Track HiveGrid Cloud planning reference
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in new issue
Block a user