Files
openclaw/extensions/clickclack/src/setup-contract.ts
Shakker 49aa383dd7 fix: consume ClickClack v1 setup claim contract (#111927)
Support ClickClack v1 setup claim URLs for split-origin and path-mounted deployments while preserving legacy setup flows and private transport overrides.

Refs #111919.
Prepared head SHA: c13ce82e38
Co-authored-by: Shakker <165377636+shakkernerd@users.noreply.github.com>
Reviewed-by: @shakkernerd
2026-07-20 18:50:30 +01:00

102 lines
3.0 KiB
TypeScript

import net from "node:net";
export const CLICKCLACK_SETUP_CODE_CLAIM_PATH = "/api/bot-setup-codes/claim";
const LOOPBACK_ADDRESSES = new net.BlockList();
LOOPBACK_ADDRESSES.addSubnet("127.0.0.0", 8, "ipv4");
LOOPBACK_ADDRESSES.addAddress("::1", "ipv6");
const BASE_PATH_SEGMENT = /^[A-Za-z0-9._~-]+$/u;
export function isClickClackSetupLoopbackHost(hostname: string): boolean {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
if (normalized === "localhost") {
return true;
}
const family = net.isIP(normalized);
return family !== 0 && LOOPBACK_ADDRESSES.check(normalized, family === 4 ? "ipv4" : "ipv6");
}
export function requireClickClackSetupApiBaseUrl(value: string, label: string): string {
if (!value || value !== value.trim()) {
throw new Error(`ClickClack ${label} is invalid`);
}
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error(`ClickClack ${label} is invalid`);
}
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
!parsed.hostname ||
parsed.hostname.endsWith(".") ||
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash
) {
throw new Error(`ClickClack ${label} is invalid`);
}
if (parsed.protocol === "http:" && !isClickClackSetupLoopbackHost(parsed.hostname)) {
throw new Error(`ClickClack ${label} must use HTTPS unless it is on loopback`);
}
const pathname = parsed.pathname;
let basePath = "";
if (pathname !== "/") {
if (
pathname.endsWith("/") ||
pathname.includes("//") ||
pathname.includes("\\") ||
pathname
.slice(1)
.split("/")
.some((segment) => !BASE_PATH_SEGMENT.test(segment))
) {
throw new Error(`ClickClack ${label} is invalid`);
}
basePath = pathname;
}
const canonical = parsed.origin + basePath;
if (value !== canonical) {
throw new Error(`ClickClack ${label} is not canonical`);
}
return canonical;
}
export function requireClickClackSetupClaimUrl(value: string): {
claimUrl: string;
apiBaseUrl: string;
} {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
throw new Error("ClickClack setup URL has an invalid claim endpoint.");
}
if (
parsed.username ||
parsed.password ||
parsed.search ||
parsed.hash ||
!parsed.pathname.endsWith(CLICKCLACK_SETUP_CODE_CLAIM_PATH)
) {
throw new Error("ClickClack setup URL has an invalid claim endpoint.");
}
const basePath = parsed.pathname.slice(0, -CLICKCLACK_SETUP_CODE_CLAIM_PATH.length);
const apiBaseUrl = requireClickClackSetupApiBaseUrl(
parsed.origin + basePath,
"setup URL API base",
);
const claimUrl = apiBaseUrl + CLICKCLACK_SETUP_CODE_CLAIM_PATH;
if (value !== claimUrl) {
throw new Error("ClickClack setup URL has a non-canonical claim endpoint.");
}
return { claimUrl, apiBaseUrl };
}
export function buildClickClackSetupClaimUrl(baseUrl: string): string {
return baseUrl.replace(/\/+$/u, "") + CLICKCLACK_SETUP_CODE_CLAIM_PATH;
}