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)
144 lines
4.2 KiB
TypeScript
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);
|
|
}
|