// Server data-backend resolution for the vendored Hasna storage kit. // // A Hasna server has one authoritative backend: PostgreSQL. A configured, // valid DATABASE_URL is required. Missing, blank, invalid, or conflicting // declarations fail closed and never select SQLite. Retired mode variables are // inert and never select anything. import { ownString } from "./own.js"; export const SERVER_DATA_BACKENDS = ["postgresql"] as const; export type ServerDataBackend = (typeof SERVER_DATA_BACKENDS)[number]; export type Env = Record; /** Upper-snake env token for an app name, e.g. `todos` -> `TODOS`. */ export function envToken(name: string): string { return name.toUpperCase().replace(/-/g, "_"); } export interface ServerDataBackendEnvKeys { databaseUrlKeys: string[]; } export function serverDataBackendEnvKeys(name: string): ServerDataBackendEnvKeys { const token = envToken(name); return { databaseUrlKeys: [`HASNA_${token}_DATABASE_URL`, `${token}_DATABASE_URL`], }; } // The resolver reads the env as OWN properties. `env` is caller-supplied and // `process.env` is itself prototype-pollutable, so an unguarded `env[key]` let a // polluted `HASNA__DATABASE_URL` flip the backend to postgresql and hand // back a connection string the operator never configured. function definedDatabaseUrlEntries(env: Env, keys: readonly string[]): Array<{ key: string; value: string }> { return keys .map((key) => ({ key, value: ownString(env, key) })) .filter((entry): entry is { key: string; value: string } => entry.value !== undefined); } function assertPostgresqlDatabaseUrl( name: string, entries: Array<{ key: string; value: string }>, ): { key: string; value: string } { const canonicalKey = serverDataBackendEnvKeys(name).databaseUrlKeys[0]; if (entries.length === 0) { throw new Error(`${canonicalKey} is required; servers use PostgreSQL and never default to SQLite.`); } const blank = entries.filter((entry) => entry.value.trim().length === 0); if (blank.length > 0) { throw new Error(`${blank.map((entry) => entry.key).join(" and ")} is set but blank; a PostgreSQL database URL is required.`); } const controlled = entries.find((entry) => /[\u0000-\u001f\u007f]/.test(entry.value)); if (controlled) throw new Error(`${controlled.key} must not contain ASCII control characters.`); const normalized = entries.map((entry) => ({ key: entry.key, value: entry.value.trim() })); if (normalized.length > 1 && new Set(normalized.map((entry) => entry.value)).size > 1) { throw new Error(`${normalized.map((entry) => entry.key).join(" and ")} disagree; database URL aliases must be identical or only one may be set.`); } const selected = normalized[0]!; let parsed: URL; try { parsed = new URL(selected.value); } catch { throw new Error(`${selected.key} must be an absolute PostgreSQL connection URL.`); } if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") { throw new Error(`${selected.key} must use the postgres or postgresql scheme.`); } if (!parsed.hostname || parsed.pathname.length <= 1) { throw new Error(`${selected.key} must name a PostgreSQL host and database.`); } return selected; } export interface ServerDataBackendResolution { backend: ServerDataBackend; source: string; databaseUrlPresent: boolean; databaseUrlSource: string; } export function resolveServerDataBackend( name: string, env: Env = process.env, ): ServerDataBackendResolution { const keys = serverDataBackendEnvKeys(name).databaseUrlKeys; const databaseUrl = assertPostgresqlDatabaseUrl(name, definedDatabaseUrlEntries(env, keys)); return { backend: "postgresql", source: databaseUrl.key, databaseUrlPresent: true, databaseUrlSource: databaseUrl.key, }; } /** Resolve the required PostgreSQL URL without logging it. */ export function resolveDatabaseUrl(name: string, env: Env = process.env): string { const keys = serverDataBackendEnvKeys(name).databaseUrlKeys; return assertPostgresqlDatabaseUrl(name, definedDatabaseUrlEntries(env, keys)).value; }