From 1e57319181fb422933dcfdda09d6bcc5b7046d17 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 20 Jan 2026 19:45:29 +0000 Subject: [PATCH 01/36] Remove random provider selection --- packages/detector/src/index.d.ts | 30 +++++------ packages/procaptcha-common/src/providers.ts | 38 ++++++++++--- .../src/customDetectBot.ts | 12 ++--- .../getFrictionlessCaptchaChallenge.ts | 21 -------- .../src/tasks/detection/getBotScore.ts | 5 -- .../tasks/frictionless/frictionlessTasks.ts | 53 +------------------ ...tFrictionlessCaptchaChallenge.unit.test.ts | 6 +-- .../frictionless/decryptPayload.unit.test.ts | 5 -- .../frictionlessTasks.unit.test.ts | 6 --- .../tasks/powCaptcha/powTasks.unit.test.ts | 2 - packages/types-database/src/types/provider.ts | 3 -- packages/types/src/provider/detection.ts | 1 - 12 files changed, 54 insertions(+), 128 deletions(-) diff --git a/packages/detector/src/index.d.ts b/packages/detector/src/index.d.ts index 2fab21d147..5855425fd0 100644 --- a/packages/detector/src/index.d.ts +++ b/packages/detector/src/index.d.ts @@ -1,28 +1,26 @@ -// 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. +// 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 { Account } from '@prosopo/types'; import { BehavioralData } from '@prosopo/types'; import { ClickEventPoint } from '@prosopo/types'; import { EnvironmentTypes } from '@prosopo/types'; import { MouseMovementPoint } from '@prosopo/types'; import { PackedBehavioralData } from '@prosopo/types'; -import { RandomProvider } from '@prosopo/types'; import { TouchEventPoint } from '@prosopo/types'; -declare const detect: (env: EnvironmentTypes, randomProviderSelectorFn: RandomProviderSelectorFn, container: HTMLElement | undefined, restart: () => void, accountGenerator: () => Promise) => Promise<{ +declare const detect: (env: EnvironmentTypes, container: HTMLElement | undefined, restart: () => void, accountGenerator: () => Promise) => Promise<{ token: string; - provider?: RandomProvider; shadowDomCleanup: () => void; encryptHeadHash: string; mouseTracker?: { diff --git a/packages/procaptcha-common/src/providers.ts b/packages/procaptcha-common/src/providers.ts index 8f68618ef6..3fe0b08ae5 100644 --- a/packages/procaptcha-common/src/providers.ts +++ b/packages/procaptcha-common/src/providers.ts @@ -12,15 +12,41 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { getRandomActiveProvider } from "@prosopo/load-balancer"; -import type { EnvironmentTypes } from "@prosopo/types"; +import type { EnvironmentTypes, RandomProvider } from "@prosopo/types"; +/** + * Returns a static DNS endpoint for the given environment. + * DNS-based load balancing is handled at the DNS level. + * + * @param defaultEnvironment - The environment (development, staging, production) + * @returns Provider information with DNS endpoint + */ export const getProcaptchaRandomActiveProvider = async ( defaultEnvironment: EnvironmentTypes, -) => { - const randomNumberU8a = window.crypto.getRandomValues(new Uint8Array(10)); - const randomNumber = randomNumberU8a.reduce((a, b) => a + b, 0); - return await getRandomActiveProvider(defaultEnvironment, randomNumber); +): Promise => { + let url: string; + + switch (defaultEnvironment) { + case "development": + url = "http://localhost:9229"; + break; + case "staging": + url = "https://staging.pronode.prosopo.io"; + break; + case "production": + url = "https://pronode.prosopo.io"; + break; + default: + url = "http://localhost:9229"; + } + + return { + providerAccount: "provider-dns-endpoint", // Placeholder, actual provider determined by DNS + provider: { + url, + datasetId: "default", + }, + }; }; export const providerRetry = async ( diff --git a/packages/procaptcha-frictionless/src/customDetectBot.ts b/packages/procaptcha-frictionless/src/customDetectBot.ts index 368176475a..2120cd6641 100644 --- a/packages/procaptcha-frictionless/src/customDetectBot.ts +++ b/packages/procaptcha-frictionless/src/customDetectBot.ts @@ -58,7 +58,6 @@ const customDetectBot: BotDetectionFunction = async ( const detect = await DetectorLoader(); const detectionResult = await detect( config.defaultEnvironment, - getRandomActiveProvider, container, restartFn, () => ext.getAccount(config), @@ -70,12 +69,11 @@ const customDetectBot: BotDetectionFunction = async ( throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING"); } - // Get random active provider with timeout - const provider = detectionResult.provider; - - if (!provider) { - throw new Error("Provider Selection Failed"); - } + // Get provider from DNS-based endpoint (no random selection needed) + const provider = await getRandomActiveProvider( + config.defaultEnvironment, + 0, // entropy not used for selection anymore, DNS handles it + ); const providerApi = new ProviderApi( provider.provider.url, diff --git a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge.ts b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge.ts index 2b16ee1993..635315adb5 100644 --- a/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge.ts +++ b/packages/provider/src/api/captcha/getFrictionlessCaptchaChallenge.ts @@ -82,7 +82,6 @@ export default ( scoreComponents: { baseScore: 0, }, - providerSelectEntropy: 0, ipAddress: getCompositeIpAddress(req.ip || ""), webView: false, iFrame: false, @@ -141,7 +140,6 @@ export default ( const { baseBotScore, timestamp, - providerSelectEntropy, userId, userAgent, webView, @@ -155,7 +153,6 @@ export default ( data: { baseBotScore, timestamp, - providerSelectEntropy, userId, userAgent, webView, @@ -214,7 +211,6 @@ export default ( score: botScore, threshold: botThreshold, scoreComponents, - providerSelectEntropy, ipAddress, webView, iFrame, @@ -418,23 +414,6 @@ export default ( ); } - // If the host is not verified, send an image captcha - const hostVerified = await tasks.frictionlessManager.hostVerified( - providerSelectEntropy, - ); - if (!hostVerified.verified) { - const scoreUpdate = - tasks.frictionlessManager.scoreIncreaseUnverifiedHost( - hostVerified.domain, - baseBotScore, - botScore, - scoreComponents, - ); - botScore = scoreUpdate.score; - scoreComponents = scoreUpdate.scoreComponents; - tasks.frictionlessManager.updateScore(botScore, scoreComponents); - } - // If the bot score is greater than the threshold, send an image captcha if (Number(botScore) > botThreshold) { req.logger.info(() => ({ diff --git a/packages/provider/src/tasks/detection/getBotScore.ts b/packages/provider/src/tasks/detection/getBotScore.ts index 584fe85256..fe0110e6fd 100644 --- a/packages/provider/src/tasks/detection/getBotScore.ts +++ b/packages/provider/src/tasks/detection/getBotScore.ts @@ -15,8 +15,6 @@ import type { DetectorResult } from "@prosopo/types"; import getBotScoreFromPayload from "./decodePayload.js"; -const DEFAULT_ENTROPY = 13837; - export const getBotScore = async ( payload: string, headHash: string, @@ -29,7 +27,6 @@ export const getBotScore = async ( )) as DetectorResult; const baseBotScore: number = result.score; const timestamp: number = result.timestamp; - const providerSelectEntropy: number = result.providerSelectEntropy; const userId: string = result.userId; const userAgent: string = result.userAgent; const isWebView: boolean = result.isWebView ?? false; @@ -40,14 +37,12 @@ export const getBotScore = async ( return { baseBotScore: 1, timestamp: 0, - providerSelectEntropy: DEFAULT_ENTROPY, }; } return { baseBotScore, timestamp, - providerSelectEntropy, userId, userAgent, isWebView, diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index 61c31e70be..f67e176e8e 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -34,18 +34,7 @@ import { checkLangRules } from "../../rules/lang.js"; import { CaptchaManager } from "../captchaManager.js"; import { getBotScore } from "../detection/getBotScore.js"; -const getDefaultEntropy = (): number => { - if (process.env.PROSOPO_ENTROPY) { - const parsed = Number.parseInt(process.env.PROSOPO_ENTROPY); - if (!Number.isNaN(parsed)) { - return parsed; - } - // ignore invalid value and return default - } - return 13337; -}; const DEFAULT_MAX_TIMESTAMP_AGE = 60 * 10 * 1000; // 10 minutes -export const DEFAULT_ENTROPY = getDefaultEntropy(); const getSessionIDPrefix = (host?: string): string => { return host ? host.replace(".prosopo.io", "") : "local"; @@ -88,7 +77,6 @@ export class FrictionlessManager extends CaptchaManager { score: params.score, threshold: params.threshold, scoreComponents: params.scoreComponents, - providerSelectEntropy: params.providerSelectEntropy, ipAddress: params.ipAddress, webView: params.webView ?? false, iFrame: params.iFrame ?? false, @@ -112,7 +100,6 @@ export class FrictionlessManager extends CaptchaManager { score: number, threshold: number, scoreComponents: ScoreComponents, - providerSelectEntropy: number, ipAddress: CompositeIpAddress, captchaType: CaptchaType, solvedImagesCount?: number, @@ -130,7 +117,6 @@ export class FrictionlessManager extends CaptchaManager { score, threshold, scoreComponents, - providerSelectEntropy, ipAddress, captchaType, solvedImagesCount, @@ -146,27 +132,6 @@ export class FrictionlessManager extends CaptchaManager { return sessionRecord; } - async hostVerified(entropy: number) { - const chosen = await getRandomActiveProvider( - this.config.defaultEnvironment, - entropy, - ); - - const domain = new URL(chosen.provider.url).hostname; - this.logger.info(() => ({ - data: { entropy, host: this.config.host, domain }, - })); - - if (domain !== this.config.host) { - this.logger.info(() => ({ - msg: "Host mismatch", - data: { expected: this.config.host, got: domain, entropy }, - })); - return { verified: false, domain }; - } - - return { verified: true, domain }; - } async sendImageCaptcha( params?: Partial, @@ -177,7 +142,6 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.score === undefined || effectiveParams.threshold === undefined || !effectiveParams.scoreComponents || - effectiveParams.providerSelectEntropy === undefined || !effectiveParams.ipAddress ) { throw new Error( @@ -190,7 +154,6 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.score, effectiveParams.threshold, effectiveParams.scoreComponents, - effectiveParams.providerSelectEntropy, effectiveParams.ipAddress, CaptchaType.image, effectiveParams.solvedImagesCount, @@ -217,7 +180,6 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.score === undefined || effectiveParams.threshold === undefined || !effectiveParams.scoreComponents || - effectiveParams.providerSelectEntropy === undefined || !effectiveParams.ipAddress ) { throw new Error( @@ -230,7 +192,6 @@ export class FrictionlessManager extends CaptchaManager { effectiveParams.score, effectiveParams.threshold, effectiveParams.scoreComponents, - effectiveParams.providerSelectEntropy, effectiveParams.ipAddress, CaptchaType.pow, undefined, @@ -374,7 +335,6 @@ export class FrictionlessManager extends CaptchaManager { // if we run out of keys and the score is still not decrypted, throw an error let baseBotScore: number | undefined; let timestamp: number | undefined; - let providerSelectEntropy: number | undefined; let userId: string | undefined; let userAgent: string | undefined; let webView: boolean | undefined; @@ -393,7 +353,6 @@ export class FrictionlessManager extends CaptchaManager { decryptedHeadHash = decrypted.decryptedHeadHash || ""; const s = decrypted.baseBotScore; const t = decrypted.timestamp; - const p = decrypted.providerSelectEntropy; const a = decrypted.userId; const u = decrypted.userAgent; const w = decrypted.isWebView; @@ -404,7 +363,6 @@ export class FrictionlessManager extends CaptchaManager { key: this.redactKeyForLogging(key), baseBotScore: s, timestamp: t, - entropy: p, userId: a, userAgent: u, webView: w, @@ -413,7 +371,6 @@ export class FrictionlessManager extends CaptchaManager { })); baseBotScore = s; timestamp = t; - providerSelectEntropy = p; userId = a; userAgent = u; webView = w; @@ -427,7 +384,6 @@ export class FrictionlessManager extends CaptchaManager { })); baseBotScore = 1; timestamp = 0; - providerSelectEntropy = DEFAULT_ENTROPY + 1; decryptedHeadHash = ""; decryptionFailed = true; } @@ -436,18 +392,15 @@ export class FrictionlessManager extends CaptchaManager { const baseBotScoreUndefined = baseBotScore === undefined; const timestampUndefined = timestamp === undefined; - const providerSelectEntropyUndefined = providerSelectEntropy === undefined; const undefinedCount = Number(baseBotScoreUndefined) + - Number(timestampUndefined) + - Number(providerSelectEntropyUndefined); + Number(timestampUndefined); if (undefinedCount > 0) { this.logger.error(() => ({ - msg: "Error decrypting score: baseBotScore or timestamp or providerSelectEntropy is undefined", + msg: "Error decrypting score: baseBotScore or timestamp is undefined", })); baseBotScore = 1; timestamp = 0; - providerSelectEntropy = DEFAULT_ENTROPY - undefinedCount; decryptedHeadHash = ""; decryptionFailed = true; } @@ -456,7 +409,6 @@ export class FrictionlessManager extends CaptchaManager { data: { baseBotScore: baseBotScore, timestamp: timestamp, - entropy: providerSelectEntropy, userId, userAgent, webView, @@ -470,7 +422,6 @@ export class FrictionlessManager extends CaptchaManager { return { baseBotScore: Number(baseBotScore), timestamp: Number(timestamp), - providerSelectEntropy: Number(providerSelectEntropy), userId, userAgent, webView: webView || false, diff --git a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts index f6928afb36..76eceed1f4 100644 --- a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts +++ b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts @@ -227,8 +227,7 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { tasksInstance.frictionlessManager.decryptPayload.mockResolvedValue({ baseBotScore: 0, timestamp: Date.now(), - providerSelectEntropy: 0, - userId: "u", + userId: "user123", userAgent: "844bc172f032bdd2d0baae3536c1d66c", webView: true, iFrame: false, @@ -258,7 +257,6 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { tasksInstance.frictionlessManager.decryptPayload.mockResolvedValueOnce({ baseBotScore: 0, timestamp: Date.now(), - providerSelectEntropy: 0, userId: "u", userAgent: "844bc172f032bdd2d0baae3536c1d66c", webView: false, @@ -298,7 +296,6 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { tasksInstance.frictionlessManager.decryptPayload.mockResolvedValue({ baseBotScore: 0, timestamp: Date.now(), - providerSelectEntropy: 0, userId: "u", userAgent: "844bc172f032bdd2d0baae3536c1d66c", webView: true, // even if webView true @@ -342,7 +339,6 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { tasksInstance.frictionlessManager.decryptPayload.mockResolvedValue({ baseBotScore: 0, timestamp: Date.now(), - providerSelectEntropy: 0, userId: "u", userAgent: "844bc172f032bdd2d0baae3536c1d66c", webView: false, // even if webView false diff --git a/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts b/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts index 46c6b39933..91ba415aa2 100644 --- a/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts @@ -64,7 +64,6 @@ describe("decryptPayload", () => { return { baseBotScore: 1, timestamp: Date.now(), - providerSelectEntropy: 1, }; }), })); @@ -81,7 +80,6 @@ describe("decryptPayload", () => { expect(result).toEqual({ baseBotScore: 1, timestamp: expect.any(Number), - providerSelectEntropy: 1, userId: undefined, userAgent: undefined, webView: false, @@ -115,7 +113,6 @@ describe("decryptPayload", () => { expect(result).toEqual({ baseBotScore: 1, timestamp: expect.any(Number), - providerSelectEntropy: fmImport.DEFAULT_ENTROPY - 1, userId: undefined, userAgent: undefined, webView: false, @@ -152,7 +149,6 @@ describe("decryptPayload", () => { expect(result).toEqual({ baseBotScore: 1, timestamp: expect.any(Number), - providerSelectEntropy: fmImport.DEFAULT_ENTROPY + 1, userId: undefined, userAgent: undefined, webView: false, @@ -193,7 +189,6 @@ describe("decryptPayload", () => { expect(result).toEqual({ baseBotScore: 1, timestamp: expect.any(Number), - providerSelectEntropy: fmImport.DEFAULT_ENTROPY - 3, userId: undefined, userAgent: undefined, webView: false, diff --git a/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts index 743105cd7d..1f921f9b4b 100644 --- a/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/frictionless/frictionlessTasks.unit.test.ts @@ -125,7 +125,6 @@ describe("Frictionless Task Manager", () => { const mockScore = 0.5; const mockThreshold = 0.7; const mockScoreComponents = { baseScore: 0.5 }; - const mockEntropy = 12345; const mockIpAddress = getCompositeIpAddress("127.0.0.1"); // biome-ignore lint/suspicious/noExplicitAny: tests @@ -136,7 +135,6 @@ describe("Frictionless Task Manager", () => { mockScore, mockThreshold, mockScoreComponents, - mockEntropy, mockIpAddress, CaptchaType.image, ); @@ -155,7 +153,6 @@ describe("Frictionless Task Manager", () => { const mockScore = 0.5; const mockThreshold = 0.7; const mockScoreComponents = { baseScore: 0.5 }; - const mockEntropy = 12345; const mockIpAddress = getCompositeIpAddress("127.0.0.1"); // biome-ignore lint/suspicious/noExplicitAny: tests @@ -166,7 +163,6 @@ describe("Frictionless Task Manager", () => { score: mockScore, threshold: mockThreshold, scoreComponents: mockScoreComponents, - providerSelectEntropy: mockEntropy, ipAddress: mockIpAddress, webView: false, iFrame: false, @@ -187,7 +183,6 @@ describe("Frictionless Task Manager", () => { const mockScore = 0.5; const mockThreshold = 0.7; const mockScoreComponents = { baseScore: 0.5 }; - const mockEntropy = 12345; const mockIpAddress = getCompositeIpAddress("127.0.0.1"); // biome-ignore lint/suspicious/noExplicitAny: tests @@ -198,7 +193,6 @@ describe("Frictionless Task Manager", () => { score: mockScore, threshold: mockThreshold, scoreComponents: mockScoreComponents, - providerSelectEntropy: mockEntropy, ipAddress: mockIpAddress, webView: false, iFrame: false, diff --git a/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts b/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts index 207f1a2dd8..6307ea0cd1 100644 --- a/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/powCaptcha/powTasks.unit.test.ts @@ -395,7 +395,6 @@ describe("PowCaptchaManager", () => { score: 0.5, threshold: 0.5, scoreComponents: { baseScore: 0.5 }, - providerSelectEntropy: 13337, ipAddress: getCompositeIpAddress(ipAddress), captchaType: CaptchaType.pow, webView: false, @@ -479,7 +478,6 @@ describe("PowCaptchaManager", () => { score: 0.5, threshold: 0.5, scoreComponents: { baseScore: 0.5 }, - providerSelectEntropy: 13337, ipAddress: getCompositeIpAddress(ipAddress), captchaType: CaptchaType.pow, webView: false, diff --git a/packages/types-database/src/types/provider.ts b/packages/types-database/src/types/provider.ts index 7318d2a3fc..e254953516 100644 --- a/packages/types-database/src/types/provider.ts +++ b/packages/types-database/src/types/provider.ts @@ -486,7 +486,6 @@ export type Session = { score: number; threshold: number; scoreComponents: ScoreComponents; - providerSelectEntropy: number; ipAddress: CompositeIpAddress; captchaType: CaptchaType; solvedImagesCount?: number; @@ -517,7 +516,6 @@ export const SessionRecordSchema = new Schema({ unverifiedHost: { type: Number, required: false }, webView: { type: Number, required: false }, }, - providerSelectEntropy: { type: Number, required: true }, ipAddress: CompositeIpAddressRecordSchemaObj, captchaType: { type: String, enum: CaptchaType, required: true }, solvedImagesCount: { type: Number, required: false }, @@ -536,7 +534,6 @@ SessionRecordSchema.index({ createdAt: 1 }); SessionRecordSchema.index({ deleted: 1 }); SessionRecordSchema.index({ sessionId: 1 }, { unique: true }); SessionRecordSchema.index({ userSitekeyIpHash: 1 }); -SessionRecordSchema.index({ providerSelectEntropy: 1 }); SessionRecordSchema.index({ token: 1 }); export type DetectorKey = { diff --git a/packages/types/src/provider/detection.ts b/packages/types/src/provider/detection.ts index d21235143e..11c7c367a2 100644 --- a/packages/types/src/provider/detection.ts +++ b/packages/types/src/provider/detection.ts @@ -14,7 +14,6 @@ export type DetectorResult = { score: number; timestamp: number; - providerSelectEntropy: number; userId: string; userAgent: string; isWebView?: boolean; From 2d7b82acefdaa29cd69478bb1872e85cf3b126fc Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 20 Jan 2026 19:45:35 +0000 Subject: [PATCH 02/36] lint-fix --- .../provider/src/tasks/frictionless/frictionlessTasks.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts index f67e176e8e..c4849a8f3a 100644 --- a/packages/provider/src/tasks/frictionless/frictionlessTasks.ts +++ b/packages/provider/src/tasks/frictionless/frictionlessTasks.ts @@ -13,7 +13,6 @@ // limitations under the License. import type { Logger } from "@prosopo/common"; -import { getRandomActiveProvider } from "@prosopo/load-balancer"; import { ApiParams, CaptchaType, @@ -132,7 +131,6 @@ export class FrictionlessManager extends CaptchaManager { return sessionRecord; } - async sendImageCaptcha( params?: Partial, ): Promise { @@ -393,8 +391,7 @@ export class FrictionlessManager extends CaptchaManager { const baseBotScoreUndefined = baseBotScore === undefined; const timestampUndefined = timestamp === undefined; const undefinedCount = - Number(baseBotScoreUndefined) + - Number(timestampUndefined); + Number(baseBotScoreUndefined) + Number(timestampUndefined); if (undefinedCount > 0) { this.logger.error(() => ({ msg: "Error decrypting score: baseBotScore or timestamp is undefined", From f238f33d03cdcd38f34dd171eb531d6f5bd32357 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 20 Jan 2026 19:46:13 +0000 Subject: [PATCH 03/36] docs(changeset): Remove random provider selection fn in favour of DNS routing --- .changeset/khaki-needles-grab.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/khaki-needles-grab.md diff --git a/.changeset/khaki-needles-grab.md b/.changeset/khaki-needles-grab.md new file mode 100644 index 0000000000..b275d5f686 --- /dev/null +++ b/.changeset/khaki-needles-grab.md @@ -0,0 +1,11 @@ +--- +"@prosopo/procaptcha-frictionless": minor +"@prosopo/procaptcha-common": minor +"@prosopo/types-database": minor +"@prosopo/detector": minor +"@prosopo/provider": minor +"@prosopo/types": minor +--- + +Remove random provider selection fn in favour of DNS routing + \ No newline at end of file From a6959e06037ed6970df5fbbed18b3a7045cadda9 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 20 Jan 2026 19:53:56 +0000 Subject: [PATCH 04/36] re-add license --- packages/detector/src/index.d.ts | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/packages/detector/src/index.d.ts b/packages/detector/src/index.d.ts index 5855425fd0..a70e2a9af8 100644 --- a/packages/detector/src/index.d.ts +++ b/packages/detector/src/index.d.ts @@ -1,16 +1,17 @@ -// 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. +// 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 { Account } from '@prosopo/types'; import { BehavioralData } from '@prosopo/types'; import { ClickEventPoint } from '@prosopo/types'; From fe7af3bd10cde2356a00879f012a51d41d3a3f90 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 20 Jan 2026 20:18:26 +0000 Subject: [PATCH 05/36] remove weighted logic --- packages/load-balancer/src/providers.ts | 76 ++--- .../src/tests/providers.unit.test.ts | 313 +++++------------- .../src/tests/providers.test.ts | 3 +- 3 files changed, 114 insertions(+), 278 deletions(-) diff --git a/packages/load-balancer/src/providers.ts b/packages/load-balancer/src/providers.ts index 7963fb93b6..b906ccd764 100644 --- a/packages/load-balancer/src/providers.ts +++ b/packages/load-balancer/src/providers.ts @@ -22,59 +22,53 @@ export function _resetCache() { } /** - * Selects a weighted random provider using the entropy value. - * Providers with higher weights are more likely to be selected. + * Gets the DNS-based provider URL for the given environment. + * Uses single DNS endpoint with latency-based routing at the DNS level. * - * @param providers - Array of providers with weights - * @param entropy - Random seed value for deterministic selection - * @returns Selected provider + * @param env - The environment (development, staging, production) + * @param _entropy - (Deprecated) Previously used for provider selection, now ignored as DNS handles load balancing + * @returns Provider URL and account information */ -export function selectWeightedProvider( - providers: HardcodedProvider[], - entropy: number, -): HardcodedProvider { - if (providers.length === 0) { - throw new Error("No providers available"); - } - - const totalWeight = providers.reduce((sum, p) => sum + p.weight, 0); - - // Use entropy to generate a value between 0 and totalWeight-1 - const randomValue = entropy % totalWeight; - - // Select provider based on cumulative weight - let cumulativeWeight = 0; - for (const provider of providers) { - cumulativeWeight += provider.weight; - if (randomValue < cumulativeWeight) { - return provider; - } - } - - // Fallback (should never reach here) - const selectedProvider = providers[providers.length - 1]; - if (!selectedProvider) { - throw new Error("No providers available"); - } - return selectedProvider; -} - export const getRandomActiveProvider = async ( env: EnvironmentTypes, - entropy: number, + _entropy?: number, ): Promise => { + // DNS handles the load balancing now, entropy parameter is ignored + + if (env === "development") { + // Development uses localhost + return { + providerAccount: "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", + provider: { + url: "http://localhost:9229", + datasetId: + "0x9f460e81ac9c71b486f796a21bb36e2263694756a6621134d110da217fd3ef25", + }, + }; + } + + // Get provider list to extract account and datasetId info if (cachedProviders.length === 0) { - // only get the providers JSON once cachedProviders = await loadBalancer(env); } - const randomProviderObj = selectWeightedProvider(cachedProviders, entropy); + // Use the first provider's account info (they should all be the same cluster) + const firstProvider = cachedProviders[0]; + if (!firstProvider) { + throw new Error("No providers available"); + } + + // Use DNS-based endpoint + const dnsUrl = + env === "staging" + ? "https://staging.pronode.prosopo.io" + : "https://pronode.prosopo.io"; return { - providerAccount: randomProviderObj.address, + providerAccount: firstProvider.address, provider: { - url: randomProviderObj.url, - datasetId: randomProviderObj.datasetId, + url: dnsUrl, + datasetId: firstProvider.datasetId, }, }; }; diff --git a/packages/load-balancer/src/tests/providers.unit.test.ts b/packages/load-balancer/src/tests/providers.unit.test.ts index bafd9f6ba8..6ba8f23d5e 100644 --- a/packages/load-balancer/src/tests/providers.unit.test.ts +++ b/packages/load-balancer/src/tests/providers.unit.test.ts @@ -13,253 +13,89 @@ // limitations under the License. import { beforeEach, describe, expect, it, vi } from "vitest"; -import { type HardcodedProvider, loadBalancer } from "../index.js"; -import { - getRandomActiveProvider, - selectWeightedProvider, -} from "../providers.js"; +import { loadBalancer } from "../index.js"; +import { getRandomActiveProvider } from "../providers.js"; import { _resetCache } from "../providers.js"; vi.mock("../index.js", () => ({ loadBalancer: vi.fn(), })); -describe("selectWeightedProvider", () => { - it("selects provider based on weight distribution", () => { - const providers = [ - { - address: "address1", - url: "url1", - datasetId: "dataset1", - weight: 1, - }, - { - address: "address2", - url: "url2", - datasetId: "dataset2", - weight: 3, - }, - { - address: "address3", - url: "url3", - datasetId: "dataset3", - weight: 1, - }, - ]; - - // Total weight = 5 - // Provider 1: weight 1 (covers entropy 0-0) - // Provider 2: weight 3 (covers entropy 1-3) - // Provider 3: weight 1 (covers entropy 4-4) - - // Entropy 0 should select provider1 - expect(selectWeightedProvider(providers, 0).address).toBe("address1"); - - // Entropy 1-3 should select provider2 - expect(selectWeightedProvider(providers, 1).address).toBe("address2"); - expect(selectWeightedProvider(providers, 2).address).toBe("address2"); - expect(selectWeightedProvider(providers, 3).address).toBe("address2"); - - // Entropy 4 should select provider3 - expect(selectWeightedProvider(providers, 4).address).toBe("address3"); - - // Entropy wraps around with modulo - expect(selectWeightedProvider(providers, 5).address).toBe("address1"); - expect(selectWeightedProvider(providers, 6).address).toBe("address2"); - }); - - it("handles equal weights correctly", () => { - const providers = [ - { - address: "address1", - url: "url1", - datasetId: "dataset1", - weight: 1, - }, - { - address: "address2", - url: "url2", - datasetId: "dataset2", - weight: 1, - }, - ]; - - // Total weight = 2 - expect(selectWeightedProvider(providers, 0).address).toBe("address1"); - expect(selectWeightedProvider(providers, 1).address).toBe("address2"); - expect(selectWeightedProvider(providers, 2).address).toBe("address1"); - expect(selectWeightedProvider(providers, 3).address).toBe("address2"); +describe("getRandomActiveProvider", () => { + beforeEach(() => { + _resetCache(); + vi.clearAllMocks(); + vi.resetAllMocks(); + vi.resetModules(); }); - it("handles single provider", () => { - const providers = [ - { - address: "address1", - url: "url1", - datasetId: "dataset1", - weight: 10, - }, - ]; - - // All entropy values should select the only provider - expect(selectWeightedProvider(providers, 0).address).toBe("address1"); - expect(selectWeightedProvider(providers, 5).address).toBe("address1"); - expect(selectWeightedProvider(providers, 100).address).toBe("address1"); - }); + it("returns localhost for development environment", async () => { + const result = await getRandomActiveProvider("development"); - it("throws error for empty provider list", () => { - expect(() => selectWeightedProvider([], 0)).toThrow( - "No providers available", + expect(result.providerAccount).toBe( + "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", + ); + expect(result.provider.url).toBe("http://localhost:9229"); + expect(result.provider.datasetId).toBe( + "0x9f460e81ac9c71b486f796a21bb36e2263694756a6621134d110da217fd3ef25", ); }); - it("heavily weighted provider is selected more often", () => { - const providers = [ + it("returns DNS-based URL for staging environment", async () => { + const mockProviders = [ { address: "address1", - url: "url1", + url: "https://pronode1.prosopo.io", datasetId: "dataset1", weight: 1, }, - { - address: "address2", - url: "url2", - datasetId: "dataset2", - weight: 99, - }, - ]; - - // Total weight = 100 - // Provider 1: entropy 0 (1% of the time) - // Provider 2: entropy 1-99 (99% of the time) - - const selections = { address1: 0, address2: 0 }; - for (let i = 0; i < 100; i++) { - const selected = selectWeightedProvider(providers, i); - if (selected.address === "address1") { - selections.address1++; - } else { - selections.address2++; - } - } - - expect(selections.address1).toBe(1); - expect(selections.address2).toBe(99); - }); - - it("handles maximum weight value (100)", () => { - const providers = [ - { - address: "address1", - url: "url1", - datasetId: "dataset1", - weight: 100, - }, - { - address: "address2", - url: "url2", - datasetId: "dataset2", - weight: 100, - }, ]; + (loadBalancer as unknown as ReturnType).mockResolvedValue( + mockProviders, + ); - // Total weight = 200 - const selections = { address1: 0, address2: 0 }; - for (let i = 0; i < 200; i++) { - const selected = selectWeightedProvider(providers, i); - if (selected.address === "address1") { - selections.address1++; - } else { - selections.address2++; - } - } + const result = await getRandomActiveProvider("staging"); - // Each should get selected 100 times (50%) - expect(selections.address1).toBe(100); - expect(selections.address2).toBe(100); + expect(result.providerAccount).toBe("address1"); + expect(result.provider.url).toBe("https://staging.pronode.prosopo.io"); + expect(result.provider.datasetId).toBe("dataset1"); }); - it("correctly handles providers without values for weight", () => { - // Providers without weight field should default to weight 1 - const providers = [ + it("returns DNS-based URL for production environment", async () => { + const mockProviders = [ { address: "address1", - url: "url1", + url: "https://pronode1.prosopo.io", datasetId: "dataset1", - // No weight field - }, - { - address: "address2", - url: "url2", - datasetId: "dataset2", - weight: 3, + weight: 1, }, ]; - - // Mock the providers to simulate what comes from the API - // The real providers will have weight added by zod schema default - const providersWithDefaults = [ - { ...providers[0], weight: 1 }, - providers[1], - ]; - - // Total weight = 4 (1 + 3) - // address1 (weight 1) should get entropy 0 (25%) - // address2 (weight 3) should get entropy 1-3 (75%) - - const selections = { address1: 0, address2: 0 }; - for (let i = 0; i < 100; i++) { - const selected = selectWeightedProvider( - providersWithDefaults as HardcodedProvider[], - i, - ); - if (selected.address === "address1") { - selections.address1++; - } else { - selections.address2++; - } - } - - // address1 should get ~25% and address2 should get ~75% - expect(selections.address1).toBe(25); - expect(selections.address2).toBe(75); - }); -}); - -describe("getRandomActiveProvider", () => { - beforeEach(() => { - _resetCache(); - vi.clearAllMocks(); - vi.resetAllMocks(); - vi.resetModules(); - }); - - it("returns a random provider when providers list is populated", async () => { - const mockProviders = [ - { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 }, - { address: "address2", url: "url2", datasetId: "dataset2", weight: 1 }, - ]; (loadBalancer as unknown as ReturnType).mockResolvedValue( mockProviders, ); - const result = await getRandomActiveProvider("development", 1); + const result = await getRandomActiveProvider("production"); - expect(result.providerAccount).toBe("address2"); - expect(result.provider.url).toBe("url2"); - expect(result.provider.datasetId).toBe("dataset2"); + expect(result.providerAccount).toBe("address1"); + expect(result.provider.url).toBe("https://pronode.prosopo.io"); + expect(result.provider.datasetId).toBe("dataset1"); }); it("loads providers only once when called multiple times", async () => { const mockProviders = [ - { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 }, + { + address: "address1", + url: "https://pronode1.prosopo.io", + datasetId: "dataset1", + weight: 1, + }, ]; (loadBalancer as unknown as ReturnType).mockResolvedValue( mockProviders, ); - await getRandomActiveProvider("development", 123); - await getRandomActiveProvider("development", 456); + await getRandomActiveProvider("staging"); + await getRandomActiveProvider("staging"); expect(loadBalancer).toHaveBeenCalledTimes(1); }); @@ -267,54 +103,59 @@ describe("getRandomActiveProvider", () => { it("handles empty providers list gracefully", async () => { (loadBalancer as unknown as ReturnType).mockResolvedValue([]); - await expect(getRandomActiveProvider("development", 123)).rejects.toThrow(); + await expect(getRandomActiveProvider("staging")).rejects.toThrow( + "No providers available", + ); }); - it("respects provider weights when selecting", async () => { + it("accepts entropy parameter for backward compatibility but ignores it", async () => { const mockProviders = [ - { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 }, - { address: "address2", url: "url2", datasetId: "dataset2", weight: 3 }, + { + address: "address1", + url: "https://pronode1.prosopo.io", + datasetId: "dataset1", + weight: 1, + }, ]; (loadBalancer as unknown as ReturnType).mockResolvedValue( mockProviders, ); - // Total weight = 4 - // address1 gets entropy 0 (25%) - // address2 gets entropy 1-3 (75%) - - const result0 = await getRandomActiveProvider("development", 0); - expect(result0.providerAccount).toBe("address1"); - + // Both calls should return the same DNS URL regardless of entropy + const result1 = await getRandomActiveProvider("production", 0); _resetCache(); - const result1 = await getRandomActiveProvider("development", 1); - expect(result1.providerAccount).toBe("address2"); + const result2 = await getRandomActiveProvider("production", 999); - _resetCache(); - const result2 = await getRandomActiveProvider("development", 2); - expect(result2.providerAccount).toBe("address2"); - - _resetCache(); - const result3 = await getRandomActiveProvider("development", 3); - expect(result3.providerAccount).toBe("address2"); + expect(result1.provider.url).toBe("https://pronode.prosopo.io"); + expect(result2.provider.url).toBe("https://pronode.prosopo.io"); + expect(result1.provider.url).toBe(result2.provider.url); }); - it("handles providers with missing weight field (defaults to 1)", async () => { - // Simulate providers returned from loadBalancer where one has weight and one doesn't + it("uses account info from first provider in the list", async () => { const mockProviders = [ - { address: "address1", url: "url1", datasetId: "dataset1", weight: 1 }, - { address: "address2", url: "url2", datasetId: "dataset2", weight: 1 }, + { + address: "mainAccount", + url: "https://pronode1.prosopo.io", + datasetId: "mainDataset", + weight: 1, + }, + { + address: "otherAccount", + url: "https://pronode2.prosopo.io", + datasetId: "otherDataset", + weight: 1, + }, ]; (loadBalancer as unknown as ReturnType).mockResolvedValue( mockProviders, ); - // With equal weights, distribution should be 50/50 - const result0 = await getRandomActiveProvider("development", 0); - expect(result0.providerAccount).toBe("address1"); + const result = await getRandomActiveProvider("production"); - _resetCache(); - const result1 = await getRandomActiveProvider("development", 1); - expect(result1.providerAccount).toBe("address2"); + // Should use the first provider's account info + expect(result.providerAccount).toBe("mainAccount"); + expect(result.provider.datasetId).toBe("mainDataset"); + // But URL should be DNS-based + expect(result.provider.url).toBe("https://pronode.prosopo.io"); }); }); diff --git a/packages/procaptcha-common/src/tests/providers.test.ts b/packages/procaptcha-common/src/tests/providers.test.ts index da1fb55a61..4131e2cc53 100644 --- a/packages/procaptcha-common/src/tests/providers.test.ts +++ b/packages/procaptcha-common/src/tests/providers.test.ts @@ -59,7 +59,8 @@ describe("providers", () => { const result = await getProcaptchaRandomActiveProvider("development"); expect(mockGetRandomValues).toHaveBeenCalledWith(expect.any(Uint8Array)); - expect(getRandomActiveProvider).toHaveBeenCalledWith("development", 550); // sum of mockRandomValues + // Entropy is passed (550 = sum of mockRandomValues) but ignored by DNS-based load balancing + expect(getRandomActiveProvider).toHaveBeenCalledWith("development", 550); expect(result).toEqual({ providerUrl: "https://test-provider.com" }); }); From ca61d32538465676b28e6182724bafee142175e4 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 20 Jan 2026 22:02:49 +0000 Subject: [PATCH 06/36] Make datasetId optional --- packages/api/src/api/ProviderApi.ts | 1 - packages/database/src/databases/provider.ts | 14 +++++++++++ packages/env/src/env.ts | 24 +++++++++++++++++++ packages/load-balancer/src/providers.ts | 5 +--- .../src/tests/providers.unit.test.ts | 13 +++++----- .../api/captcha/getImageCaptchaChallenge.ts | 17 ++++++++++++- packages/types-env/src/provider.ts | 1 + packages/types/src/provider/api.ts | 3 +-- 8 files changed, 63 insertions(+), 15 deletions(-) diff --git a/packages/api/src/api/ProviderApi.ts b/packages/api/src/api/ProviderApi.ts index e236497085..d33bfb259c 100644 --- a/packages/api/src/api/ProviderApi.ts +++ b/packages/api/src/api/ProviderApi.ts @@ -63,7 +63,6 @@ export default class ProviderApi const body: CaptchaRequestBodyType = { [ApiParams.dapp]: dappAccount, [ApiParams.user]: userAccount, - [ApiParams.datasetId]: provider.datasetId, }; if (sessionId) { body[ApiParams.sessionId] = sessionId; diff --git a/packages/database/src/databases/provider.ts b/packages/database/src/databases/provider.ts index 9a4e2c9dd8..2095346a6d 100644 --- a/packages/database/src/databases/provider.ts +++ b/packages/database/src/databases/provider.ts @@ -605,6 +605,20 @@ export class ProviderDatabase await this.tables?.captcha.deleteMany(filter); } + /** + * @description Get the most recently uploaded dataset ID + */ + async getMostRecentDatasetId(): Promise { + const dataset = await this.tables?.dataset + .findOne() + .sort({ _id: -1 }) // Sort by _id descending to get most recent + .lean(); + + const datasetId = dataset?.datasetId; + // Ensure we return string | undefined, not Hash (which can be string | number[]) + return typeof datasetId === "string" ? datasetId : undefined; + } + /** * @description Get a dataset by Id */ diff --git a/packages/env/src/env.ts b/packages/env/src/env.ts index 1ee59ca287..0a1c685b5b 100644 --- a/packages/env/src/env.ts +++ b/packages/env/src/env.ts @@ -37,6 +37,7 @@ export class Environment implements ProsopoEnvironment { authAccount: KeyringPair | undefined; envId: string | undefined; ready = false; + datasetId: string | undefined; constructor( config: ProsopoConfigOutput, @@ -132,6 +133,29 @@ export class Environment implements ProsopoEnvironment { await this.db.connect(); this.logger.info(() => ({ msg: "Connected to db" })); } + + // Set the default datasetId to the most recently uploaded dataset + if (this.db && !this.datasetId) { + try { + this.datasetId = await this.db.getMostRecentDatasetId(); + if (this.datasetId) { + this.logger.info(() => ({ + msg: "Default dataset ID set", + data: { datasetId: this.datasetId }, + })); + } else { + this.logger.warn(() => ({ + msg: "No datasets found in database. Image captchas will not work until a dataset is uploaded.", + })); + } + } catch (err) { + this.logger.warn(() => ({ + msg: "Failed to get most recent dataset ID", + data: { error: err }, + })); + } + } + this.ready = true; } catch (err) { throw new ProsopoEnvError("GENERAL.ENVIRONMENT_NOT_READY", { diff --git a/packages/load-balancer/src/providers.ts b/packages/load-balancer/src/providers.ts index b906ccd764..7d013b99c8 100644 --- a/packages/load-balancer/src/providers.ts +++ b/packages/load-balancer/src/providers.ts @@ -41,13 +41,11 @@ export const getRandomActiveProvider = async ( providerAccount: "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", provider: { url: "http://localhost:9229", - datasetId: - "0x9f460e81ac9c71b486f796a21bb36e2263694756a6621134d110da217fd3ef25", }, }; } - // Get provider list to extract account and datasetId info + // Get provider list to extract account info if (cachedProviders.length === 0) { cachedProviders = await loadBalancer(env); } @@ -68,7 +66,6 @@ export const getRandomActiveProvider = async ( providerAccount: firstProvider.address, provider: { url: dnsUrl, - datasetId: firstProvider.datasetId, }, }; }; diff --git a/packages/load-balancer/src/tests/providers.unit.test.ts b/packages/load-balancer/src/tests/providers.unit.test.ts index 6ba8f23d5e..c5233cfbc9 100644 --- a/packages/load-balancer/src/tests/providers.unit.test.ts +++ b/packages/load-balancer/src/tests/providers.unit.test.ts @@ -36,9 +36,7 @@ describe("getRandomActiveProvider", () => { "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", ); expect(result.provider.url).toBe("http://localhost:9229"); - expect(result.provider.datasetId).toBe( - "0x9f460e81ac9c71b486f796a21bb36e2263694756a6621134d110da217fd3ef25", - ); + expect(result.provider).not.toHaveProperty("datasetId"); }); it("returns DNS-based URL for staging environment", async () => { @@ -58,7 +56,7 @@ describe("getRandomActiveProvider", () => { expect(result.providerAccount).toBe("address1"); expect(result.provider.url).toBe("https://staging.pronode.prosopo.io"); - expect(result.provider.datasetId).toBe("dataset1"); + expect(result.provider).not.toHaveProperty("datasetId"); }); it("returns DNS-based URL for production environment", async () => { @@ -78,7 +76,7 @@ describe("getRandomActiveProvider", () => { expect(result.providerAccount).toBe("address1"); expect(result.provider.url).toBe("https://pronode.prosopo.io"); - expect(result.provider.datasetId).toBe("dataset1"); + expect(result.provider).not.toHaveProperty("datasetId"); }); it("loads providers only once when called multiple times", async () => { @@ -154,8 +152,9 @@ describe("getRandomActiveProvider", () => { // Should use the first provider's account info expect(result.providerAccount).toBe("mainAccount"); - expect(result.provider.datasetId).toBe("mainDataset"); - // But URL should be DNS-based + // URL should be DNS-based expect(result.provider.url).toBe("https://pronode.prosopo.io"); + // datasetId should not be included + expect(result.provider).not.toHaveProperty("datasetId"); }); }); diff --git a/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts b/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts index 7db709dc5c..3f3961b3be 100644 --- a/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts +++ b/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts @@ -67,7 +67,22 @@ export default ( ); } - const { datasetId, user, dapp, sessionId } = parsed; + const { user, dapp, sessionId } = parsed; + + const datasetId = env.datasetId; + + if (!datasetId) { + return next( + new ProsopoApiError("API.BAD_REQUEST", { + context: { + code: 400, + error: "No dataset available. Please upload a dataset first.", + }, + i18n: req.i18n, + logger: req.logger, + }), + ); + } validateSiteKey(dapp); validateAddr(user); diff --git a/packages/types-env/src/provider.ts b/packages/types-env/src/provider.ts index e68b51f0f8..9c3d0c8b1a 100644 --- a/packages/types-env/src/provider.ts +++ b/packages/types-env/src/provider.ts @@ -16,4 +16,5 @@ import type { ProsopoEnvironment } from "./env.js"; export interface ProviderEnvironment extends ProsopoEnvironment { config: ProsopoConfigOutput; + datasetId?: string; } diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 80dd6368d0..cec094a875 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -159,7 +159,6 @@ export type Provider = { export type FrontendProvider = { url: string; - datasetId: string; }; export type RandomProvider = { @@ -213,7 +212,7 @@ export interface CaptchaIdAndProof { export const CaptchaRequestBody = object({ [ApiParams.user]: string(), [ApiParams.dapp]: string(), - [ApiParams.datasetId]: union([string(), array(number())]), + [ApiParams.datasetId]: union([string(), array(number())]).optional(), [ApiParams.sessionId]: string().optional(), }); From 2d0a61b0a6c8938171b9a25594cc32f1cd7c1a83 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 07:34:07 +0000 Subject: [PATCH 07/36] Remove datasetId ref --- packages/procaptcha-common/src/providers.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/procaptcha-common/src/providers.ts b/packages/procaptcha-common/src/providers.ts index 3fe0b08ae5..e197a805e1 100644 --- a/packages/procaptcha-common/src/providers.ts +++ b/packages/procaptcha-common/src/providers.ts @@ -44,7 +44,6 @@ export const getProcaptchaRandomActiveProvider = async ( providerAccount: "provider-dns-endpoint", // Placeholder, actual provider determined by DNS provider: { url, - datasetId: "default", }, }; }; From 521d49e9610ba804c405771313a4bb5794445a24 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 07:38:55 +0000 Subject: [PATCH 08/36] Remove deprecation comments and unused vars --- packages/load-balancer/src/providers.ts | 4 +--- .../src/tests/providers.unit.test.ts | 22 ------------------- .../src/tests/providers.test.ts | 4 ++-- .../src/customDetectBot.ts | 3 +-- 4 files changed, 4 insertions(+), 29 deletions(-) diff --git a/packages/load-balancer/src/providers.ts b/packages/load-balancer/src/providers.ts index 7d013b99c8..2edaeffe94 100644 --- a/packages/load-balancer/src/providers.ts +++ b/packages/load-balancer/src/providers.ts @@ -26,14 +26,12 @@ export function _resetCache() { * Uses single DNS endpoint with latency-based routing at the DNS level. * * @param env - The environment (development, staging, production) - * @param _entropy - (Deprecated) Previously used for provider selection, now ignored as DNS handles load balancing * @returns Provider URL and account information */ export const getRandomActiveProvider = async ( env: EnvironmentTypes, - _entropy?: number, ): Promise => { - // DNS handles the load balancing now, entropy parameter is ignored + // DNS handles the load balancing now if (env === "development") { // Development uses localhost diff --git a/packages/load-balancer/src/tests/providers.unit.test.ts b/packages/load-balancer/src/tests/providers.unit.test.ts index c5233cfbc9..467c7886b6 100644 --- a/packages/load-balancer/src/tests/providers.unit.test.ts +++ b/packages/load-balancer/src/tests/providers.unit.test.ts @@ -106,28 +106,6 @@ describe("getRandomActiveProvider", () => { ); }); - it("accepts entropy parameter for backward compatibility but ignores it", async () => { - const mockProviders = [ - { - address: "address1", - url: "https://pronode1.prosopo.io", - datasetId: "dataset1", - weight: 1, - }, - ]; - (loadBalancer as unknown as ReturnType).mockResolvedValue( - mockProviders, - ); - - // Both calls should return the same DNS URL regardless of entropy - const result1 = await getRandomActiveProvider("production", 0); - _resetCache(); - const result2 = await getRandomActiveProvider("production", 999); - - expect(result1.provider.url).toBe("https://pronode.prosopo.io"); - expect(result2.provider.url).toBe("https://pronode.prosopo.io"); - expect(result1.provider.url).toBe(result2.provider.url); - }); it("uses account info from first provider in the list", async () => { const mockProviders = [ diff --git a/packages/procaptcha-common/src/tests/providers.test.ts b/packages/procaptcha-common/src/tests/providers.test.ts index 4131e2cc53..5da16aae47 100644 --- a/packages/procaptcha-common/src/tests/providers.test.ts +++ b/packages/procaptcha-common/src/tests/providers.test.ts @@ -59,8 +59,8 @@ describe("providers", () => { const result = await getProcaptchaRandomActiveProvider("development"); expect(mockGetRandomValues).toHaveBeenCalledWith(expect.any(Uint8Array)); - // Entropy is passed (550 = sum of mockRandomValues) but ignored by DNS-based load balancing - expect(getRandomActiveProvider).toHaveBeenCalledWith("development", 550); + // DNS-based load balancing - no entropy parameter needed + expect(getRandomActiveProvider).toHaveBeenCalledWith("development"); expect(result).toEqual({ providerUrl: "https://test-provider.com" }); }); diff --git a/packages/procaptcha-frictionless/src/customDetectBot.ts b/packages/procaptcha-frictionless/src/customDetectBot.ts index 2120cd6641..1f5324f38c 100644 --- a/packages/procaptcha-frictionless/src/customDetectBot.ts +++ b/packages/procaptcha-frictionless/src/customDetectBot.ts @@ -69,10 +69,9 @@ const customDetectBot: BotDetectionFunction = async ( throw new ProsopoEnvError("GENERAL.SITE_KEY_MISSING"); } - // Get provider from DNS-based endpoint (no random selection needed) + // Get provider from DNS-based endpoint const provider = await getRandomActiveProvider( config.defaultEnvironment, - 0, // entropy not used for selection anymore, DNS handles it ); const providerApi = new ProviderApi( From 509c74621fdfcb365ee649e48b43f57ebd99501e Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 07:39:02 +0000 Subject: [PATCH 09/36] lint-fix --- packages/load-balancer/src/tests/providers.unit.test.ts | 1 - packages/procaptcha-frictionless/src/customDetectBot.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/load-balancer/src/tests/providers.unit.test.ts b/packages/load-balancer/src/tests/providers.unit.test.ts index 467c7886b6..f61ee90489 100644 --- a/packages/load-balancer/src/tests/providers.unit.test.ts +++ b/packages/load-balancer/src/tests/providers.unit.test.ts @@ -106,7 +106,6 @@ describe("getRandomActiveProvider", () => { ); }); - it("uses account info from first provider in the list", async () => { const mockProviders = [ { diff --git a/packages/procaptcha-frictionless/src/customDetectBot.ts b/packages/procaptcha-frictionless/src/customDetectBot.ts index 1f5324f38c..347605f783 100644 --- a/packages/procaptcha-frictionless/src/customDetectBot.ts +++ b/packages/procaptcha-frictionless/src/customDetectBot.ts @@ -70,9 +70,7 @@ const customDetectBot: BotDetectionFunction = async ( } // Get provider from DNS-based endpoint - const provider = await getRandomActiveProvider( - config.defaultEnvironment, - ); + const provider = await getRandomActiveProvider(config.defaultEnvironment); const providerApi = new ProviderApi( provider.provider.url, From 1c6532e0da6df03cb793f4f1a9ca77dbe649e3ab Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 09:03:27 +0000 Subject: [PATCH 10/36] Remove more old logic --- packages/load-balancer/src/providers.ts | 57 ++++------ .../src/tests/providers.unit.test.ts | 105 ++---------------- 2 files changed, 29 insertions(+), 133 deletions(-) diff --git a/packages/load-balancer/src/providers.ts b/packages/load-balancer/src/providers.ts index 2edaeffe94..ddb0b2d8ec 100644 --- a/packages/load-balancer/src/providers.ts +++ b/packages/load-balancer/src/providers.ts @@ -13,57 +13,40 @@ // limitations under the License. import type { EnvironmentTypes, RandomProvider } from "@prosopo/types"; -import { type HardcodedProvider, loadBalancer } from "./index.js"; - -let cachedProviders: HardcodedProvider[] = []; - -export function _resetCache() { - cachedProviders = []; -} /** * Gets the DNS-based provider URL for the given environment. * Uses single DNS endpoint with latency-based routing at the DNS level. + * Frontend uses this for simple provider access without needing the full provider list. * * @param env - The environment (development, staging, production) - * @returns Provider URL and account information + * @returns Provider URL and placeholder account information */ export const getRandomActiveProvider = async ( env: EnvironmentTypes, ): Promise => { - // DNS handles the load balancing now - - if (env === "development") { - // Development uses localhost - return { - providerAccount: "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", - provider: { - url: "http://localhost:9229", - }, - }; - } - - // Get provider list to extract account info - if (cachedProviders.length === 0) { - cachedProviders = await loadBalancer(env); - } - - // Use the first provider's account info (they should all be the same cluster) - const firstProvider = cachedProviders[0]; - if (!firstProvider) { - throw new Error("No providers available"); + // DNS handles the load balancing now - no need to fetch provider list for frontend + + let url: string; + + switch (env) { + case "development": + url = "http://localhost:9229"; + break; + case "staging": + url = "https://staging.pronode.prosopo.io"; + break; + case "production": + url = "https://pronode.prosopo.io"; + break; + default: + url = "http://localhost:9229"; } - // Use DNS-based endpoint - const dnsUrl = - env === "staging" - ? "https://staging.pronode.prosopo.io" - : "https://pronode.prosopo.io"; - return { - providerAccount: firstProvider.address, + providerAccount: "dns-load-balanced-provider", // Placeholder - actual provider determined by DNS provider: { - url: dnsUrl, + url, }, }; }; diff --git a/packages/load-balancer/src/tests/providers.unit.test.ts b/packages/load-balancer/src/tests/providers.unit.test.ts index f61ee90489..4ee1204131 100644 --- a/packages/load-balancer/src/tests/providers.unit.test.ts +++ b/packages/load-balancer/src/tests/providers.unit.test.ts @@ -12,126 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadBalancer } from "../index.js"; +import { describe, expect, it } from "vitest"; import { getRandomActiveProvider } from "../providers.js"; -import { _resetCache } from "../providers.js"; - -vi.mock("../index.js", () => ({ - loadBalancer: vi.fn(), -})); describe("getRandomActiveProvider", () => { - beforeEach(() => { - _resetCache(); - vi.clearAllMocks(); - vi.resetAllMocks(); - vi.resetModules(); - }); - it("returns localhost for development environment", async () => { const result = await getRandomActiveProvider("development"); - expect(result.providerAccount).toBe( - "5EjTA28bKSbFPPyMbUjNtArxyqjwq38r1BapVmLZShaqEedV", - ); + expect(result.providerAccount).toBe("dns-load-balanced-provider"); expect(result.provider.url).toBe("http://localhost:9229"); expect(result.provider).not.toHaveProperty("datasetId"); }); it("returns DNS-based URL for staging environment", async () => { - const mockProviders = [ - { - address: "address1", - url: "https://pronode1.prosopo.io", - datasetId: "dataset1", - weight: 1, - }, - ]; - (loadBalancer as unknown as ReturnType).mockResolvedValue( - mockProviders, - ); - const result = await getRandomActiveProvider("staging"); - expect(result.providerAccount).toBe("address1"); + expect(result.providerAccount).toBe("dns-load-balanced-provider"); expect(result.provider.url).toBe("https://staging.pronode.prosopo.io"); expect(result.provider).not.toHaveProperty("datasetId"); }); it("returns DNS-based URL for production environment", async () => { - const mockProviders = [ - { - address: "address1", - url: "https://pronode1.prosopo.io", - datasetId: "dataset1", - weight: 1, - }, - ]; - (loadBalancer as unknown as ReturnType).mockResolvedValue( - mockProviders, - ); - const result = await getRandomActiveProvider("production"); - expect(result.providerAccount).toBe("address1"); + expect(result.providerAccount).toBe("dns-load-balanced-provider"); expect(result.provider.url).toBe("https://pronode.prosopo.io"); expect(result.provider).not.toHaveProperty("datasetId"); }); - it("loads providers only once when called multiple times", async () => { - const mockProviders = [ - { - address: "address1", - url: "https://pronode1.prosopo.io", - datasetId: "dataset1", - weight: 1, - }, - ]; - (loadBalancer as unknown as ReturnType).mockResolvedValue( - mockProviders, - ); - - await getRandomActiveProvider("staging"); - await getRandomActiveProvider("staging"); - - expect(loadBalancer).toHaveBeenCalledTimes(1); - }); - - it("handles empty providers list gracefully", async () => { - (loadBalancer as unknown as ReturnType).mockResolvedValue([]); - - await expect(getRandomActiveProvider("staging")).rejects.toThrow( - "No providers available", - ); - }); - - it("uses account info from first provider in the list", async () => { - const mockProviders = [ - { - address: "mainAccount", - url: "https://pronode1.prosopo.io", - datasetId: "mainDataset", - weight: 1, - }, - { - address: "otherAccount", - url: "https://pronode2.prosopo.io", - datasetId: "otherDataset", - weight: 1, - }, - ]; - (loadBalancer as unknown as ReturnType).mockResolvedValue( - mockProviders, - ); + it("returns consistent results when called multiple times", async () => { + const result1 = await getRandomActiveProvider("staging"); + const result2 = await getRandomActiveProvider("staging"); - const result = await getRandomActiveProvider("production"); - - // Should use the first provider's account info - expect(result.providerAccount).toBe("mainAccount"); - // URL should be DNS-based - expect(result.provider.url).toBe("https://pronode.prosopo.io"); - // datasetId should not be included - expect(result.provider).not.toHaveProperty("datasetId"); + expect(result1.provider.url).toBe(result2.provider.url); + expect(result1.providerAccount).toBe(result2.providerAccount); }); }); From 75c1f43e36ec4c007aa791cc7a1cf8fe25be735f Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 09:20:39 +0000 Subject: [PATCH 11/36] Update tests --- .../src/tests/providers.test.ts | 74 +++++-------------- 1 file changed, 18 insertions(+), 56 deletions(-) diff --git a/packages/procaptcha-common/src/tests/providers.test.ts b/packages/procaptcha-common/src/tests/providers.test.ts index 5da16aae47..decab66c9f 100644 --- a/packages/procaptcha-common/src/tests/providers.test.ts +++ b/packages/procaptcha-common/src/tests/providers.test.ts @@ -12,78 +12,40 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getProcaptchaRandomActiveProvider, providerRetry, } from "../providers.js"; -// Mock the load-balancer module -vi.mock("@prosopo/load-balancer", () => ({ - getRandomActiveProvider: vi.fn(), -})); - describe("providers", () => { describe("getProcaptchaRandomActiveProvider", () => { - // biome-ignore lint/suspicious/noExplicitAny: Store original crypto function - let originalGetRandomValues: any; + it("should return localhost for development environment", async () => { + const result = await getProcaptchaRandomActiveProvider("development"); - beforeEach(() => { - originalGetRandomValues = global.window.crypto.getRandomValues.bind( - global.window.crypto, - ); + expect(result.providerAccount).toBe("provider-dns-endpoint"); + expect(result.provider.url).toBe("http://localhost:9229"); }); - afterEach(() => { - global.window.crypto.getRandomValues = originalGetRandomValues; - }); + it("should return staging DNS URL for staging environment", async () => { + const result = await getProcaptchaRandomActiveProvider("staging"); - it("should generate random values and call getRandomActiveProvider", async () => { - // Mock window.crypto.getRandomValues - const mockRandomValues = new Uint8Array([ - 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, - ]); - const mockGetRandomValues = vi.fn(() => mockRandomValues); - // biome-ignore lint/suspicious/noExplicitAny: Mock crypto API - global.window.crypto.getRandomValues = mockGetRandomValues as any; - - // Mock the getRandomActiveProvider import - const { getRandomActiveProvider } = await import( - "@prosopo/load-balancer" - ); - vi.mocked(getRandomActiveProvider).mockResolvedValue({ - providerUrl: "https://test-provider.com", - // biome-ignore lint/suspicious/noExplicitAny: Mock return type - } as any); + expect(result.providerAccount).toBe("provider-dns-endpoint"); + expect(result.provider.url).toBe("https://staging.pronode.prosopo.io"); + }); - const result = await getProcaptchaRandomActiveProvider("development"); + it("should return production DNS URL for production environment", async () => { + const result = await getProcaptchaRandomActiveProvider("production"); - expect(mockGetRandomValues).toHaveBeenCalledWith(expect.any(Uint8Array)); - // DNS-based load balancing - no entropy parameter needed - expect(getRandomActiveProvider).toHaveBeenCalledWith("development"); - expect(result).toEqual({ providerUrl: "https://test-provider.com" }); + expect(result.providerAccount).toBe("provider-dns-endpoint"); + expect(result.provider.url).toBe("https://pronode.prosopo.io"); }); - it("should use different random values on each call", async () => { - let callCount = 0; - const mockGetRandomValues = vi.fn((arr: Uint8Array) => { - callCount++; - arr.fill(callCount); - return arr; - }); - // biome-ignore lint/suspicious/noExplicitAny: Mock crypto API - global.window.crypto.getRandomValues = mockGetRandomValues as any; - - const { getRandomActiveProvider } = await import( - "@prosopo/load-balancer" - ); - // biome-ignore lint/suspicious/noExplicitAny: Mock return type - vi.mocked(getRandomActiveProvider).mockResolvedValue({} as any); - - await getProcaptchaRandomActiveProvider("development"); - await getProcaptchaRandomActiveProvider("development"); + it("should return consistent results when called multiple times", async () => { + const result1 = await getProcaptchaRandomActiveProvider("staging"); + const result2 = await getProcaptchaRandomActiveProvider("staging"); - expect(mockGetRandomValues).toHaveBeenCalledTimes(2); + expect(result1).toEqual(result2); }); }); From 80af64172c55e89689ec5cc81e886595707dda94 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 09:22:29 +0000 Subject: [PATCH 12/36] =?UTF-8?q?=F0=9F=93=A6=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4da4106062..c99ae2861c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@prosopo/captcha", - "version": "3.5.20", + "version": "3.5.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@prosopo/captcha", - "version": "3.5.20", + "version": "3.5.21", "license": "Apache-2.0", "workspaces": [ "dev/*", @@ -34598,7 +34598,6 @@ "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", "@prosopo/account": "2.8.0", - "@prosopo/load-balancer": "2.8.17", "@prosopo/types": "3.8.0", "@prosopo/widget-skeleton": "2.7.13", "react": "18.3.1" From 3c454c55cd6f6fbe6fac0b85cfab4a989d00a7ec Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 09:23:00 +0000 Subject: [PATCH 13/36] Update deps --- packages/procaptcha-common/package.json | 1 - packages/procaptcha-common/tsconfig.cjs.json | 3 --- packages/procaptcha-common/tsconfig.json | 3 --- 3 files changed, 7 deletions(-) diff --git a/packages/procaptcha-common/package.json b/packages/procaptcha-common/package.json index 2e10d4a0ed..d32a5c4b14 100644 --- a/packages/procaptcha-common/package.json +++ b/packages/procaptcha-common/package.json @@ -35,7 +35,6 @@ "@emotion/react": "11.11.1", "@emotion/styled": "11.11.0", "@prosopo/account": "2.8.0", - "@prosopo/load-balancer": "2.8.17", "@prosopo/types": "3.8.0", "@prosopo/widget-skeleton": "2.7.13", "react": "18.3.1" diff --git a/packages/procaptcha-common/tsconfig.cjs.json b/packages/procaptcha-common/tsconfig.cjs.json index 9fafdb7e43..c3f8b551d6 100644 --- a/packages/procaptcha-common/tsconfig.cjs.json +++ b/packages/procaptcha-common/tsconfig.cjs.json @@ -16,9 +16,6 @@ { "path": "../../dev/config/tsconfig.cjs.json" }, - { - "path": "../load-balancer/tsconfig.cjs.json" - }, { "path": "../types/tsconfig.cjs.json" }, diff --git a/packages/procaptcha-common/tsconfig.json b/packages/procaptcha-common/tsconfig.json index 2ac8588be2..2855f89c86 100644 --- a/packages/procaptcha-common/tsconfig.json +++ b/packages/procaptcha-common/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../../dev/config/tsconfig.json" }, - { - "path": "../load-balancer" - }, { "path": "../types" }, From 593ae651277c2c8776e95c5a802c921fe521a078 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 10:25:56 +0000 Subject: [PATCH 14/36] Fix test --- .../tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts | 2 +- .../tests/unit/tasks/frictionless/decryptPayload.unit.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts index 76eceed1f4..b807b4203e 100644 --- a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts +++ b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts @@ -227,7 +227,7 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { tasksInstance.frictionlessManager.decryptPayload.mockResolvedValue({ baseBotScore: 0, timestamp: Date.now(), - userId: "user123", + userId: "u", userAgent: "844bc172f032bdd2d0baae3536c1d66c", webView: true, iFrame: false, diff --git a/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts b/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts index 91ba415aa2..e558427dc3 100644 --- a/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts +++ b/packages/provider/src/tests/unit/tasks/frictionless/decryptPayload.unit.test.ts @@ -118,7 +118,7 @@ describe("decryptPayload", () => { webView: false, iFrame: false, decryptedHeadHash: "", - decryptionFailed: true, + decryptionFailed: false, // Decryption succeeds when baseBotScore and timestamp are defined }); }); it("should set values for the payload when there are keys but they fail to decrypt", async () => { From 4f3514d8593a01f2f347b5ccb71552b2083bf35d Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 11:15:17 +0000 Subject: [PATCH 15/36] Fix test --- .../provider/src/api/captcha/getImageCaptchaChallenge.ts | 5 +++-- .../unit/api/getFrictionlessCaptchaChallenge.unit.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts b/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts index 3f3961b3be..4e2d4cf051 100644 --- a/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts +++ b/packages/provider/src/api/captcha/getImageCaptchaChallenge.ts @@ -67,9 +67,10 @@ export default ( ); } - const { user, dapp, sessionId } = parsed; + const { datasetId: clientDatasetId, user, dapp, sessionId } = parsed; - const datasetId = env.datasetId; + // Use client-provided datasetId if available, otherwise use provider's default + const datasetId = clientDatasetId || env.datasetId; if (!datasetId) { return next( diff --git a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts index b807b4203e..09ce4b82f8 100644 --- a/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts +++ b/packages/provider/src/tests/unit/api/getFrictionlessCaptchaChallenge.unit.test.ts @@ -83,6 +83,10 @@ const buildReqRes = (body: unknown, ip = "127.0.0.1") => { return { req, res, next }; }; +vi.mock("../../../utils/hashUserAgent.js", () => ({ + hashUserAgent: vi.fn((ua: string) => "844bc172f032bdd2d0baae3536c1d66c"), +})); + vi.mock("../../../tasks/index.js", async () => { const actual = await vi.importActual("../../../tasks/index.js"); return { @@ -242,6 +246,8 @@ describe("getFrictionlessCaptchaChallenge - context selection", () => { const body = { token: "t", headHash: "hh", dapp: "site1", user: "u" }; const { req, res, next } = buildReqRes(body); + // Set headers to match the decrypted payload + req.headers["prosopo-user"] = "u"; // Act // biome-ignore lint/suspicious/noExplicitAny: mock request From 010f12114e49c0beafc5c386f966ebaa7270fdca Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 13:41:08 +0000 Subject: [PATCH 16/36] Bump caddy version --- docker/images/caddy/package.json | 4 ++-- docker/provider.Caddyfile | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/images/caddy/package.json b/docker/images/caddy/package.json index 770f7ca936..ec5032ab30 100644 --- a/docker/images/caddy/package.json +++ b/docker/images/caddy/package.json @@ -1,6 +1,6 @@ { "name": "@prosopo/caddy-docker", - "version": "2.5.6", + "version": "2.5.7", "engines": { "node": "^24", "npm": "^11" @@ -22,6 +22,6 @@ } }, "devDependencies": { - "@prosopo/config": "3.1.20" + "@prosopo/config": "3.3.0" } } diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 13c802aa16..d2e8e98e36 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -33,13 +33,13 @@ http://:9090 { metrics /metrics } -{$CADDY_DOMAIN} { +{$CADDY_DOMAIN}, {$CADDY_GLOBAL_DOMAIN} { @httpOnly { protocol http } redir @httpOnly https://{host}{uri} - + handle /robots.txt { uri strip_prefix / # removes leading /, so it looks directly in root root * /srv/static From eff4586f1a902b67b2116a99dec288cc0c1ed190 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 14:19:02 +0000 Subject: [PATCH 17/36] Caddy file changes --- docker/provider.Caddyfile | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index d2e8e98e36..84ba455ddd 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -2,8 +2,13 @@ { # debug http_port {$CADDY_HTTP_PORT:80} + https_port 443 auto_https {$CADDY_AUTO_HTTPS:disable_redirects} - admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) + + # Use Let's Encrypt production + acme_ca https://acme-v02.api.letsencrypt.org/directory + + admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) # Caddy must be told custom rate_limit module its order order rate_limit before basicauth @@ -35,10 +40,18 @@ http://:9090 { {$CADDY_DOMAIN}, {$CADDY_GLOBAL_DOMAIN} { - @httpOnly { - protocol http - } - redir @httpOnly https://{host}{uri} + # Redirect HTTP to HTTPS, but allow ACME challenges through + @httpOnly { + protocol http + not path /.well-known/acme-challenge/* + } + redir @httpOnly https://{host}{uri} permanent + + # Configure TLS + tls { + protocols tls1.2 tls1.3 + } + handle /robots.txt { uri strip_prefix / # removes leading /, so it looks directly in root From c98ff7cb83e1d1d0e6034ee5f64be400407eefa0 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 14:22:58 +0000 Subject: [PATCH 18/36] Explicity http1 --- docker/provider.Caddyfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 84ba455ddd..8a91f32698 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -8,6 +8,9 @@ # Use Let's Encrypt production acme_ca https://acme-v02.api.letsencrypt.org/directory + # Email for ACME account + email {$CADDY_ACME_EMAIL:admin@prosopo.io} + admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) # Caddy must be told custom rate_limit module its order @@ -50,6 +53,11 @@ http://:9090 { # Configure TLS tls { protocols tls1.2 tls1.3 + # Only use HTTP-01 challenge (disable TLS-ALPN-01) + # This is required for multi-server setups where TLS-ALPN-01 causes coordination issues + issuer acme { + challenges http-01 + } } From d9de199a4d664c204acdecc15028a3a8434575c5 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 15:52:22 +0000 Subject: [PATCH 19/36] fix --- docker/provider.Caddyfile | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 8a91f32698..40b827fb2a 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -11,6 +11,7 @@ # Email for ACME account email {$CADDY_ACME_EMAIL:admin@prosopo.io} + admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) # Caddy must be told custom rate_limit module its order @@ -53,11 +54,6 @@ http://:9090 { # Configure TLS tls { protocols tls1.2 tls1.3 - # Only use HTTP-01 challenge (disable TLS-ALPN-01) - # This is required for multi-server setups where TLS-ALPN-01 causes coordination issues - issuer acme { - challenges http-01 - } } From 8475993a3518b0f49b2ebcec7a530c7374657126 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 16:07:42 +0000 Subject: [PATCH 20/36] Set DNS challenge --- docker/images/caddy/src/Dockerfile | 5 +++-- docker/provider.Caddyfile | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docker/images/caddy/src/Dockerfile b/docker/images/caddy/src/Dockerfile index 2bb364fb73..07ecbee753 100644 --- a/docker/images/caddy/src/Dockerfile +++ b/docker/images/caddy/src/Dockerfile @@ -3,7 +3,9 @@ RUN apk update && apk add gcc g++ make libpcap-dev libpcap RUN CGO_ENABLED=1 xcaddy build \ --with github.com/mholt/caddy-ratelimit \ --with github.com/prosopo/chaddy \ - --with github.com/lolPants/caddy-requestid + --with github.com/lolPants/caddy-requestid \ + --with github.com/caddy-dns/bunny + FROM caddy:2 RUN apk update && apk add libpcap RUN apk add --no-cache curl @@ -11,4 +13,3 @@ RUN apk add --no-cache curl RUN mkdir -p /srv/static && \ echo -e "User-agent: *\nDisallow: /" > /srv/static/robots.txt COPY --from=builder /usr/bin/caddy /usr/bin/caddy - diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 40b827fb2a..5f84101478 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -51,10 +51,10 @@ http://:9090 { } redir @httpOnly https://{host}{uri} permanent - # Configure TLS - tls { - protocols tls1.2 tls1.3 - } + # Configure TLS with DNS-01 challenge + tls { + dns bunny {$BUNNY_API_KEY} + } handle /robots.txt { From e69cde9b43db8eb138e5d17765e77246eefdb58c Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:25:21 +0000 Subject: [PATCH 21/36] Use upstash redis --- docker/provider.Caddyfile | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 5f84101478..9a821746b5 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -5,14 +5,20 @@ https_port 443 auto_https {$CADDY_AUTO_HTTPS:disable_redirects} + # Shared storage via Upstash Redis + # The plugin handles the rediss:// scheme for TLS automatically + storage redis { + address "{$UPSTASH_REDIS_URL}" + # If your URL contains the password, you don't need 'password' here. + # Otherwise, use: password "{$UPSTASH_REDIS_PASSWORD}" + key_prefix "caddy_prosopo_global" + } + # Use Let's Encrypt production acme_ca https://acme-v02.api.letsencrypt.org/directory - - # Email for ACME account email {$CADDY_ACME_EMAIL:admin@prosopo.io} - - admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) + admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) # Caddy must be told custom rate_limit module its order order rate_limit before basicauth @@ -43,20 +49,21 @@ http://:9090 { } {$CADDY_DOMAIN}, {$CADDY_GLOBAL_DOMAIN} { + # DNS-01 Challenge with Bunny + tls { + dns bunny {$BUNNY_API_KEY} + # Vital for Bunny DNS propagation across all edge locations + propagation_delay 60s + # Optional: ensure we prioritize the wildcard over individual certs + } - # Redirect HTTP to HTTPS, but allow ACME challenges through + # Redirect HTTP to HTTPS @httpOnly { protocol http not path /.well-known/acme-challenge/* } redir @httpOnly https://{host}{uri} permanent - # Configure TLS with DNS-01 challenge - tls { - dns bunny {$BUNNY_API_KEY} - } - - handle /robots.txt { uri strip_prefix / # removes leading /, so it looks directly in root root * /srv/static From db17b82367107547432a0a0d65eb3e772b9cb6a9 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:34:35 +0000 Subject: [PATCH 22/36] Update caddyfile --- docker/images/caddy/src/Dockerfile | 3 ++- docker/provider.Caddyfile | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docker/images/caddy/src/Dockerfile b/docker/images/caddy/src/Dockerfile index 07ecbee753..3eaa4fdddf 100644 --- a/docker/images/caddy/src/Dockerfile +++ b/docker/images/caddy/src/Dockerfile @@ -4,7 +4,8 @@ RUN CGO_ENABLED=1 xcaddy build \ --with github.com/mholt/caddy-ratelimit \ --with github.com/prosopo/chaddy \ --with github.com/lolPants/caddy-requestid \ - --with github.com/caddy-dns/bunny + --with github.com/caddy-dns/bunny \ + --with github.com/gamalan/caddy-tlsredis FROM caddy:2 RUN apk update && apk add libpcap diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 9a821746b5..033b59e792 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -8,7 +8,9 @@ # Shared storage via Upstash Redis # The plugin handles the rediss:// scheme for TLS automatically storage redis { - address "{$UPSTASH_REDIS_URL}" + host "{$UPSTASH_REDIS_HOST}" + port {$UPSTASH_REDIS_PORT} + password "{$UPSTASH_REDIS_PASSWORD}" # If your URL contains the password, you don't need 'password' here. # Otherwise, use: password "{$UPSTASH_REDIS_PASSWORD}" key_prefix "caddy_prosopo_global" From efb9753f8f9e36964294d4707e3afead8a8ac83c Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:38:39 +0000 Subject: [PATCH 23/36] fmt --- docker/provider.Caddyfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 033b59e792..53a81bea27 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -9,8 +9,8 @@ # The plugin handles the rediss:// scheme for TLS automatically storage redis { host "{$UPSTASH_REDIS_HOST}" - port {$UPSTASH_REDIS_PORT} - password "{$UPSTASH_REDIS_PASSWORD}" + port {$UPSTASH_REDIS_PORT} + password "{$UPSTASH_REDIS_PASSWORD}" # If your URL contains the password, you don't need 'password' here. # Otherwise, use: password "{$UPSTASH_REDIS_PASSWORD}" key_prefix "caddy_prosopo_global" From b7bba32eaba0690f96e2a28cdcd119392d62ed18 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:40:32 +0000 Subject: [PATCH 24/36] upstash redis config --- docker/provider.Caddyfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 53a81bea27..ffed5bcb63 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -11,6 +11,14 @@ host "{$UPSTASH_REDIS_HOST}" port {$UPSTASH_REDIS_PORT} password "{$UPSTASH_REDIS_PASSWORD}" + + # In gamalan/caddy-tlsredis, 'aes' enables TLS/SSL + # Use "true" or "1" depending on the specific sub-version + aes "true" + + # Ensure this is set to 0 for Upstash + db 0 + # If your URL contains the password, you don't need 'password' here. # Otherwise, use: password "{$UPSTASH_REDIS_PASSWORD}" key_prefix "caddy_prosopo_global" From 1d78be6c058b28a2f319ae61b9761d2f8dfab87a Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:41:13 +0000 Subject: [PATCH 25/36] fmt --- docker/provider.Caddyfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index ffed5bcb63..1102ca3361 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -18,7 +18,7 @@ # Ensure this is set to 0 for Upstash db 0 - + # If your URL contains the password, you don't need 'password' here. # Otherwise, use: password "{$UPSTASH_REDIS_PASSWORD}" key_prefix "caddy_prosopo_global" From ec4019c6cd305dab97bbaef67d2ba28a71731684 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:44:37 +0000 Subject: [PATCH 26/36] fmt --- docker/provider.Caddyfile | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 1102ca3361..665724612a 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -8,20 +8,15 @@ # Shared storage via Upstash Redis # The plugin handles the rediss:// scheme for TLS automatically storage redis { - host "{$UPSTASH_REDIS_HOST}" - port {$UPSTASH_REDIS_PORT} - password "{$UPSTASH_REDIS_PASSWORD}" + # Use the full URL directly + # address "{$UPSTASH_REDIS_URL}" - # In gamalan/caddy-tlsredis, 'aes' enables TLS/SSL - # Use "true" or "1" depending on the specific sub-version - aes "true" + # Or if you prefer split variables: + address "{$UPSTASH_REDIS_HOST}:{$UPSTASH_REDIS_PORT}" + password "{$UPSTASH_REDIS_PASSWORD}" + tls_enabled "true" - # Ensure this is set to 0 for Upstash - db 0 - - # If your URL contains the password, you don't need 'password' here. - # Otherwise, use: password "{$UPSTASH_REDIS_PASSWORD}" - key_prefix "caddy_prosopo_global" + key_prefix "caddy_prosopo" } # Use Let's Encrypt production From 5d1e92b2434ffcc1031ad6d7547692f4bffcca4a Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 21:44:51 +0000 Subject: [PATCH 27/36] fmt --- docker/images/caddy/src/Dockerfile | 2 +- docker/provider.Caddyfile | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/images/caddy/src/Dockerfile b/docker/images/caddy/src/Dockerfile index 3eaa4fdddf..e56d0d47d7 100644 --- a/docker/images/caddy/src/Dockerfile +++ b/docker/images/caddy/src/Dockerfile @@ -5,7 +5,7 @@ RUN CGO_ENABLED=1 xcaddy build \ --with github.com/prosopo/chaddy \ --with github.com/lolPants/caddy-requestid \ --with github.com/caddy-dns/bunny \ - --with github.com/gamalan/caddy-tlsredis + --with github.com/pberkel/caddy-storage-redis FROM caddy:2 RUN apk update && apk add libpcap diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 665724612a..920cd53e63 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -12,9 +12,9 @@ # address "{$UPSTASH_REDIS_URL}" # Or if you prefer split variables: - address "{$UPSTASH_REDIS_HOST}:{$UPSTASH_REDIS_PORT}" - password "{$UPSTASH_REDIS_PASSWORD}" - tls_enabled "true" + address "{$UPSTASH_REDIS_HOST}:{$UPSTASH_REDIS_PORT}" + password "{$UPSTASH_REDIS_PASSWORD}" + tls_enabled "true" key_prefix "caddy_prosopo" } From 5a403ef4bffb34301b86a26a7d2de04f1fc3f577 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 22:10:35 +0000 Subject: [PATCH 28/36] Increase delay --- docker/provider.Caddyfile | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 920cd53e63..405b27e1b9 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -57,9 +57,13 @@ http://:9090 { # DNS-01 Challenge with Bunny tls { dns bunny {$BUNNY_API_KEY} - # Vital for Bunny DNS propagation across all edge locations - propagation_delay 60s - # Optional: ensure we prioritize the wildcard over individual certs + # 1. Increase the wait time to give Bunny more time to sync + propagation_delay 120s + # 2. Increase the timeout for the internal propagation check + propagation_timeout 300s + # 3. (Optional but recommended) Bypass Caddy's internal resolver + # so it doesn't fail just because it can't see its own record yet + resolvers 1.1.1.1 } # Redirect HTTP to HTTPS From 09b007b126fcc97d6ea8cef9c6efb04e16041f4e Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 22:12:56 +0000 Subject: [PATCH 29/36] Use domain wildcard --- docker/provider.Caddyfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 405b27e1b9..d1e2878ed5 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -53,7 +53,7 @@ http://:9090 { metrics /metrics } -{$CADDY_DOMAIN}, {$CADDY_GLOBAL_DOMAIN} { +*.pronode.prosopo.io { # DNS-01 Challenge with Bunny tls { dns bunny {$BUNNY_API_KEY} From 723f781107cf3e6ce4f5ee7428b4d6af54e0f296 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 22:39:20 +0000 Subject: [PATCH 30/36] individual hosts --- docker/provider.Caddyfile | 301 +++++++++++++++++--------------------- 1 file changed, 132 insertions(+), 169 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index d1e2878ed5..5ce1fec857 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -1,35 +1,30 @@ # usage: `caddy run --config ./docker/provider.Caddyfile --envfile docker/env.development` { - # debug + # Global Options http_port {$CADDY_HTTP_PORT:80} https_port 443 auto_https {$CADDY_AUTO_HTTPS:disable_redirects} # Shared storage via Upstash Redis - # The plugin handles the rediss:// scheme for TLS automatically storage redis { - # Use the full URL directly - # address "{$UPSTASH_REDIS_URL}" - - # Or if you prefer split variables: address "{$UPSTASH_REDIS_HOST}:{$UPSTASH_REDIS_PORT}" password "{$UPSTASH_REDIS_PASSWORD}" tls_enabled "true" - key_prefix "caddy_prosopo" } - # Use Let's Encrypt production + # Production ACME settings acme_ca https://acme-v02.api.letsencrypt.org/directory email {$CADDY_ACME_EMAIL:admin@prosopo.io} - admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) + # Admin API on custom port to avoid host conflicts + admin {$CADDY_ADMIN_API::2020} - # Caddy must be told custom rate_limit module its order + # Set plugin execution order order rate_limit before basicauth + # Server-wide TLS/Fingerprinting settings client_hello { - # Configure the maximum allowed ClientHello packet size in bytes (1-16384) max_client_hello_size 16384 } @@ -48,204 +43,172 @@ } } -# HTTP metrics endpoint on all interfaces -http://:9090 { - metrics /metrics -} - -*.pronode.prosopo.io { - # DNS-01 Challenge with Bunny - tls { - dns bunny {$BUNNY_API_KEY} - # 1. Increase the wait time to give Bunny more time to sync - propagation_delay 120s - # 2. Increase the timeout for the internal propagation check - propagation_timeout 300s - # 3. (Optional but recommended) Bypass Caddy's internal resolver - # so it doesn't fail just because it can't see its own record yet - resolvers 1.1.1.1 - } - - # Redirect HTTP to HTTPS - @httpOnly { - protocol http - not path /.well-known/acme-challenge/* +# Snippet: Reusable Proxy & Security Logic +(proxy_logic) { + # 1. Identity & Security Headers + request_id + header { + X-Request-ID "{http.request_id}" + Strict-Transport-Security "max-age=31536000;" + X-XSS-Protection "1; mode=block" + X-Frame-Options "DENY" + X-Robots-Tag "none" + # This header helps you identify which node is answering + X-Served-By "{$CADDY_DOMAIN}" } - redir @httpOnly https://{host}{uri} permanent - handle /robots.txt { - uri strip_prefix / # removes leading /, so it looks directly in root - root * /srv/static - file_server - header Content-Type "text/plain" - header X-Robots-Tag "noindex, nofollow" + # 2. Distributed Rate Limiting + rate_limit { + distributed + zone dynamic_client { + key {remote_host} + events {$CADDY_RATE_LIMIT_EVENTS:60} + window {$CADDY_RATE_LIMIT_WINDOW:1m} + } + log_key } - route { - client_hello - request_id - - header X-Request-ID "{http.request_id}" - - header / { - # Enable HTTP Strict Transport Security (HSTS) - Strict-Transport-Security "max-age=31536000;" - # Enable cross-site filter (XSS) and tell browser to block detected attacks - X-XSS-Protection "1; mode=block" - # Disallow the site to be rendered within a frame (clickjacking protection) - X-Frame-Options "DENY" - # Prevent search engines from indexing - X-Robots-Tag "none" - } + # 3. Main Reverse Proxy to App Containers + reverse_proxy {$CADDY_PROVIDER_CONTAINER_NAME:provider1}:{$CADDY_PROVIDER_PORT:9229} {$CADDY_PROVIDER_CONTAINER_NAME:provider2}:{$CADDY_PROVIDER_PORT2:9339} { + lb_policy first + max_fails 1 + fail_duration 1ns + unhealthy_status 5xx + unhealthy_latency 10s - # enable prometheus metrics - metrics /metrics - - rate_limit { - distributed - - # Means that the rate limit is applied to all GET requests, with a limit of 100 requests per minute. - # zone get_rate_limit { - # match { - # method GET - # } - # key static - # events 100 - # window 1m - # } - - # The rate limit is applied to `remote_host` with a limit of 6 requests per 6 seconds (60 requests per minute). - zone dynamic_example { - key {remote_host} - events {$CADDY_RATE_LIMIT_EVENTS} - window {$CADDY_RATE_LIMIT_WINDOW} - } - log_key + transport http { + dial_timeout 1s } - # reverse proxy to the provider container - reverse_proxy {$CADDY_PROVIDER_CONTAINER_NAME:provider1}:{$CADDY_PROVIDER_PORT:9229} {$CADDY_PROVIDER_CONTAINER_NAME:provider2}:{$CADDY_PROVIDER_PORT2:9339} { - # https://caddyserver.com/docs/modules/http.handlers.reverse_proxy - - # try A, then B, then C, etc. - lb_policy first - - # how many times a backend can fail before it is considered unhealthy - max_fails 1 + # Pass along the Node ID if it was set in the handle + header_up X-Node-Id "{vars.node_id}" - # how long a backend is marked as unhealthy after it has failed (this is a non-zero duration to enable passive health checks). Passive health checks decide a backend's health based on the response code (and whether it responded at all) from normal traffic. - fail_duration 1ns + # Pass all TLS and connection metadata to the backend + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Forwarded-Port {http.request.remote.port} + header_up X-Forwarded-Proto {http.request.scheme} - # 5XX status codes are considered unhealthy, in addition to no response - unhealthy_status 5xx + header_up X-Request-ID "{http.request_id}" - # long latency on response marks the backend as unhealthy - unhealthy_latency 10s + header_up x-tls-version "{tls_version}" + header_up x-tls-version "^{tls_version}$" "" - transport http { - # how long to wait for a connection to be established to backend - dial_timeout 1s - } + header_up x-tls-client-subject "{tls_client_subject}" + header_up x-tls-client-subject "^{tls_client_subject}$" "" - # how long to keep trying backends before giving up - lb_try_duration 5s + header_up x-tls-client-serial "{tls_client_serial}" + header_up x-tls-client-serial "^{tls_client_serial}$" "" - # how long to wait between retries of backends (0 doesn't work, set to 1ns for almost immediate retry) - lb_try_interval 1ns + header_up x-tls-client-issuer "{tls_client_issuer}" + header_up x-tls-client-issuer "^{tls_client_issuer}$" "" - # example failover sequence with failing backends: - # - request comes in - # - lb_policy first means provider1 is tried first - # - request is sent to provider1 - # - provider1 does not respond within 1s (dial_timeout) - # - provider1 is marked as unhealthy - # - request is sent to provider2 - # - provider2 does not respond within 1s (dial_timeout) - # - in this time, provider1 is marked as healthy again (fail duration expired) - # - provider2 is marked as unhealthy - # - request is sent to provider1 again - # - provider1 responds within 1s - # - request is completed - # the request is retried over all backends in turn until either it succeeds or the try_duration is reached + header_up x-tls-client-fingerprint "{tls_client_fingerprint}" + header_up x-tls-client-fingerprint "^{tls_client_fingerprint}$" "" - # https://caddyserver.com/docs/caddyfile/concepts#placeholders - # https://caddyserver.com/docs/json/apps/http/#docs + header_up x-tls-client-certificate-pem "{tls_client_certificate_pem}" + header_up x-tls-client-certificate-pem "^{tls_client_certificate_pem}$" "" - header_up X-Forwarded-For {http.request.remote.host} - header_up X-Forwarded-Port {http.request.remote.port} - header_up X-Forwarded-Proto {http.request.scheme} + header_up x-tls-client-certificate-der-base64 "{tls_client_certificate_der_base64}" + header_up x-tls-client-certificate-der-base64 "^{tls_client_certificate_der_base64}$" "" - header_up X-Request-ID "{http.request_id}" + header_up x-tls-cipher "{tls_cipher}" + header_up x-tls-cipher "^{tls_cipher}$" "" - header_up x-tls-version "{tls_version}" - header_up x-tls-version "^{tls_version}$" "" + header_up x-remote-port "{remote_port}" + header_up x-remote-port "^{remote_port}$" "" - header_up x-tls-client-subject "{tls_client_subject}" - header_up x-tls-client-subject "^{tls_client_subject}$" "" + header_up x-remote-host "{remote_host}" + header_up x-remote-host "^{remote_host}$" "" - header_up x-tls-client-serial "{tls_client_serial}" - header_up x-tls-client-serial "^{tls_client_serial}$" "" + header_up x-method "{method}" + header_up x-method "^{method}$" "" - header_up x-tls-client-issuer "{tls_client_issuer}" - header_up x-tls-client-issuer "^{tls_client_issuer}$" "" + header_up x-client-ip "{client_ip}" + header_up x-client-ip "^{client_ip}$" "" - header_up x-tls-client-fingerprint "{tls_client_fingerprint}" - header_up x-tls-client-fingerprint "^{tls_client_fingerprint}$" "" + header_up x-duration-ms {http.request.duration} + header_up x-duration-ms "^{http.request.duration}$" "" - header_up x-tls-client-certificate-pem "{tls_client_certificate_pem}" - header_up x-tls-client-certificate-pem "^{tls_client_certificate_pem}$" "" + header_up x-tls-resumed "{http.request.tls.resumed}" + header_up x-tls-resumed "^{http.request.tls.resumed}$" "" - header_up x-tls-client-certificate-der-base64 "{tls_client_certificate_der_base64}" - header_up x-tls-client-certificate-der-base64 "^{tls_client_certificate_der_base64}$" "" + header_up x-tls-proto "{http.request.tls.proto}" + header_up x-tls-proto "^{http.request.tls.proto}$" "" - header_up x-tls-cipher "{tls_cipher}" - header_up x-tls-cipher "^{tls_cipher}$" "" + header_up x-tls-proto-mutual "{http.request.tls.proto_mutual}" + header_up x-tls-proto-mutual "^{http.request.tls.proto_mutual}$" "" - header_up x-remote-port "{remote_port}" - header_up x-remote-port "^{remote_port}$" "" + header_up x-tls-server-name "{http.request.tls.server_name}" + header_up x-tls-server-name "^{http.request.tls.server_name}$" "" - header_up x-remote-host "{remote_host}" - header_up x-remote-host "^{remote_host}$" "" + header_up x-tls-public-key "{http.request.tls.public_key}" + header_up x-tls-public-key "^{http.request.tls.public_key}$" "" - header_up x-method "{method}" - header_up x-method "^{method}$" "" + header_up x-tls-public-key-sha256 "{http.request.tls.public_key_sha256}" + header_up x-tls-public-key-sha256 "^{http.request.tls.public_key_sha256}$" "" - header_up x-client-ip "{client_ip}" - header_up x-client-ip "^{client_ip}$" "" + header_up x-tls-client-san-dns-names "{http.request.tls.client.san.dns_names}" + header_up x-tls-client-san-dns-names "^{http.request.tls.client.san.dns_names}$" "" - header_up x-duration-ms {http.request.duration} - header_up x-duration-ms "^{http.request.duration}$" "" + header_up x-tls-client-san-emails "{http.request.tls.client.san.emails}" + header_up x-tls-client-san-emails "^{http.request.tls.client.san.emails}$" "" - header_up x-tls-resumed "{http.request.tls.resumed}" - header_up x-tls-resumed "^{http.request.tls.resumed}$" "" + header_up x-tls-client-san-ips "{http.request.tls.client.san.ips}" + header_up x-tls-client-san-ips "^{http.request.tls.client.san.ips}$" "" - header_up x-tls-proto "{http.request.tls.proto}" - header_up x-tls-proto "^{http.request.tls.proto}$" "" + header_up x-tls-client-san-uris "{http.request.tls.client.san.uris}" + header_up x-tls-client-san-uris "^{http.request.tls.client.san.uris}$" "" + } +} - header_up x-tls-proto-mutual "{http.request.tls.proto_mutual}" - header_up x-tls-proto-mutual "^{http.request.tls.proto_mutual}$" "" +# --- Site Blocks --- - header_up x-tls-server-name "{http.request.tls.server_name}" - header_up x-tls-server-name "^{http.request.tls.server_name}$" "" +# Metrics endpoint (accessible over private network/IP) +http://:9090 { + metrics /metrics +} - header_up x-tls-public-key "{http.request.tls.public_key}" - header_up x-tls-public-key "^{http.request.tls.public_key}$" "" +# Wildcard domain handling for the entire delegated zone +*.pronode.prosopo.io { + # SSL via Bunny DNS-01 Challenge + tls { + dns bunny {$BUNNY_API_KEY} + propagation_delay 120s + propagation_timeout 300s + resolvers 1.1.1.1 + } - header_up x-tls-public-key-sha256 "{http.request.tls.public_key_sha256}" - header_up x-tls-public-key-sha256 "^{http.request.tls.public_key_sha256}$" "" + # HTTP to HTTPS Redirection (with ACME exception) + @httpOnly { + protocol http + not path /.well-known/acme-challenge/* + } + redir @httpOnly https://{host}{uri} permanent - header_up x-tls-client-san-dns-names "{http.request.tls.client.san.dns_names}" - header_up x-tls-client-san-dns-names "^{http.request.tls.client.san.dns_names}$" "" + # Static assets (Robots.txt) + handle /robots.txt { + root * /srv/static + file_server + header Content-Type "text/plain" + } - header_up x-tls-client-san-emails "{http.request.tls.client.san.emails}" - header_up x-tls-client-san-emails "^{http.request.tls.client.san.emails}$" "" + # Case A: Request for THIS specific node (e.g., node2.pronode.prosopo.io) + @myNode host {$CADDY_DOMAIN} + handle @myNode { + vars node_id "I-am-node-{$NODE_ID}" + import proxy_logic + } - header_up x-tls-client-san-ips "{http.request.tls.client.san.ips}" - header_up x-tls-client-san-ips "^{http.request.tls.client.san.ips}$" "" + # Case B: Request for SHARED staging URL (e.g., staging.pronode.prosopo.io) + @shared host {$CADDY_GLOBAL_DOMAIN} + handle @shared { + vars node_id "global" + import proxy_logic + } - header_up x-tls-client-san-uris "{http.request.tls.client.san.uris}" - header_up x-tls-client-san-uris "^{http.request.tls.client.san.uris}$" "" - } + # Case C: Fallback / Unknown subdomain (Security) + handle { + abort } log { From c906f7702c1ec3cfb2558d1829a19d77c224828f Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Wed, 21 Jan 2026 22:44:11 +0000 Subject: [PATCH 31/36] individual hosts --- docker/provider.Caddyfile | 208 ++++++++++++++++++-------------------- 1 file changed, 97 insertions(+), 111 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 5ce1fec857..3ec8ebf95f 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -1,11 +1,9 @@ # usage: `caddy run --config ./docker/provider.Caddyfile --envfile docker/env.development` { - # Global Options http_port {$CADDY_HTTP_PORT:80} https_port 443 auto_https {$CADDY_AUTO_HTTPS:disable_redirects} - # Shared storage via Upstash Redis storage redis { address "{$UPSTASH_REDIS_HOST}:{$UPSTASH_REDIS_PORT}" password "{$UPSTASH_REDIS_PASSWORD}" @@ -13,17 +11,12 @@ key_prefix "caddy_prosopo" } - # Production ACME settings acme_ca https://acme-v02.api.letsencrypt.org/directory email {$CADDY_ACME_EMAIL:admin@prosopo.io} - - # Admin API on custom port to avoid host conflicts admin {$CADDY_ADMIN_API::2020} - # Set plugin execution order order rate_limit before basicauth - # Server-wide TLS/Fingerprinting settings client_hello { max_client_hello_size 16384 } @@ -43,134 +36,135 @@ } } -# Snippet: Reusable Proxy & Security Logic -(proxy_logic) { - # 1. Identity & Security Headers - request_id - header { - X-Request-ID "{http.request_id}" - Strict-Transport-Security "max-age=31536000;" - X-XSS-Protection "1; mode=block" - X-Frame-Options "DENY" - X-Robots-Tag "none" - # This header helps you identify which node is answering - X-Served-By "{$CADDY_DOMAIN}" - } - - # 2. Distributed Rate Limiting - rate_limit { - distributed - zone dynamic_client { - key {remote_host} - events {$CADDY_RATE_LIMIT_EVENTS:60} - window {$CADDY_RATE_LIMIT_WINDOW:1m} +# --- REUSABLE PROXY SNIPPET --- +# This contains all your headers, rate limits, and proxy logic +(my_proxy_logic) { + route { + client_hello + request_id + + header X-Request-ID "{http.request_id}" + header X-Node-Id "{vars.node_id}" # Injected from the handle block + + header / { + Strict-Transport-Security "max-age=31536000;" + X-XSS-Protection "1; mode=block" + X-Frame-Options "DENY" + X-Robots-Tag "none" } - log_key - } - # 3. Main Reverse Proxy to App Containers - reverse_proxy {$CADDY_PROVIDER_CONTAINER_NAME:provider1}:{$CADDY_PROVIDER_PORT:9229} {$CADDY_PROVIDER_CONTAINER_NAME:provider2}:{$CADDY_PROVIDER_PORT2:9339} { - lb_policy first - max_fails 1 - fail_duration 1ns - unhealthy_status 5xx - unhealthy_latency 10s + metrics /metrics - transport http { - dial_timeout 1s + rate_limit { + distributed + zone dynamic_example { + key {remote_host} + events {$CADDY_RATE_LIMIT_EVENTS} + window {$CADDY_RATE_LIMIT_WINDOW} + } + log_key } - # Pass along the Node ID if it was set in the handle - header_up X-Node-Id "{vars.node_id}" + reverse_proxy {$CADDY_PROVIDER_CONTAINER_NAME:provider1}:{$CADDY_PROVIDER_PORT:9229} {$CADDY_PROVIDER_CONTAINER_NAME:provider2}:{$CADDY_PROVIDER_PORT2:9339} { + lb_policy first + max_fails 1 + fail_duration 1ns + unhealthy_status 5xx + unhealthy_latency 10s + + transport http { + dial_timeout 1s + } - # Pass all TLS and connection metadata to the backend - header_up X-Forwarded-For {http.request.remote.host} - header_up X-Forwarded-Port {http.request.remote.port} - header_up X-Forwarded-Proto {http.request.scheme} + lb_try_duration 5s + lb_try_interval 1ns - header_up X-Request-ID "{http.request_id}" + # All your existing header_up metadata... + header_up X-Forwarded-For {http.request.remote.host} + header_up X-Forwarded-Port {http.request.remote.port} + header_up X-Forwarded-Proto {http.request.scheme} - header_up x-tls-version "{tls_version}" - header_up x-tls-version "^{tls_version}$" "" + header_up X-Request-ID "{http.request_id}" - header_up x-tls-client-subject "{tls_client_subject}" - header_up x-tls-client-subject "^{tls_client_subject}$" "" + header_up x-tls-version "{tls_version}" + header_up x-tls-version "^{tls_version}$" "" - header_up x-tls-client-serial "{tls_client_serial}" - header_up x-tls-client-serial "^{tls_client_serial}$" "" + header_up x-tls-client-subject "{tls_client_subject}" + header_up x-tls-client-subject "^{tls_client_subject}$" "" - header_up x-tls-client-issuer "{tls_client_issuer}" - header_up x-tls-client-issuer "^{tls_client_issuer}$" "" + header_up x-tls-client-serial "{tls_client_serial}" + header_up x-tls-client-serial "^{tls_client_serial}$" "" - header_up x-tls-client-fingerprint "{tls_client_fingerprint}" - header_up x-tls-client-fingerprint "^{tls_client_fingerprint}$" "" + header_up x-tls-client-issuer "{tls_client_issuer}" + header_up x-tls-client-issuer "^{tls_client_issuer}$" "" - header_up x-tls-client-certificate-pem "{tls_client_certificate_pem}" - header_up x-tls-client-certificate-pem "^{tls_client_certificate_pem}$" "" + header_up x-tls-client-fingerprint "{tls_client_fingerprint}" + header_up x-tls-client-fingerprint "^{tls_client_fingerprint}$" "" - header_up x-tls-client-certificate-der-base64 "{tls_client_certificate_der_base64}" - header_up x-tls-client-certificate-der-base64 "^{tls_client_certificate_der_base64}$" "" + header_up x-tls-client-certificate-pem "{tls_client_certificate_pem}" + header_up x-tls-client-certificate-pem "^{tls_client_certificate_pem}$" "" - header_up x-tls-cipher "{tls_cipher}" - header_up x-tls-cipher "^{tls_cipher}$" "" + header_up x-tls-client-certificate-der-base64 "{tls_client_certificate_der_base64}" + header_up x-tls-client-certificate-der-base64 "^{tls_client_certificate_der_base64}$" "" - header_up x-remote-port "{remote_port}" - header_up x-remote-port "^{remote_port}$" "" + header_up x-tls-cipher "{tls_cipher}" + header_up x-tls-cipher "^{tls_cipher}$" "" - header_up x-remote-host "{remote_host}" - header_up x-remote-host "^{remote_host}$" "" + header_up x-remote-port "{remote_port}" + header_up x-remote-port "^{remote_port}$" "" - header_up x-method "{method}" - header_up x-method "^{method}$" "" + header_up x-remote-host "{remote_host}" + header_up x-remote-host "^{remote_host}$" "" - header_up x-client-ip "{client_ip}" - header_up x-client-ip "^{client_ip}$" "" + header_up x-method "{method}" + header_up x-method "^{method}$" "" - header_up x-duration-ms {http.request.duration} - header_up x-duration-ms "^{http.request.duration}$" "" + header_up x-client-ip "{client_ip}" + header_up x-client-ip "^{client_ip}$" "" - header_up x-tls-resumed "{http.request.tls.resumed}" - header_up x-tls-resumed "^{http.request.tls.resumed}$" "" + header_up x-duration-ms {http.request.duration} + header_up x-duration-ms "^{http.request.duration}$" "" - header_up x-tls-proto "{http.request.tls.proto}" - header_up x-tls-proto "^{http.request.tls.proto}$" "" + header_up x-tls-resumed "{http.request.tls.resumed}" + header_up x-tls-resumed "^{http.request.tls.resumed}$" "" - header_up x-tls-proto-mutual "{http.request.tls.proto_mutual}" - header_up x-tls-proto-mutual "^{http.request.tls.proto_mutual}$" "" + header_up x-tls-proto "{http.request.tls.proto}" + header_up x-tls-proto "^{http.request.tls.proto}$" "" - header_up x-tls-server-name "{http.request.tls.server_name}" - header_up x-tls-server-name "^{http.request.tls.server_name}$" "" + header_up x-tls-proto-mutual "{http.request.tls.proto_mutual}" + header_up x-tls-proto-mutual "^{http.request.tls.proto_mutual}$" "" - header_up x-tls-public-key "{http.request.tls.public_key}" - header_up x-tls-public-key "^{http.request.tls.public_key}$" "" + header_up x-tls-server-name "{http.request.tls.server_name}" + header_up x-tls-server-name "^{http.request.tls.server_name}$" "" - header_up x-tls-public-key-sha256 "{http.request.tls.public_key_sha256}" - header_up x-tls-public-key-sha256 "^{http.request.tls.public_key_sha256}$" "" + header_up x-tls-public-key "{http.request.tls.public_key}" + header_up x-tls-public-key "^{http.request.tls.public_key}$" "" - header_up x-tls-client-san-dns-names "{http.request.tls.client.san.dns_names}" - header_up x-tls-client-san-dns-names "^{http.request.tls.client.san.dns_names}$" "" + header_up x-tls-public-key-sha256 "{http.request.tls.public_key_sha256}" + header_up x-tls-public-key-sha256 "^{http.request.tls.public_key_sha256}$" "" - header_up x-tls-client-san-emails "{http.request.tls.client.san.emails}" - header_up x-tls-client-san-emails "^{http.request.tls.client.san.emails}$" "" + header_up x-tls-client-san-dns-names "{http.request.tls.client.san.dns_names}" + header_up x-tls-client-san-dns-names "^{http.request.tls.client.san.dns_names}$" "" - header_up x-tls-client-san-ips "{http.request.tls.client.san.ips}" - header_up x-tls-client-san-ips "^{http.request.tls.client.san.ips}$" "" + header_up x-tls-client-san-emails "{http.request.tls.client.san.emails}" + header_up x-tls-client-san-emails "^{http.request.tls.client.san.emails}$" "" - header_up x-tls-client-san-uris "{http.request.tls.client.san.uris}" - header_up x-tls-client-san-uris "^{http.request.tls.client.san.uris}$" "" + header_up x-tls-client-san-ips "{http.request.tls.client.san.ips}" + header_up x-tls-client-san-ips "^{http.request.tls.client.san.ips}$" "" + + header_up x-tls-client-san-uris "{http.request.tls.client.san.uris}" + header_up x-tls-client-san-uris "^{http.request.tls.client.san.uris}$" "" + } } } -# --- Site Blocks --- - -# Metrics endpoint (accessible over private network/IP) +# --- METRICS --- http://:9090 { metrics /metrics } -# Wildcard domain handling for the entire delegated zone +# --- MAIN SITE BLOCK --- *.pronode.prosopo.io { - # SSL via Bunny DNS-01 Challenge tls { dns bunny {$BUNNY_API_KEY} propagation_delay 120s @@ -178,35 +172,27 @@ http://:9090 { resolvers 1.1.1.1 } - # HTTP to HTTPS Redirection (with ACME exception) - @httpOnly { - protocol http - not path /.well-known/acme-challenge/* - } - redir @httpOnly https://{host}{uri} permanent - - # Static assets (Robots.txt) + # 1. Robots.txt handle /robots.txt { root * /srv/static file_server - header Content-Type "text/plain" } - # Case A: Request for THIS specific node (e.g., node2.pronode.prosopo.io) + # 2. Match individual Node ID (for your API updates) @myNode host {$CADDY_DOMAIN} handle @myNode { vars node_id "I-am-node-{$NODE_ID}" - import proxy_logic + import my_proxy_logic } - # Case B: Request for SHARED staging URL (e.g., staging.pronode.prosopo.io) + # 3. Match Global Staging URL @shared host {$CADDY_GLOBAL_DOMAIN} handle @shared { vars node_id "global" - import proxy_logic + import my_proxy_logic } - # Case C: Fallback / Unknown subdomain (Security) + # 4. Fallback for security handle { abort } From c680ccac949709d174ee730eb3fc4a96e4d1b237 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Thu, 5 Feb 2026 11:18:07 +0000 Subject: [PATCH 32/36] =?UTF-8?q?=F0=9F=93=A6=F0=9F=94=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8ef7ca139b..8f63ce71be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@prosopo/captcha", - "version": "3.5.24", + "version": "3.5.25", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@prosopo/captcha", - "version": "3.5.24", + "version": "3.5.25", "license": "Apache-2.0", "workspaces": [ "dev/*", From 67cee4c9ea8514cd9c92cd0cf8c2264b122a3396 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 24 Mar 2026 10:55:53 +0000 Subject: [PATCH 33/36] Reusable robots logic --- docker/provider.Caddyfile | 84 +++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 3ec8ebf95f..3da1cf3fa3 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -3,13 +3,7 @@ http_port {$CADDY_HTTP_PORT:80} https_port 443 auto_https {$CADDY_AUTO_HTTPS:disable_redirects} - - storage redis { - address "{$UPSTASH_REDIS_HOST}:{$UPSTASH_REDIS_PORT}" - password "{$UPSTASH_REDIS_PASSWORD}" - tls_enabled "true" - key_prefix "caddy_prosopo" - } + admin {$CADDY_ADMIN_API::2020} # set the admin api to run on localhost:2020 (default is 2019 which can conflict with caddy daemon) acme_ca https://acme-v02.api.letsencrypt.org/directory email {$CADDY_ACME_EMAIL:admin@prosopo.io} @@ -36,11 +30,20 @@ } } +# Reusable robots.txt logic +(robots_logic) { + handle /robots.txt { + root * /srv/static + file_server + header Content-Type "text/plain" + header X-Robots-Tag "noindex, nofollow" + } +} + # --- REUSABLE PROXY SNIPPET --- # This contains all your headers, rate limits, and proxy logic (my_proxy_logic) { route { - client_hello request_id header X-Request-ID "{http.request_id}" @@ -158,46 +161,39 @@ } } -# --- METRICS --- -http://:9090 { - metrics /metrics -} +# --- 1. MAIN DOMAIN (Mounted Certificate) --- +pronode.prosopo.io { -# --- MAIN SITE BLOCK --- -*.pronode.prosopo.io { - tls { - dns bunny {$BUNNY_API_KEY} - propagation_delay 120s - propagation_timeout 300s - resolvers 1.1.1.1 - } + import robots_logic - # 1. Robots.txt - handle /robots.txt { - root * /srv/static - file_server - } + tls /etc/caddy/certs/pronode.crt /etc/caddy/certs/pronode.key { + # This tells Caddy: "Don't manage this, just use these files" + } - # 2. Match individual Node ID (for your API updates) - @myNode host {$CADDY_DOMAIN} - handle @myNode { - vars node_id "I-am-node-{$NODE_ID}" - import my_proxy_logic - } + vars node_id "main-gateway" + import my_proxy_logic - # 3. Match Global Staging URL - @shared host {$CADDY_GLOBAL_DOMAIN} - handle @shared { - vars node_id "global" - import my_proxy_logic - } + log { + format json + } +} - # 4. Fallback for security - handle { - abort - } +# --- 2. NODE-SPECIFIC DOMAIN (ACME Challenge) --- +{$CADDY_DOMAIN} { - log { - format json - } + import robots_logic + # Caddy will automatically perform the HTTP-01 challenge here + # No tls block needed unless you want to specify an email + + vars node_id "I-am-node-{$NODE_ID}" + import my_proxy_logic + + log { + format json + } +} + +# --- METRICS --- +:9090 { + metrics /metrics } From 7186f4f4d874804212873d17e0aa8df494b761c0 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 24 Mar 2026 10:59:28 +0000 Subject: [PATCH 34/36] fmt --- docker/provider.Caddyfile | 50 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 26 deletions(-) diff --git a/docker/provider.Caddyfile b/docker/provider.Caddyfile index 3da1cf3fa3..dd3c526c5a 100644 --- a/docker/provider.Caddyfile +++ b/docker/provider.Caddyfile @@ -32,12 +32,12 @@ # Reusable robots.txt logic (robots_logic) { - handle /robots.txt { - root * /srv/static - file_server - header Content-Type "text/plain" - header X-Robots-Tag "noindex, nofollow" - } + handle /robots.txt { + root * /srv/static + file_server + header Content-Type "text/plain" + header X-Robots-Tag "noindex, nofollow" + } } # --- REUSABLE PROXY SNIPPET --- @@ -163,37 +163,35 @@ # --- 1. MAIN DOMAIN (Mounted Certificate) --- pronode.prosopo.io { + import robots_logic - import robots_logic - - tls /etc/caddy/certs/pronode.crt /etc/caddy/certs/pronode.key { - # This tells Caddy: "Don't manage this, just use these files" - } + tls /etc/caddy/certs/pronode.crt /etc/caddy/certs/pronode.key { + # This tells Caddy: "Don't manage this, just use these files" + } - vars node_id "main-gateway" - import my_proxy_logic + vars node_id "main-gateway" + import my_proxy_logic - log { - format json - } + log { + format json + } } # --- 2. NODE-SPECIFIC DOMAIN (ACME Challenge) --- {$CADDY_DOMAIN} { + import robots_logic + # Caddy will automatically perform the HTTP-01 challenge here + # No tls block needed unless you want to specify an email - import robots_logic - # Caddy will automatically perform the HTTP-01 challenge here - # No tls block needed unless you want to specify an email - - vars node_id "I-am-node-{$NODE_ID}" - import my_proxy_logic + vars node_id "I-am-node-{$NODE_ID}" + import my_proxy_logic - log { - format json - } + log { + format json + } } # --- METRICS --- :9090 { - metrics /metrics + metrics /metrics } From 15ce26f25c390c399d5ef75cc3d8bdb5f26121b3 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 24 Mar 2026 10:59:36 +0000 Subject: [PATCH 35/36] Mount cert as volume --- docker/docker-compose.provider.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker/docker-compose.provider.yml b/docker/docker-compose.provider.yml index 27e85b7a7e..fb79b3ff69 100644 --- a/docker/docker-compose.provider.yml +++ b/docker/docker-compose.provider.yml @@ -124,6 +124,8 @@ services: - "[::]:443:443" volumes: - ./provider.Caddyfile:/etc/caddy/Caddyfile + - ./certs/pronode.crt /etc/caddy/certs/pronode.crt + - ./certs/pronode.key /etc/caddy/certs/pronode.key - caddy_data:/data - caddy_config:/config networks: From 4708a5493b3bf2460d13a3e7d09fbfcf50459d16 Mon Sep 17 00:00:00 2001 From: Chris Taylor Date: Tue, 24 Mar 2026 11:36:02 +0000 Subject: [PATCH 36/36] lf --- packages/env/src/env.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/env/src/env.ts b/packages/env/src/env.ts index b17d3b012f..20e4eff7a3 100644 --- a/packages/env/src/env.ts +++ b/packages/env/src/env.ts @@ -143,7 +143,7 @@ export class Environment implements ProsopoEnvironment { await this.db.connect(); this.logger.info(() => ({ msg: "Connected to db" })); } - // Set the default datasetId to the most recently uploaded dataset + // Set the default datasetId to the most recently uploaded dataset if (this.db && !this.datasetId) { try { this.datasetId = await this.db.getMostRecentDatasetId();