mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
847 lines
34 KiB
JavaScript
847 lines
34 KiB
JavaScript
import net from "node:net";
|
|
import dns from "node:dns/promises";
|
|
import { loadConfig, loadSecrets } from "./config.js";
|
|
|
|
export const CONTEXT_FAILURES = Object.freeze([
|
|
"CONTEXT_KEY_MISSING",
|
|
"CONTEXT_KEY_INVALID",
|
|
"CONTEXT_EMAIL_UNVERIFIED",
|
|
"CONTEXT_CREDITS_EXHAUSTED",
|
|
"CONTEXT_RATE_LIMITED",
|
|
"CONTEXT_TIMEOUT",
|
|
"CONTEXT_BLOCKED",
|
|
"CONTEXT_BAD_RESPONSE",
|
|
]);
|
|
|
|
const CONTEXT_FAILURE_SET = new Set(CONTEXT_FAILURES);
|
|
const OFFICIAL_BASE_URL = "https://api.context.dev/v1";
|
|
const PREFIXED_CREDENTIAL = /(?:^|[^A-Za-z0-9])(?:AIza[0-9A-Za-z_-]{20,}|AKIA[0-9A-Z]{16}|(?:ctxt|github_pat|gh[pousr]|sk|pk|rk|xox[aboprs])[-_][A-Za-z0-9._~-]{10,}|eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})(?:$|[^A-Za-z0-9])/i;
|
|
// `/` is a public URL/path separator, not part of one opaque candidate. Each
|
|
// path segment is still scanned independently, avoiding false positives where
|
|
// a long mixed-case documentation path looked like one credential.
|
|
const OPAQUE_TOKEN_CANDIDATE = /[A-Za-z0-9._~+=-]{32,}/g;
|
|
const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
const MAX_CREDENTIAL_DECODE_PASSES = 8;
|
|
const MIN_OPAQUE_TOKEN_ENTROPY = 4.25;
|
|
|
|
const BOOLEAN_OPTION = "boolean";
|
|
const INTEGER_OPTION = "integer";
|
|
const STRING_OPTION = "string";
|
|
const STRING_ARRAY_OPTION = "string-array";
|
|
const PDF_OPTIONS = Object.freeze({
|
|
shouldParse: BOOLEAN_OPTION,
|
|
start: INTEGER_OPTION,
|
|
end: INTEGER_OPTION,
|
|
ocr: BOOLEAN_OPTION,
|
|
});
|
|
const VIEWPORT_OPTIONS = Object.freeze({
|
|
width: INTEGER_OPTION,
|
|
height: INTEGER_OPTION,
|
|
});
|
|
|
|
// Context.dev also documents free-form outbound headers and request tags. They
|
|
// are deliberately excluded: either can carry authentication or unrelated
|
|
// private text. Only the public extraction controls below may cross the boundary.
|
|
const PUBLIC_OPTION_SCHEMAS = Object.freeze({
|
|
scrapeMarkdown: Object.freeze({
|
|
includeLinks: BOOLEAN_OPTION,
|
|
includeImages: BOOLEAN_OPTION,
|
|
shortenBase64Images: BOOLEAN_OPTION,
|
|
useMainContentOnly: BOOLEAN_OPTION,
|
|
pdf: Object.freeze({ object: PDF_OPTIONS }),
|
|
includeFrames: BOOLEAN_OPTION,
|
|
includeSelectors: STRING_ARRAY_OPTION,
|
|
excludeSelectors: STRING_ARRAY_OPTION,
|
|
maxAgeMs: INTEGER_OPTION,
|
|
waitForMs: INTEGER_OPTION,
|
|
settleAnimations: BOOLEAN_OPTION,
|
|
country: STRING_OPTION,
|
|
timeoutMS: INTEGER_OPTION,
|
|
}),
|
|
scrapeHtml: Object.freeze({
|
|
pdf: Object.freeze({ object: PDF_OPTIONS }),
|
|
includeFrames: BOOLEAN_OPTION,
|
|
useMainContentOnly: BOOLEAN_OPTION,
|
|
includeSelectors: STRING_ARRAY_OPTION,
|
|
excludeSelectors: STRING_ARRAY_OPTION,
|
|
maxAgeMs: INTEGER_OPTION,
|
|
waitForMs: INTEGER_OPTION,
|
|
settleAnimations: BOOLEAN_OPTION,
|
|
country: STRING_OPTION,
|
|
timeoutMS: INTEGER_OPTION,
|
|
}),
|
|
crawl: Object.freeze({
|
|
maxPages: INTEGER_OPTION,
|
|
maxDepth: INTEGER_OPTION,
|
|
urlRegex: STRING_OPTION,
|
|
includeLinks: BOOLEAN_OPTION,
|
|
includeImages: BOOLEAN_OPTION,
|
|
shortenBase64Images: BOOLEAN_OPTION,
|
|
useMainContentOnly: BOOLEAN_OPTION,
|
|
followSubdomains: BOOLEAN_OPTION,
|
|
pdf: Object.freeze({ object: PDF_OPTIONS }),
|
|
includeFrames: BOOLEAN_OPTION,
|
|
includeSelectors: STRING_ARRAY_OPTION,
|
|
excludeSelectors: STRING_ARRAY_OPTION,
|
|
maxAgeMs: INTEGER_OPTION,
|
|
waitForMs: INTEGER_OPTION,
|
|
settleAnimations: BOOLEAN_OPTION,
|
|
stopAfterMs: INTEGER_OPTION,
|
|
country: STRING_OPTION,
|
|
timeoutMS: INTEGER_OPTION,
|
|
}),
|
|
sitemap: Object.freeze({
|
|
maxLinks: INTEGER_OPTION,
|
|
sitemapUrl: STRING_OPTION,
|
|
urlRegex: STRING_OPTION,
|
|
timeoutMS: INTEGER_OPTION,
|
|
}),
|
|
screenshot: Object.freeze({
|
|
domain: STRING_OPTION,
|
|
directUrl: STRING_OPTION,
|
|
fullScreenshot: BOOLEAN_OPTION,
|
|
page: Object.freeze({ enum: Object.freeze(["login", "signup", "blog", "careers", "pricing", "terms", "privacy", "contact"]) }),
|
|
waitForMs: INTEGER_OPTION,
|
|
viewport: Object.freeze({ object: VIEWPORT_OPTIONS }),
|
|
handleCookiePopup: BOOLEAN_OPTION,
|
|
colorScheme: Object.freeze({ enum: Object.freeze(["light", "dark"]) }),
|
|
scrollOffset: INTEGER_OPTION,
|
|
maxAgeMs: INTEGER_OPTION,
|
|
country: STRING_OPTION,
|
|
timeoutMS: INTEGER_OPTION,
|
|
}),
|
|
});
|
|
|
|
export class ContextError extends Error {
|
|
constructor(code, message, options = {}) {
|
|
if (!CONTEXT_FAILURE_SET.has(code)) throw new TypeError(`Unknown Context.dev failure code: ${code}`);
|
|
const secrets = options.secrets ?? [];
|
|
const safeCause = sanitizeErrorCause(options.cause, secrets);
|
|
super(redactSensitiveText(message, secrets), safeCause ? { cause: safeCause } : undefined);
|
|
this.name = "ContextError";
|
|
this.code = code;
|
|
this.status = options.status;
|
|
this.retryAfter = options.retryAfter;
|
|
this.details = redactSensitiveValue(options.details, secrets);
|
|
this.unsupported = Boolean(options.unsupported);
|
|
}
|
|
|
|
static fromCause(code, message, cause, options = {}) {
|
|
return new ContextError(code, message, { ...options, cause });
|
|
}
|
|
|
|
toJSON() {
|
|
return {
|
|
name: this.name,
|
|
code: this.code,
|
|
message: this.message,
|
|
...(this.status == null ? {} : { status: this.status }),
|
|
...(this.retryAfter == null ? {} : { retryAfter: this.retryAfter }),
|
|
...(this.unsupported ? { unsupported: true } : {}),
|
|
};
|
|
}
|
|
}
|
|
|
|
export async function readContextKey(options = {}) {
|
|
const env = options.env ?? process.env;
|
|
const fromEnv = env.CONTEXT_DEV_API_KEY || env.CONTEXT_API_KEY;
|
|
if (fromEnv?.trim()) return { key: fromEnv.trim(), source: "environment" };
|
|
|
|
const home = options.home;
|
|
if (!home) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
|
|
const secrets = await loadSecrets(home);
|
|
const key = secrets?.context?.apiKey;
|
|
if (!key) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
|
|
return { key: String(key).trim(), source: "secrets.json" };
|
|
}
|
|
|
|
export function validateContextKey(key) {
|
|
if (typeof key !== "string" || !key) {
|
|
throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is missing");
|
|
}
|
|
// Do not bake in a provider prefix: Context.dev may rotate key formats.
|
|
// Whitespace is never valid in a bearer token; a small length floor catches
|
|
// accidental empty/placeholder values without rejecting a future prefix.
|
|
if (key.length < 12 || /\s/.test(key)) {
|
|
throw new ContextError("CONTEXT_KEY_INVALID", "Context.dev API key has an invalid format");
|
|
}
|
|
return key;
|
|
}
|
|
|
|
/**
|
|
* Lexically validate a URL before any DNS lookup or HTTP request occurs.
|
|
*/
|
|
export function assertPublicUrl(input) {
|
|
let url;
|
|
try {
|
|
url = input instanceof URL ? new URL(input.href) : new URL(String(input));
|
|
} catch (cause) {
|
|
throw ContextError.fromCause("CONTEXT_BLOCKED", "Target must be an absolute public HTTP(S) URL", cause);
|
|
}
|
|
if (!["http:", "https:"].includes(url.protocol)) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Only HTTP and HTTPS target URLs are allowed");
|
|
}
|
|
if (url.username || url.password) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Target URLs must not contain credentials");
|
|
}
|
|
assertNoCredentialMaterial(decodeUrlComponent(url.pathname), "URL path");
|
|
for (const [key, value] of url.searchParams) {
|
|
if (isSensitiveFieldName(key) || containsCredentialLabel(key)) {
|
|
throw new ContextError("CONTEXT_BLOCKED", `Target URL contains credential-like query parameter: ${key}`);
|
|
}
|
|
assertNoCredentialMaterial(key, "URL query parameter name");
|
|
assertNoCredentialMaterial(value, "URL query value");
|
|
}
|
|
const fragment = decodeUrlFragment(url.hash.slice(1));
|
|
assertNoCredentialMaterial(fragment, "URL fragment");
|
|
for (const part of fragment.split(/[?&;]/)) {
|
|
const separator = part.indexOf("=");
|
|
if (separator === -1) continue;
|
|
const key = part.slice(0, separator).trim();
|
|
if (isSensitiveFieldName(key) || containsCredentialLabel(key)) {
|
|
throw new ContextError("CONTEXT_BLOCKED", `Target URL contains credential-like fragment parameter: ${key}`);
|
|
}
|
|
assertNoCredentialMaterial(part.slice(separator + 1), "URL fragment value");
|
|
}
|
|
assertPublicHostname(url.hostname);
|
|
return url;
|
|
}
|
|
|
|
export function assertPublicHostname(input) {
|
|
const hostname = String(input).replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
|
|
if (!hostname || hostname.includes("\0")) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Target hostname is missing or invalid");
|
|
}
|
|
const ipVersion = net.isIP(hostname);
|
|
if (ipVersion) {
|
|
if (!isPublicIp(hostname)) throw new ContextError("CONTEXT_BLOCKED", "Target IP address is not public");
|
|
return hostname;
|
|
}
|
|
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Localhost targets are not allowed");
|
|
}
|
|
const forbiddenSuffixes = [
|
|
".local", ".internal", ".intranet", ".lan", ".home", ".home.arpa",
|
|
".localdomain", ".corp", ".private", ".test", ".invalid", ".example",
|
|
];
|
|
if (!hostname.includes(".") || forbiddenSuffixes.some((suffix) => hostname.endsWith(suffix))) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Private or non-public hostnames are not allowed");
|
|
}
|
|
if (/^(metadata|instance-data)(\.|$)/.test(hostname) || hostname === "metadata.google.internal") {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Cloud metadata hostnames are not allowed");
|
|
}
|
|
return hostname;
|
|
}
|
|
|
|
export function isPublicIp(input) {
|
|
const address = String(input).replace(/^\[|\]$/g, "");
|
|
const version = net.isIP(address);
|
|
if (version === 4) return isPublicIpv4(address);
|
|
if (version === 6) return isPublicIpv6(address);
|
|
return false;
|
|
}
|
|
|
|
export async function assertPublicUrlResolved(input, options = {}) {
|
|
const url = assertPublicUrl(input);
|
|
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
|
if (net.isIP(hostname)) return url;
|
|
const lookup = options.lookup ?? dns.lookup;
|
|
let records;
|
|
try {
|
|
records = await lookup(hostname, { all: true, verbatim: true });
|
|
} catch (cause) {
|
|
throw ContextError.fromCause("CONTEXT_BLOCKED", "Target hostname could not be resolved publicly", cause);
|
|
}
|
|
const list = Array.isArray(records) ? records : [records];
|
|
if (!list.length || list.some((record) => !isPublicIp(record?.address ?? record))) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Target hostname resolves to a non-public address");
|
|
}
|
|
return url;
|
|
}
|
|
|
|
export function mapContextFailure(status, payload = {}, cause, headers, options = {}) {
|
|
const secrets = options.secrets ?? [];
|
|
const safeCause = sanitizeErrorCause(cause, secrets);
|
|
if (cause?.name === "AbortError" || cause?.code === "ABORT_ERR" || cause?.code === "ETIMEDOUT") {
|
|
return new ContextError("CONTEXT_TIMEOUT", "Context.dev request timed out", { status, cause: safeCause, secrets });
|
|
}
|
|
const apiCode = String(payload?.error_code ?? payload?.code ?? "").toUpperCase();
|
|
const rawMessage = String(payload?.message ?? payload?.error ?? "Context.dev request failed");
|
|
const searchable = `${apiCode} ${rawMessage}`.toLowerCase();
|
|
const message = redactSensitiveText(rawMessage, secrets);
|
|
const details = payload && typeof payload === "object" ? redactSensitiveValue(payload, secrets) : undefined;
|
|
|
|
if (status === 429 || apiCode === "RATE_LIMITED") {
|
|
const retryAfter = getHeader(headers, "retry-after");
|
|
return new ContextError("CONTEXT_RATE_LIMITED", message, { status, retryAfter, cause: safeCause, details, secrets });
|
|
}
|
|
if (status === 408 || apiCode === "REQUEST_TIMEOUT" || apiCode === "TIMEOUT_EXCEEDS_MAXIMUM") {
|
|
return new ContextError("CONTEXT_TIMEOUT", message, { status, cause: safeCause, details, secrets });
|
|
}
|
|
if (apiCode === "USAGE_EXCEEDED" || /credits?\s*(exhausted|exceeded|remaining\s*[:=]?\s*0)|usage\s*(limit|exceeded)|quota/.test(searchable)) {
|
|
return new ContextError("CONTEXT_CREDITS_EXHAUSTED", message, { status, cause: safeCause, details, secrets });
|
|
}
|
|
if (/email.*(unverified|not verified|verify)|verify.*email/.test(searchable)) {
|
|
return new ContextError("CONTEXT_EMAIL_UNVERIFIED", message, { status, cause: safeCause, details, secrets });
|
|
}
|
|
if (apiCode === "WEBSITE_ACCESS_ERROR" || apiCode === "EXTERNAL_PROVIDER_ERROR" ||
|
|
/blocked|private address|localhost|link.local|website access|hostile waf/.test(searchable)) {
|
|
return new ContextError("CONTEXT_BLOCKED", message, { status, cause: safeCause, details, secrets });
|
|
}
|
|
if (status === 401 || ["UNAUTHORIZED", "DISABLED", "INSUFFICIENT_PERMISSIONS", "FORBIDDEN"].includes(apiCode) || status === 403) {
|
|
return new ContextError("CONTEXT_KEY_INVALID", message, { status, cause: safeCause, details, secrets });
|
|
}
|
|
return new ContextError("CONTEXT_BAD_RESPONSE", message, { status, cause: safeCause, details, secrets });
|
|
}
|
|
|
|
export class ContextClient {
|
|
constructor(options = {}) {
|
|
this.home = options.home;
|
|
this.env = options.env ?? process.env;
|
|
this.fetch = options.fetch ?? globalThis.fetch;
|
|
this.lookup = options.lookup ?? dns.lookup;
|
|
this.resolveDns = options.resolveDns ?? true;
|
|
this.config = options.config;
|
|
this.key = options.key;
|
|
this.timeoutMs = options.timeoutMs ?? 90_000;
|
|
this.baseUrl = options.baseUrl;
|
|
}
|
|
|
|
async scrapeMarkdown(url, options = {}) {
|
|
const publicOptions = assertPublicRequestOptions("scrapeMarkdown", options);
|
|
const target = String(url);
|
|
await this.#gateTarget(target);
|
|
return this.#request("GET", "/web/scrape/markdown", { query: { ...publicOptions, url: target } });
|
|
}
|
|
|
|
async scrapeHtml(url, options = {}) {
|
|
const publicOptions = assertPublicRequestOptions("scrapeHtml", options);
|
|
const target = String(url);
|
|
await this.#gateTarget(target);
|
|
return this.#request("GET", "/web/scrape/html", { query: { ...publicOptions, url: target } });
|
|
}
|
|
|
|
async crawl(url, options = {}) {
|
|
const publicOptions = assertPublicRequestOptions("crawl", options);
|
|
const target = String(url);
|
|
await this.#gateTarget(target);
|
|
return this.#request("POST", "/web/crawl", { body: { ...publicOptions, url: target } });
|
|
}
|
|
|
|
async sitemap(domain, options = {}) {
|
|
const publicOptions = assertPublicRequestOptions("sitemap", options);
|
|
const normalized = normalizeDomain(domain);
|
|
await this.#gateTarget(`https://${normalized}`);
|
|
if (publicOptions.sitemapUrl) await this.#gateTarget(publicOptions.sitemapUrl);
|
|
return this.#request("GET", "/web/scrape/sitemap", { query: { ...publicOptions, domain: normalized } });
|
|
}
|
|
|
|
async screenshot(target, options = {}) {
|
|
let query;
|
|
if (target && typeof target === "object" && !(target instanceof URL)) {
|
|
query = { ...target, ...options };
|
|
} else {
|
|
const serialized = String(target);
|
|
query = /^https?:\/\//i.test(serialized)
|
|
? { directUrl: serialized, ...options }
|
|
: { domain: serialized, ...options };
|
|
}
|
|
query = assertPublicRequestOptions("screenshot", query);
|
|
if (query.directUrl && query.domain) {
|
|
throw new ContextError("CONTEXT_BAD_RESPONSE", "Screenshot accepts either domain or directUrl, not both");
|
|
}
|
|
if (query.directUrl) await this.#gateTarget(query.directUrl);
|
|
else {
|
|
query.domain = normalizeDomain(query.domain);
|
|
await this.#gateTarget(`https://${query.domain}`);
|
|
}
|
|
return this.#request("GET", "/screenshot", { query });
|
|
}
|
|
|
|
async search() {
|
|
throw new ContextError(
|
|
"CONTEXT_BAD_RESPONSE",
|
|
"Context.dev Search API is deprecated and is intentionally unsupported",
|
|
{ unsupported: true },
|
|
);
|
|
}
|
|
|
|
async #gateTarget(input) {
|
|
// The lexical gate is intentionally first and performs no I/O. DNS is only
|
|
// reached after #networkSettings confirms explicit persisted consent.
|
|
const url = assertPublicUrl(input);
|
|
const settings = await this.#networkSettings();
|
|
if (!settings.enabled) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Network access is off; run `gstack context setup --consent`");
|
|
}
|
|
if (this.resolveDns) {
|
|
try {
|
|
await withTimeout(assertPublicUrlResolved(url, { lookup: this.lookup }), this.timeoutMs);
|
|
} catch (cause) {
|
|
if (cause instanceof ContextError) throw cause;
|
|
throw mapContextFailure(undefined, {}, cause);
|
|
}
|
|
}
|
|
return url;
|
|
}
|
|
|
|
async #networkSettings() {
|
|
const config = this.config ?? await loadConfig(this.home);
|
|
return {
|
|
enabled: hasContextNetworkConsent(config),
|
|
config,
|
|
};
|
|
}
|
|
|
|
async #apiKey() {
|
|
if (this.key) return validateContextKey(String(this.key));
|
|
return validateContextKey((await readContextKey({ home: this.home, env: this.env })).key);
|
|
}
|
|
|
|
async #request(method, endpoint, options = {}) {
|
|
if (typeof this.fetch !== "function") {
|
|
throw new ContextError("CONTEXT_BAD_RESPONSE", "This Node runtime does not provide fetch()");
|
|
}
|
|
const { config, enabled } = await this.#networkSettings();
|
|
if (!enabled) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Network access is off; explicit Context.dev consent is required");
|
|
}
|
|
const configuredBase = this.baseUrl ?? config?.context?.baseUrl ?? OFFICIAL_BASE_URL;
|
|
const baseUrl = validateBaseUrl(configuredBase);
|
|
const key = await this.#apiKey();
|
|
if (!key) throw new ContextError("CONTEXT_KEY_MISSING", "Context.dev API key is not configured");
|
|
const requestUrl = new URL(`${baseUrl.replace(/\/$/, "")}${endpoint}`);
|
|
addQuery(requestUrl.searchParams, options.query ?? {});
|
|
const controller = new AbortController();
|
|
const timeoutMs = options.timeoutMs ?? this.timeoutMs;
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
// This timer is part of the public operation contract, so keep it
|
|
// referenced until the request settles. Bun on Windows may otherwise
|
|
// leave a caller awaiting an unresolving fetch/body without delivering
|
|
// the unref'ed abort timer.
|
|
let response;
|
|
try {
|
|
try {
|
|
response = await raceWithAbort(this.fetch(requestUrl, {
|
|
method,
|
|
headers: {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${key}`,
|
|
...(options.body ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
signal: controller.signal,
|
|
redirect: "error",
|
|
}), controller.signal);
|
|
} catch (cause) {
|
|
if (cause instanceof ContextError) throw cause;
|
|
throw mapContextFailure(undefined, {}, cause, undefined, { secrets: [key] });
|
|
}
|
|
|
|
let text;
|
|
try {
|
|
text = await raceWithAbort(response.text(), controller.signal);
|
|
} catch (cause) {
|
|
throw mapContextFailure(response.status, {}, cause, response.headers, { secrets: [key] });
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = text ? JSON.parse(text) : null;
|
|
} catch (cause) {
|
|
throw ContextError.fromCause("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", cause, {
|
|
status: response.status,
|
|
secrets: [key],
|
|
});
|
|
}
|
|
if (!response.ok) {
|
|
throw mapContextFailure(response.status, payload, undefined, response.headers, { secrets: [key] });
|
|
}
|
|
if (!payload || typeof payload !== "object") {
|
|
throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned an empty or invalid response", {
|
|
status: response.status,
|
|
secrets: [key],
|
|
});
|
|
}
|
|
return payload;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function assertPublicRequestOptions(endpoint, options) {
|
|
const schema = PUBLIC_OPTION_SCHEMAS[endpoint];
|
|
if (!schema) throw new TypeError(`Unknown Context.dev option schema: ${endpoint}`);
|
|
return copyOptionObject(options, schema, endpoint);
|
|
}
|
|
|
|
function copyOptionObject(value, schema, trail) {
|
|
if (!isPlainObject(value)) {
|
|
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${trail} options must be a plain public-data object`);
|
|
}
|
|
const copy = {};
|
|
for (const [key, child] of Object.entries(value)) {
|
|
if (!Object.hasOwn(schema, key)) {
|
|
throw new ContextError("CONTEXT_BLOCKED", `Context.dev request option is not allowlisted: ${trail}.${key}`);
|
|
}
|
|
copy[key] = copyOptionValue(child, schema[key], `${trail}.${key}`);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function copyOptionValue(value, schema, trail) {
|
|
if (value == null) return value;
|
|
if (schema === BOOLEAN_OPTION) {
|
|
if (typeof value === "boolean" || value === "true" || value === "false") return value;
|
|
} else if (schema === INTEGER_OPTION) {
|
|
if (Number.isSafeInteger(value)) return value;
|
|
} else if (schema === STRING_OPTION) {
|
|
if (typeof value === "string" && !value.includes("\0")) {
|
|
assertNoCredentialMaterial(value, trail);
|
|
return value;
|
|
}
|
|
} else if (schema === STRING_ARRAY_OPTION) {
|
|
if (Array.isArray(value) && value.every((item) => typeof item === "string" && !item.includes("\0"))) {
|
|
for (const item of value) assertNoCredentialMaterial(item, trail);
|
|
return [...value];
|
|
}
|
|
} else if (schema?.object) {
|
|
return copyOptionObject(value, schema.object, trail);
|
|
} else if (schema?.enum) {
|
|
if (schema.enum.includes(value)) return value;
|
|
}
|
|
throw new ContextError("CONTEXT_BLOCKED", `Context.dev request option has a disallowed shape: ${trail}`);
|
|
}
|
|
|
|
function isPlainObject(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const prototype = Object.getPrototypeOf(value);
|
|
return prototype === Object.prototype || prototype === null;
|
|
}
|
|
|
|
function hasContextNetworkConsent(config) {
|
|
return config?.network?.selection === "context" &&
|
|
config?.network?.mode === "context" &&
|
|
config?.network?.consent === true &&
|
|
config?.context?.validation?.status !== "unverified";
|
|
}
|
|
|
|
export async function contextStatus(home, env = process.env) {
|
|
const config = await loadConfig(home);
|
|
let keySource = null;
|
|
try {
|
|
keySource = (await readContextKey({ home, env })).source;
|
|
} catch (error) {
|
|
if (error?.code !== "CONTEXT_KEY_MISSING") throw error;
|
|
}
|
|
const validation = config.context?.validation?.status ?? "unverified";
|
|
const contextReady = Boolean(keySource) && hasContextNetworkConsent(config) && validation === "verified";
|
|
return {
|
|
configured: Boolean(keySource),
|
|
keySource,
|
|
networkMode: config.network.mode,
|
|
selection: config.network.selection,
|
|
consent: config.network.consent === true,
|
|
validation,
|
|
contextReady,
|
|
ready: config.network.selection === "context"
|
|
? contextReady
|
|
: ["host", "local-browser", "none"].includes(config.network.selection),
|
|
needsChoice: config.network.selection == null,
|
|
};
|
|
}
|
|
|
|
function normalizeDomain(input) {
|
|
const raw = String(input ?? "").trim();
|
|
if (!raw) throw new ContextError("CONTEXT_BLOCKED", "A public domain is required");
|
|
const url = assertPublicUrl(raw.includes("://") ? raw : `https://${raw}`);
|
|
if (url.pathname !== "/" || url.search || url.hash || url.port) {
|
|
throw new ContextError("CONTEXT_BLOCKED", "Expected a bare public domain");
|
|
}
|
|
return url.hostname.replace(/^\[|\]$/g, "");
|
|
}
|
|
|
|
function decodeUrlFragment(fragment) {
|
|
try {
|
|
return decodeURIComponent(fragment);
|
|
} catch {
|
|
return fragment;
|
|
}
|
|
}
|
|
|
|
function validateBaseUrl(input) {
|
|
let url;
|
|
try {
|
|
url = new URL(String(input));
|
|
} catch (cause) {
|
|
throw ContextError.fromCause("CONTEXT_BAD_RESPONSE", "Invalid Context.dev API base URL", cause);
|
|
}
|
|
if (url.origin !== "https://api.context.dev" || url.username || url.password ||
|
|
!["/v1", "/v1/"].includes(url.pathname) || url.search || url.hash) {
|
|
throw new ContextError("CONTEXT_BAD_RESPONSE", "Refusing to send a Context.dev key to a non-official API host");
|
|
}
|
|
return OFFICIAL_BASE_URL;
|
|
}
|
|
|
|
export function redactSensitiveText(message, knownSecrets = []) {
|
|
let safe = String(message ?? "");
|
|
const exactSecrets = new Set();
|
|
for (const secret of knownSecrets) {
|
|
const raw = String(secret ?? "");
|
|
if (raw.length < 8) continue;
|
|
exactSecrets.add(raw);
|
|
if (hasWellFormedUtf16(raw)) exactSecrets.add(encodeURIComponent(raw));
|
|
}
|
|
for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]");
|
|
safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]");
|
|
safe = safe.replace(/(\b(?:Bearer|Basic)\s+)[A-Za-z0-9._~+/=-]{8,}/gi, "$1[REDACTED]");
|
|
safe = safe.replace(/((?:[?&#]|\b)(?:access_?token|api_?key|auth(?:orization)?|client_?secret|credential|id_?token|jwt|oauth_?token|password|refresh_?token|secret|session|signature|token)=)[^&#\s,}]+/gi, "$1[REDACTED]");
|
|
safe = safe.replace(/(\b(?:access[_-]?token|api[_-]?key|auth(?:orization)?|client[_-]?secret|credential|id[_-]?token|jwt|oauth[_-]?token|password|refresh[_-]?token|secret|session|signature|token)\s*[:=]\s*["']?)[^\s,"'&}]+/gi, "$1[REDACTED]");
|
|
safe = safe.replace(/[A-Za-z0-9._~+/=-]{32,}/g, (candidate) =>
|
|
looksOpaqueCredential(candidate) ? "[REDACTED]" : candidate);
|
|
return safe;
|
|
}
|
|
|
|
function redactSensitiveValue(value, knownSecrets = [], seen = new WeakSet()) {
|
|
if (typeof value === "string") return redactSensitiveText(value, knownSecrets);
|
|
if (!value || typeof value !== "object") return value;
|
|
if (seen.has(value)) return "[REDACTED CYCLE]";
|
|
seen.add(value);
|
|
if (Array.isArray(value)) return value.map((item) => redactSensitiveValue(item, knownSecrets, seen));
|
|
const copy = {};
|
|
for (const [key, child] of Object.entries(value)) {
|
|
copy[key] = isSensitiveFieldName(key)
|
|
? "[REDACTED]"
|
|
: redactSensitiveValue(child, knownSecrets, seen);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function sanitizeErrorCause(cause, knownSecrets = []) {
|
|
if (!cause) return undefined;
|
|
const safe = new Error(redactSensitiveText(cause?.message ?? String(cause), knownSecrets));
|
|
safe.name = String(cause?.name ?? "Error");
|
|
if (cause?.code != null) safe.code = cause.code;
|
|
return safe;
|
|
}
|
|
|
|
function assertNoCredentialMaterial(value, location) {
|
|
let candidate = String(value ?? "");
|
|
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
|
if (containsCredentialMaterial(candidate)) {
|
|
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${location} contains secret-shaped data`);
|
|
}
|
|
const decoded = decodeUrlComponent(candidate);
|
|
if (decoded === candidate) return;
|
|
candidate = decoded;
|
|
}
|
|
// A value that remains encoded after the inspection cap could hide a token
|
|
// at arbitrary depth. Fail closed rather than forwarding residual encoding.
|
|
throw new ContextError("CONTEXT_BLOCKED", `Context.dev ${location} uses excessive nested URL encoding`);
|
|
}
|
|
|
|
function containsCredentialMaterial(value) {
|
|
const text = String(value ?? "");
|
|
if (!text) return false;
|
|
if (PREFIXED_CREDENTIAL.test(text)) return true;
|
|
for (const candidate of text.match(OPAQUE_TOKEN_CANDIDATE) ?? []) {
|
|
if (looksOpaqueCredential(candidate)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function looksOpaqueCredential(candidate) {
|
|
const token = candidate.replace(/[.,;:!?]+$/, "");
|
|
if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || UUID.test(token) || isReadablePublicSlug(token)) return false;
|
|
const categories = [/[a-z]/, /[A-Z]/, /\d/, /[._~+/=-]/]
|
|
.reduce((count, pattern) => count + Number(pattern.test(token)), 0);
|
|
return categories >= 3 && shannonEntropy(token) >= MIN_OPAQUE_TOKEN_ENTROPY;
|
|
}
|
|
|
|
function isReadablePublicSlug(value) {
|
|
const parts = value.split("-");
|
|
if (parts.length < 3) return false;
|
|
let wordParts = 0;
|
|
for (const part of parts) {
|
|
if (/^[A-Za-z]{1,24}$/.test(part)) {
|
|
wordParts += 1;
|
|
continue;
|
|
}
|
|
if (/^\d{1,8}$/.test(part)) continue;
|
|
if (/^[A-Za-z]{2,20}\d{1,4}$/.test(part)) {
|
|
wordParts += 1;
|
|
continue;
|
|
}
|
|
return false;
|
|
}
|
|
return wordParts >= 2;
|
|
}
|
|
|
|
function shannonEntropy(value) {
|
|
const counts = new Map();
|
|
for (const character of value) counts.set(character, (counts.get(character) ?? 0) + 1);
|
|
let entropy = 0;
|
|
for (const count of counts.values()) {
|
|
const probability = count / value.length;
|
|
entropy -= probability * Math.log2(probability);
|
|
}
|
|
return entropy;
|
|
}
|
|
|
|
function isSensitiveFieldName(key) {
|
|
const normalized = String(key).replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
if (["authorization", "clientsecret", "code", "cookie", "credential", "idtoken", "jwt", "key", "password", "refreshtoken", "secret", "session", "setcookie", "signature", "token"].includes(normalized)) {
|
|
return true;
|
|
}
|
|
return /(?:access|api|auth|client|oauth|refresh|private|security|session|xamz|xgoog)(?:credential|key|password|secret|signature|token)$/.test(normalized) ||
|
|
/(?:credential|password|secret|signature|token)$/.test(normalized);
|
|
}
|
|
|
|
function hasWellFormedUtf16(value) {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const unit = value.charCodeAt(index);
|
|
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
const next = value.charCodeAt(index + 1);
|
|
if (next < 0xdc00 || next > 0xdfff) return false;
|
|
index += 1;
|
|
} else if (unit >= 0xdc00 && unit <= 0xdfff) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function containsCredentialLabel(value) {
|
|
let candidate = String(value ?? "");
|
|
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
|
const parts = candidate.split(/[\s/?#&;=:[\](){},]+/).filter(Boolean);
|
|
if (parts.some((part) => isSensitiveFieldName(part))) return true;
|
|
const decoded = decodeUrlComponent(candidate);
|
|
if (decoded === candidate) return false;
|
|
candidate = decoded;
|
|
}
|
|
// Residual nested encoding after the shared cap is ambiguous and may conceal
|
|
// a credential label. Match the value scanner's fail-closed behavior.
|
|
return true;
|
|
}
|
|
|
|
function raceWithAbort(promise, signal) {
|
|
if (signal.aborted) return Promise.reject(abortError());
|
|
return new Promise((resolve, reject) => {
|
|
const onAbort = () => reject(abortError());
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
Promise.resolve(promise).then(resolve, reject).finally(() => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function withTimeout(promise, timeoutMs) {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
// Keep the deadline referenced while the caller is awaiting `promise`.
|
|
// An unref'ed timer is only appropriate for optional background cleanup;
|
|
// this timer is the mechanism that guarantees the operation completes.
|
|
try {
|
|
return await raceWithAbort(promise, controller.signal);
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function abortError() {
|
|
const error = new Error("The operation was aborted");
|
|
error.name = "AbortError";
|
|
error.code = "ABORT_ERR";
|
|
return error;
|
|
}
|
|
|
|
function decodeUrlComponent(value) {
|
|
try {
|
|
return decodeURIComponent(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
function addQuery(searchParams, values, prefix = "") {
|
|
for (const [key, value] of Object.entries(values)) {
|
|
if (value == null) continue;
|
|
const name = prefix ? `${prefix}[${key}]` : key;
|
|
if (Array.isArray(value)) {
|
|
for (const item of value) searchParams.append(name, String(item));
|
|
} else if (typeof value === "object") {
|
|
addQuery(searchParams, value, name);
|
|
} else {
|
|
searchParams.append(name, String(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
function getHeader(headers, name) {
|
|
if (!headers) return undefined;
|
|
if (typeof headers.get === "function") return headers.get(name) ?? undefined;
|
|
return headers[name] ?? headers[name.toLowerCase()];
|
|
}
|
|
|
|
function isPublicIpv4(address) {
|
|
const parts = address.split(".").map(Number);
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
const value = (((parts[0] * 256 + parts[1]) * 256 + parts[2]) * 256 + parts[3]) >>> 0;
|
|
const blocked = [
|
|
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
|
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.0.2.0", 24],
|
|
["192.168.0.0", 16], ["198.18.0.0", 15], ["198.51.100.0", 24], ["203.0.113.0", 24],
|
|
["224.0.0.0", 4], ["240.0.0.0", 4],
|
|
];
|
|
return !blocked.some(([base, bits]) => inIpv4Cidr(value, ipv4Number(base), bits));
|
|
}
|
|
|
|
function ipv4Number(address) {
|
|
return address.split(".").map(Number).reduce((value, part) => value * 256 + part, 0) >>> 0;
|
|
}
|
|
|
|
function inIpv4Cidr(value, base, bits) {
|
|
const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0;
|
|
return (value & mask) === (base & mask);
|
|
}
|
|
|
|
function isPublicIpv6(address) {
|
|
// isPublicIp has already required Node's IPv6 parser to accept the address.
|
|
const value = ipv6BigInt(address);
|
|
if ((value >> 32n) === 0xffffn) {
|
|
const ipv4 = Number(value & 0xffffffffn);
|
|
return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`);
|
|
}
|
|
const ranges = [
|
|
["::", 128], ["::1", 128], ["64:ff9b:1::", 48], ["100::", 64], ["2001:2::", 48],
|
|
["2001:10::", 28], ["2001:db8::", 32], ["2002::", 16],
|
|
["fc00::", 7], ["fec0::", 10], ["fe80::", 10], ["ff00::", 8],
|
|
];
|
|
return !ranges.some(([base, bits]) => inIpv6Cidr(value, ipv6BigInt(base), bits));
|
|
}
|
|
|
|
function ipv6BigInt(address) {
|
|
let source = address.toLowerCase().split("%")[0];
|
|
if (source.includes(".")) {
|
|
const lastColon = source.lastIndexOf(":");
|
|
const ipv4 = source.slice(lastColon + 1).split(".").map(Number);
|
|
if (ipv4.length !== 4 || ipv4.some((part) => part < 0 || part > 255)) throw new Error("bad IPv6");
|
|
source = `${source.slice(0, lastColon)}:${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${((ipv4[2] << 8) | ipv4[3]).toString(16)}`;
|
|
}
|
|
const halves = source.split("::");
|
|
if (halves.length > 2) throw new Error("bad IPv6");
|
|
const left = halves[0] ? halves[0].split(":") : [];
|
|
const right = halves[1] ? halves[1].split(":") : [];
|
|
const missing = 8 - left.length - right.length;
|
|
if ((halves.length === 1 && missing !== 0) || missing < 0) throw new Error("bad IPv6");
|
|
const groups = [...left, ...Array(missing).fill("0"), ...right];
|
|
if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group))) throw new Error("bad IPv6");
|
|
return groups.reduce((total, group) => (total << 16n) + BigInt(`0x${group}`), 0n);
|
|
}
|
|
|
|
function inIpv6Cidr(value, base, bits) {
|
|
const shift = BigInt(128 - bits);
|
|
return (value >> shift) === (base >> shift);
|
|
}
|