Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,19 @@
{
"name": "@toolctrl/captcha",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,32 @@
import { describe, it, expect } from "vitest";
import { resolveCaptchaConfig, getSitekey, isCaptchaConfigured } from "./config.js";
describe("resolveCaptchaConfig", () => {
it("maps env vars to config", () => {
const cfg = resolveCaptchaConfig({
MCAPTCHA_INSTANCE_URL: "https://captcha.tool-ctrl.de",
MCAPTCHA_ACCOUNT_SECRET: "secret",
MCAPTCHA_SITEKEY_LOGIN: "login-key",
MCAPTCHA_SITEKEY_FORMS: "forms-key",
MCAPTCHA_SITEKEY_BOOKING: "booking-key",
});
expect(cfg.provider).toBe("mcaptcha");
expect(cfg.instanceUrl).toBe("https://captcha.tool-ctrl.de");
expect(getSitekey(cfg, "login")).toBe("login-key");
expect(getSitekey(cfg, "booking")).toBe("booking-key");
expect(isCaptchaConfigured(cfg)).toBe(true);
});
it("defaults failOpen to true and respects explicit false", () => {
expect(resolveCaptchaConfig({}).failOpen).toBe(true);
expect(resolveCaptchaConfig({ CAPTCHA_FAIL_OPEN: "false" }).failOpen).toBe(false);
expect(resolveCaptchaConfig({ CAPTCHA_FAIL_OPEN: "true" }).failOpen).toBe(true);
});
it("treats blank env values as undefined and reports unconfigured", () => {
const cfg = resolveCaptchaConfig({ MCAPTCHA_INSTANCE_URL: " ", MCAPTCHA_ACCOUNT_SECRET: "" });
expect(cfg.instanceUrl).toBeUndefined();
expect(cfg.accountSecret).toBeUndefined();
expect(isCaptchaConfigured(cfg)).toBe(false);
});
});
@@ -0,0 +1,43 @@
import type { CaptchaConfig, CaptchaSurface } from "./types.js";
type EnvLike = Record<string, string | undefined>;
function present(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
/**
* Resolves the captcha config from env. mCaptcha is the only provider
* (see HiveGrid docs/ARCHITECTURE-DECISIONS.md AD-7).
*
* MCAPTCHA_INSTANCE_URL=https://captcha.tool-ctrl.de
* MCAPTCHA_ACCOUNT_SECRET=<dashboard account secret>
* MCAPTCHA_SITEKEY_LOGIN / _FORMS / _BOOKING=<public sitekeys>
* CAPTCHA_FAIL_OPEN=true|false (default true)
*/
export function resolveCaptchaConfig(env: EnvLike): CaptchaConfig {
return {
provider: "mcaptcha",
instanceUrl: present(env.MCAPTCHA_INSTANCE_URL),
accountSecret: present(env.MCAPTCHA_ACCOUNT_SECRET),
sitekeys: {
login: present(env.MCAPTCHA_SITEKEY_LOGIN),
forms: present(env.MCAPTCHA_SITEKEY_FORMS),
booking: present(env.MCAPTCHA_SITEKEY_BOOKING),
},
failOpen: present(env.CAPTCHA_FAIL_OPEN) !== "false",
};
}
export function getSitekey(
config: CaptchaConfig,
surface: CaptchaSurface,
): string | undefined {
return config.sitekeys[surface];
}
/** True when the instance + account secret are present (sitekeys checked per-surface). */
export function isCaptchaConfigured(config: CaptchaConfig): boolean {
return Boolean(config.instanceUrl && config.accountSecret);
}
@@ -0,0 +1,5 @@
export type { CaptchaSurface, CaptchaLogger, CaptchaConfig } from "./types.js";
export { CAPTCHA_SURFACES } from "./types.js";
export { verifyCaptchaToken } from "./verify.js";
export type { VerifyCaptchaArgs } from "./verify.js";
export { resolveCaptchaConfig, getSitekey, isCaptchaConfigured } from "./config.js";
@@ -0,0 +1,25 @@
// Captcha surfaces — one mCaptcha sitekey per surface so difficulty/rate-limit
// can be tuned independently (see infra/mcaptcha/README.md).
export type CaptchaSurface = "login" | "forms" | "booking";
export const CAPTCHA_SURFACES: readonly CaptchaSurface[] = ["login", "forms", "booking"];
export type CaptchaLogger = {
warn: (metadata: Record<string, unknown>, message: string) => void;
};
export type CaptchaConfig = {
provider: "mcaptcha";
/** Public base URL of the self-hosted mCaptcha instance, e.g. https://captcha.tool-ctrl.de */
instanceUrl: string | undefined;
/** mCaptcha dashboard account secret used for server-side siteverify. */
accountSecret: string | undefined;
/** Public sitekey per surface (created in the mCaptcha dashboard). */
sitekeys: Record<CaptchaSurface, string | undefined>;
/**
* When the instance is unreachable/misconfigured: true = let the request
* through (fail-open, default), false = block it (fail-closed). A missing
* token is always a hard failure regardless of this flag.
*/
failOpen: boolean;
};
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { verifyCaptchaToken } from "./verify.js";
const base = {
token: "pow-token",
sitekey: "site-key",
secret: "account-secret",
instanceUrl: "https://captcha.example.test",
};
function jsonResponse(body: unknown, ok = true, status = 200): Response {
return {
ok,
status,
json: async () => body,
} as unknown as Response;
}
describe("verifyCaptchaToken", () => {
it("returns true when mCaptcha reports valid", async () => {
let calledUrl = "";
let calledBody: unknown = null;
const fetchImpl = (async (url: string, init?: RequestInit) => {
calledUrl = url;
calledBody = JSON.parse(String(init?.body));
return jsonResponse({ valid: true });
}) as unknown as typeof fetch;
const ok = await verifyCaptchaToken({ ...base, fetchImpl });
expect(ok).toBe(true);
expect(calledUrl).toBe("https://captcha.example.test/api/v1/pow/siteverify");
expect(calledBody).toEqual({ token: "pow-token", key: "site-key", secret: "account-secret" });
});
it("returns false when mCaptcha reports invalid", async () => {
const fetchImpl = (async () => jsonResponse({ valid: false })) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, fetchImpl })).toBe(false);
});
it("returns false on missing token even when configured (hard failure)", async () => {
const fetchImpl = (async () => jsonResponse({ valid: true })) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, token: "", fetchImpl })).toBe(false);
});
it("fails open (true) on network error by default", async () => {
const fetchImpl = (async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, fetchImpl })).toBe(true);
});
it("fails closed (false) on network error when failOpen=false", async () => {
const fetchImpl = (async () => {
throw new Error("ECONNREFUSED");
}) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, failOpen: false, fetchImpl })).toBe(false);
});
it("fails open on non-OK HTTP status", async () => {
const fetchImpl = (async () => jsonResponse({}, false, 502)) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, fetchImpl })).toBe(true);
});
it("applies fail policy when misconfigured (no secret)", async () => {
const fetchImpl = (async () => jsonResponse({ valid: true })) as unknown as typeof fetch;
expect(await verifyCaptchaToken({ ...base, secret: undefined, fetchImpl })).toBe(true);
expect(
await verifyCaptchaToken({ ...base, secret: undefined, failOpen: false, fetchImpl }),
).toBe(false);
});
});
@@ -0,0 +1,91 @@
import type { CaptchaLogger } from "./types.js";
const DEFAULT_TIMEOUT_MS = 3_000;
const SITEVERIFY_PATH = "/api/v1/pow/siteverify";
export type VerifyCaptchaArgs = {
/** PoW solution token produced by the mCaptcha widget. */
token: string | null | undefined;
/** Public sitekey for the surface being protected. */
sitekey: string | undefined;
/** mCaptcha account secret (server-side only). */
secret: string | undefined;
/** Base URL of the mCaptcha instance. */
instanceUrl: string | undefined;
/** Outage/misconfig policy: true (default) lets the request through. */
failOpen?: boolean;
logger?: CaptchaLogger;
fetchImpl?: typeof fetch;
timeoutMs?: number;
};
/**
* Verifies an mCaptcha proof-of-work token against a self-hosted instance.
*
* POST {instanceUrl}/api/v1/pow/siteverify body: { token, key, secret }
* response: { valid: boolean }
*
* Semantics:
* - missing token -> false (real client failure, never fail-open)
* - misconfigured / outage -> `failOpen` (default true), with a warn log
* - valid:true -> true, otherwise false
*/
export async function verifyCaptchaToken({
token,
sitekey,
secret,
instanceUrl,
failOpen = true,
logger,
fetchImpl = fetch,
timeoutMs = DEFAULT_TIMEOUT_MS,
}: VerifyCaptchaArgs): Promise<boolean> {
if (!instanceUrl || !secret || !sitekey) {
logger?.warn(
{
op: "captcha.misconfigured",
hasInstance: Boolean(instanceUrl),
hasSecret: Boolean(secret),
hasSitekey: Boolean(sitekey),
failOpen,
},
"mCaptcha not configured — applying fail policy",
);
return failOpen;
}
if (!token) return false;
const url = instanceUrl.replace(/\/+$/, "") + SITEVERIFY_PATH;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetchImpl(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token, key: sitekey, secret }),
signal: controller.signal,
});
if (!res.ok) {
logger?.warn(
{ op: "captcha.http_error", status: res.status, failOpen },
"mCaptcha siteverify returned non-OK — applying fail policy",
);
return failOpen;
}
const data = (await res.json()) as { valid?: boolean };
return data.valid === true;
} catch (err) {
logger?.warn(
{
op: "captcha.error",
err: err instanceof Error ? err.message : String(err),
failOpen,
},
"mCaptcha verification failed — applying fail policy",
);
return failOpen;
} finally {
clearTimeout(timeout);
}
}
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}