Files
Jyotisha/frontend/src/modules/identity/config.ts
T
jesse-uxandGrok (agent loop) 32aa46b573 fix(release): close the first review of the production release path
Host rehearsal no longer needs Node on the production machine, and a failed cleanup now stops with the rehearsal database name. Docs-only staging heads no longer block rollback. Tests no longer hardcode Windows paths, and a trailing slash on the production site still refuses the test code.

pytest 12 passed; tsc exit 0; lint 0 errors; identity tests 9 passed, 0 cancelled. test:db was not run.

Co-Authored-By: Grok (agent loop)
2026-10-14 16:37:58 +08:00

144 lines
4.2 KiB
TypeScript

type IdentityEnvironment = Record<string, string | undefined>;
export interface SupabaseIdentityConfig {
provider: "supabase";
}
export interface SelfHostedIdentityConfig {
provider: "self-hosted";
databaseUrl: string;
userOrigin: string;
adminOrigin: string;
userSecret: string;
resendApiKey: string;
resendFrom: string;
/** Fixed OTP accepted when IDENTITY_TEST_OTP is set (test channel only). */
testOtp: string | null;
}
export type IdentityConfig =
| SupabaseIdentityConfig
| SelfHostedIdentityConfig;
export function isSelfHostedIdentityEnabled(
env: IdentityEnvironment,
): boolean {
const value = env.SELF_HOSTED_IDENTITY_ENABLED?.trim() || "false";
if (value !== "true" && value !== "false") {
throw new Error("SELF_HOSTED_IDENTITY_ENABLED must be true or false");
}
return value === "true";
}
function required(env: IdentityEnvironment, key: string): string {
const value = env[key]?.trim();
if (!value) throw new Error(`${key} is required`);
return value;
}
function readPostgresUrl(env: IdentityEnvironment): string {
const value = required(env, "IDENTITY_DATABASE_URL");
if (!value.startsWith("postgresql://")) {
throw new Error("IDENTITY_DATABASE_URL must be a PostgreSQL URL");
}
try {
const url = new URL(value);
if (!url.hostname || !url.pathname || url.pathname === "/") {
throw new Error("invalid PostgreSQL URL");
}
} catch {
throw new Error("IDENTITY_DATABASE_URL must be a PostgreSQL URL");
}
return value;
}
function readOrigin(env: IdentityEnvironment, key: string): string {
const value = required(env, key);
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${key} must be a valid origin`);
}
const isLocalhost =
url.hostname === "localhost" || url.hostname.endsWith(".localhost");
if (url.protocol !== "https:" && !(isLocalhost && url.protocol === "http:")) {
throw new Error(`${key} must use HTTPS outside localhost`);
}
if (url.pathname !== "/" || url.search || url.hash || url.username || url.password) {
throw new Error(`${key} must be an origin without a path`);
}
return url.origin;
}
function readSecret(env: IdentityEnvironment, key: string): string {
const value = required(env, key);
if (value.length < 32) {
throw new Error(`${key} must be at least 32 characters`);
}
return value;
}
function readSender(env: IdentityEnvironment): string {
const value = required(env, "RESEND_FROM_EMAIL");
const match = value.match(/(?:^|<)([^<>\s]+@[^<>\s]+)(?:>|$)/);
if (!match) {
throw new Error("RESEND_FROM_EMAIL must contain a valid email address");
}
return value;
}
function readTestOtp(env: IdentityEnvironment): string | null {
const value = env.IDENTITY_TEST_OTP?.trim() || "";
if (!value) return null;
if (!/^\d{6}$/.test(value)) {
throw new Error("IDENTITY_TEST_OTP must be a 6-digit code when set");
}
if (readOrigin(env, "AUTH_USER_ORIGIN") === "https://jyotisha.chat") {
throw new Error("IDENTITY_TEST_OTP is forbidden when AUTH_USER_ORIGIN is the production site");
}
return value;
}
export function readSelfHostedIdentityConfig(
env: IdentityEnvironment,
): SelfHostedIdentityConfig {
const userOrigin = readOrigin(env, "AUTH_USER_ORIGIN");
const adminOrigin = readOrigin(env, "ADMIN_USER_ORIGIN");
if (adminOrigin === userOrigin) {
throw new Error("ADMIN_USER_ORIGIN must differ from AUTH_USER_ORIGIN");
}
const userSecret = readSecret(env, "BETTER_AUTH_USER_SECRET");
return {
provider: "self-hosted",
databaseUrl: readPostgresUrl(env),
userOrigin,
adminOrigin,
userSecret,
resendApiKey: required(env, "RESEND_API_KEY"),
resendFrom: readSender(env),
testOtp: readTestOtp(env),
};
}
export function readIdentityConfig(
env: IdentityEnvironment,
): IdentityConfig {
const provider = env.AUTH_PROVIDER?.trim() || "supabase";
if (provider === "supabase") return { provider };
if (provider !== "self-hosted") {
throw new Error("AUTH_PROVIDER must be supabase or self-hosted");
}
if (!isSelfHostedIdentityEnabled(env)) {
throw new Error(
"SELF_HOSTED_IDENTITY_ENABLED must be true when AUTH_PROVIDER is self-hosted",
);
}
return readSelfHostedIdentityConfig(env);
}