1import { normalizeDeploymentBase } from "./base-path.ts";23export const clientEnvironmentNames = ["JAZZ_APP_ID", "JAZZ_SERVER_URL"] as const;4export const serverEnvironmentNames = ["JAZZ_ADMIN_SECRET", "BACKEND_SECRET"] as const;5export const deploymentEnvironmentNames = ["LOFI_BASE_PATH"] as const;6export const environmentNames = [7 ...clientEnvironmentNames,8 ...serverEnvironmentNames,9 ...deploymentEnvironmentNames,10] as const;1112interface EnvironmentResultBase {13 presentClientNames: string[];14 presentServerNames: string[];15 basePath: string;16 warnings: string[];17}1819export type EnvironmentValidation =20 & EnvironmentResultBase21 & (22 | {23 ok: true;24 mode: "local-only";25 client: Record<string, never>;26 }27 | {28 ok: true;29 mode: "cloud-configured";30 client: { JAZZ_APP_ID: string; JAZZ_SERVER_URL: string };31 }32 | {33 ok: false;34 mode: "invalid";35 errors: string[];36 }37 );3839function isPresent(env: Readonly<Record<string, string>>, name: string): boolean {40 return (env[name] ?? "").trim().length > 0;41}4243export function validateEnvironment(44 env: Readonly<Record<string, string>>,45): EnvironmentValidation {46 const presentClientNames = clientEnvironmentNames.filter((name) => isPresent(env, name));47 const presentServerNames = serverEnvironmentNames.filter((name) => isPresent(env, name));48 const errors: string[] = [];49 const warnings: string[] = [];50 let basePath = "/";5152 try {53 basePath = normalizeDeploymentBase(env.LOFI_BASE_PATH);54 } catch (error) {55 errors.push(error instanceof Error ? error.message : String(error));56 }5758 if (59 presentClientNames.length > 0 && presentClientNames.length !== clientEnvironmentNames.length60 ) {61 const missing = clientEnvironmentNames.filter((name) => !isPresent(env, name));62 errors.push(63 `Cloud sync cannot start because configuration is incomplete; set ${64 missing.join(", ")65 } or remove the partial JAZZ_APP_ID/JAZZ_SERVER_URL pair to run local-only.`,66 );67 }6869 if (isPresent(env, "JAZZ_SERVER_URL")) {70 try {71 const url = new URL(env.JAZZ_SERVER_URL);72 if (!new Set(["http:", "https:"]).has(url.protocol)) {73 errors.push(74 "Cloud sync cannot start because JAZZ_SERVER_URL must use http or https; correct the URL or remove the cloud configuration to run local-only.",75 );76 }77 } catch {78 errors.push(79 "Cloud sync cannot start because JAZZ_SERVER_URL is not a valid URL; correct it or remove the cloud configuration to run local-only.",80 );81 }82 }8384 const base: EnvironmentResultBase = {85 presentClientNames: [...presentClientNames],86 presentServerNames: [...presentServerNames],87 basePath,88 warnings,89 };90 if (errors.length > 0) return { ...base, ok: false, mode: "invalid", errors };91 if (presentClientNames.length === 0) {92 if (presentServerNames.length > 0) {93 warnings.push(94 "Server-only credentials are present without cloud client configuration; they remain isolated and unused by the client.",95 );96 }97 return { ...base, ok: true, mode: "local-only", client: {} };98 }99 return {100 ...base,101 ok: true,102 mode: "cloud-configured",103 client: {104 JAZZ_APP_ID: env.JAZZ_APP_ID.trim(),105 JAZZ_SERVER_URL: env.JAZZ_SERVER_URL.trim(),106 },107 };108}109110export function parseDotenv(source: string): Record<string, string> {111 const parsed: Record<string, string> = {};112 for (const originalLine of source.split(/\r?\n/)) {113 let line = originalLine.trim();114 if (line.length === 0 || line.startsWith("#")) continue;115 if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();116 const separator = line.indexOf("=");117 if (separator < 1) continue;118 const name = line.slice(0, separator).trim();119 if (!environmentNames.includes(name as (typeof environmentNames)[number])) continue;120 let value = line.slice(separator + 1).trim();121 if (122 value.length >= 2 &&123 ((value.startsWith('"') && value.endsWith('"')) ||124 (value.startsWith("'") && value.endsWith("'")))125 ) {126 value = value.slice(1, -1);127 }128 parsed[name] = value;129 }130 return parsed;131}132133export async function loadEnvironment(134 path = ".env",135 processLookup: (name: string) => string | undefined = (name) => Deno.env.get(name),136): Promise<Record<string, string>> {137 let fileEnvironment: Record<string, string> = {};138 try {139 fileEnvironment = parseDotenv(await Deno.readTextFile(path));140 } catch (error) {141 if (!(error instanceof Deno.errors.NotFound)) throw error;142 }143 return Object.fromEntries(144 environmentNames.map((name) => [name, processLookup(name) ?? fileEnvironment[name] ?? ""]),145 );146}147148const childEnvironmentAllowlist = [149 "CI",150 "DENO_DIR",151 "HOME",152 "LANG",153 "LC_ALL",154 "NIX_SSL_CERT_FILE",155 "NO_PROXY",156 "PATH",157 "PLAYWRIGHT_BROWSERS_PATH",158 "SSL_CERT_FILE",159 "TEMP",160 "TMP",161 "TMPDIR",162 // Windows process bootstrap. Children run with a cleared environment, and163 // without these Windows cannot load system DLLs, resolve executables, or164 // locate per-user state.165 "APPDATA",166 "COMSPEC",167 "HOMEDRIVE",168 "HOMEPATH",169 "LOCALAPPDATA",170 "PATHEXT",171 "PROGRAMDATA",172 "SYSTEMDRIVE",173 "SYSTEMROOT",174 "USERPROFILE",175 "WINDIR",176] as const;177178const childEnvironmentAllowedUpper = new Set<string>(179 childEnvironmentAllowlist.map((name) => name.toUpperCase()),180);181182export function childEnvironment(183 validation: Exclude<EnvironmentValidation, { ok: false }>,184 source: Record<string, string> = Deno.env.toObject(),185): Record<string, string> {186 const environment: Record<string, string> = {};187 // Windows environment names are case-insensitive and arrive with arbitrary188 // casing ("Path", "SystemRoot"); match case-insensitively and preserve the189 // source casing so the child sees the variables the platform expects.190 for (const [name, value] of Object.entries(source)) {191 if (value && childEnvironmentAllowedUpper.has(name.toUpperCase())) {192 environment[name] = value;193 }194 }195 if (validation.mode === "cloud-configured") {196 environment.JAZZ_APP_ID = validation.client.JAZZ_APP_ID;197 environment.JAZZ_SERVER_URL = validation.client.JAZZ_SERVER_URL;198 } else {199 environment.JAZZ_APP_ID = "";200 environment.JAZZ_SERVER_URL = "";201 }202 environment.LOFI_BASE_PATH = validation.basePath;203 environment.JAZZ_ADMIN_SECRET = "";204 environment.BACKEND_SECRET = "";205 return environment;206}