import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; /** * The managed control-plane link recorded in `.openai/hosting.json`. * Contains only the managed project id and logical D1/R2 bindings — never * secrets, env values, or customer data. */ export interface HostingConfig { d1?: string | null; projectId?: string; r2?: string | null; } const ALLOWED_KEYS = ["project_id", "d1", "r2"] as const; /** Conservative secret markers; hosting.json must never contain values like these. */ const SECRET_MARKERS = [ "sk-", "AKIA", "-----BEGIN", "ghp_", "password=", "token=", ]; /** True when `/.openai/hosting.json` exists. */ export function isSitesProject(dir: string): boolean { return existsSync(join(dir, ".openai", "hosting.json")); } /** * Read and validate `/.openai/hosting.json`. Returns null when the file * is missing, unparseable, or fails schema validation. */ export function readHostingConfig(dir: string): HostingConfig | null { try { const text = readFileSync(join(dir, ".openai", "hosting.json"), "utf8"); const raw: unknown = JSON.parse(text); const result = validateHostingConfig(raw); return result.ok ? result.value : null; } catch { return null; } } /** * Validate a parsed hosting.json value. * * - must be a JSON object; * - allowed keys are exactly `project_id`, `d1`, `r2`; * - `project_id` is a non-empty string when present; `d1`/`r2` are strings or * null when present; * - secret-like values are rejected with an error naming the key but never * echoing the value. */ export function validateHostingConfig( raw: unknown ): { ok: true; value: HostingConfig } | { ok: false; errors: string[] } { if (!isRecord(raw)) { return { errors: ["hosting.json must be a JSON object"], ok: false }; } const errors: string[] = []; for (const key of Object.keys(raw)) { if (!ALLOWED_KEYS.includes(key as (typeof ALLOWED_KEYS)[number])) { errors.push( `unknown key "${key}" in hosting.json (allowed keys: project_id, d1, r2)` ); } } const value: HostingConfig = {}; applyProjectId(raw, errors, value); applyNullableBinding(raw, errors, value, "d1"); applyNullableBinding(raw, errors, value, "r2"); if (errors.length > 0) { return { errors, ok: false }; } return { ok: true, value }; } function applyProjectId( raw: Record, errors: string[], value: HostingConfig ): void { if (!("project_id" in raw)) { return; } const rawValue = raw.project_id; if ( rawValue === null || typeof rawValue !== "string" || rawValue.trim() === "" ) { errors.push("project_id must be a non-empty string when present"); return; } if (hasSecretMarker(rawValue)) { errors.push( "project_id must not contain secret-like values (hosting.json holds logical bindings only, never secrets)" ); return; } value.projectId = rawValue; } function applyNullableBinding( raw: Record, errors: string[], value: HostingConfig, key: "d1" | "r2" ): void { if (!(key in raw)) { return; } const rawValue = raw[key]; if (rawValue === null) { if (key === "d1") { value.d1 = null; } else { value.r2 = null; } return; } if (typeof rawValue !== "string") { errors.push(`${key} must be a string or null when present`); return; } if (hasSecretMarker(rawValue)) { errors.push( `${key} must not contain secret-like values (hosting.json holds logical bindings only, never secrets)` ); return; } if (key === "d1") { value.d1 = rawValue; } else { value.r2 = rawValue; } } function hasSecretMarker(value: string): boolean { return SECRET_MARKERS.some((marker) => value.includes(marker)); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); }