diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 27b51bf3..bbd7cd22 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", + "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", @@ -580,6 +581,33 @@ } } }, + "node_modules/@better-auth/utils": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@better-auth/utils/-/utils-0.4.2.tgz", + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.0.1" + } + }, + "node_modules/@better-auth/utils/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@better-fetch/fetch": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@better-fetch/fetch/-/fetch-1.3.1.tgz", + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==", + "license": "MIT" + }, "node_modules/@date-fns/tz": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.5.0.tgz", @@ -2305,6 +2333,15 @@ "node": ">=12.4.0" } }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@posthog/core": { "version": "1.40.2", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.40.2.tgz", @@ -4084,6 +4121,282 @@ "node": ">=6.0.0" } }, + "node_modules/better-auth": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/better-auth/-/better-auth-1.6.23.tgz", + "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", + "license": "MIT", + "dependencies": { + "@better-auth/core": "1.6.23", + "@better-auth/drizzle-adapter": "1.6.23", + "@better-auth/kysely-adapter": "1.6.23", + "@better-auth/memory-adapter": "1.6.23", + "@better-auth/mongo-adapter": "1.6.23", + "@better-auth/prisma-adapter": "1.6.23", + "@better-auth/telemetry": "1.6.23", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@noble/ciphers": "^2.1.1", + "@noble/hashes": "^2.0.1", + "better-call": "1.3.7", + "defu": "^6.1.4", + "jose": "^6.1.3", + "kysely": "^0.28.17 || ^0.29.0", + "nanostores": "^1.1.1", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@lynx-js/react": "*", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "@sveltejs/kit": "^2.0.0", + "@tanstack/react-start": "^1.0.0", + "@tanstack/solid-start": "^1.0.0", + "better-sqlite3": "^12.0.0", + "drizzle-kit": ">=0.31.4", + "drizzle-orm": "^0.45.2", + "mongodb": "^6.0.0 || ^7.0.0", + "mysql2": "^3.0.0", + "next": "^14.0.0 || ^15.0.0 || ^16.0.0", + "pg": "^8.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "solid-js": "^1.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", + "vue": "^3.0.0" + }, + "peerDependenciesMeta": { + "@lynx-js/react": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "@tanstack/react-start": { + "optional": true + }, + "@tanstack/solid-start": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "drizzle-kit": { + "optional": true + }, + "drizzle-orm": { + "optional": true + }, + "mongodb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "next": { + "optional": true + }, + "pg": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vitest": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/core": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/core/-/core-1.6.23.tgz", + "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", + "license": "MIT", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.39.0", + "@standard-schema/spec": "^1.1.0", + "zod": "^4.3.6" + }, + "peerDependencies": { + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1", + "@cloudflare/workers-types": ">=4", + "@opentelemetry/api": "^1.9.0", + "better-call": "1.3.7", + "jose": "^6.1.0", + "kysely": "^0.28.5 || ^0.29.0", + "nanostores": "^1.0.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/drizzle-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/drizzle-adapter/-/drizzle-adapter-1.6.23.tgz", + "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "drizzle-orm": "^0.45.2" + }, + "peerDependenciesMeta": { + "drizzle-orm": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/kysely-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/kysely-adapter/-/kysely-adapter-1.6.23.tgz", + "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "kysely": "^0.28.17 || ^0.29.0" + }, + "peerDependenciesMeta": { + "kysely": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/memory-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/memory-adapter/-/memory-adapter-1.6.23.tgz", + "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2" + } + }, + "node_modules/better-auth/node_modules/@better-auth/mongo-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/mongo-adapter/-/mongo-adapter-1.6.23.tgz", + "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "mongodb": "^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "mongodb": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/prisma-adapter": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.23.tgz", + "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", + "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@prisma/client": { + "optional": true + }, + "prisma": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/@better-auth/telemetry": { + "version": "1.6.23", + "resolved": "https://registry.npmjs.org/@better-auth/telemetry/-/telemetry-1.6.23.tgz", + "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", + "license": "MIT", + "peerDependencies": { + "@better-auth/core": "^1.6.23", + "@better-auth/utils": "0.4.2", + "@better-fetch/fetch": "1.3.1" + } + }, + "node_modules/better-auth/node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/better-auth/node_modules/better-call": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/better-call/-/better-call-1.3.7.tgz", + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "license": "MIT", + "dependencies": { + "@better-auth/utils": "^0.4.0", + "@better-fetch/fetch": "^1.1.21", + "rou3": "^0.7.12", + "set-cookie-parser": "^3.0.1" + }, + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/better-auth/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4680,6 +4993,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7274,6 +7593,15 @@ "node": ">=0.10.0" } }, + "node_modules/kysely": { + "version": "0.29.4", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz", + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", @@ -8624,6 +8952,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.4.1.tgz", + "integrity": "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -9713,6 +10056,12 @@ "node": ">=0.10.0" } }, + "node_modules/rou3": { + "version": "0.7.12", + "resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz", + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -9894,6 +10243,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/set-cookie-parser": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index fd4f53ed..8fed1bba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", + "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", diff --git a/frontend/src/modules/identity/config.ts b/frontend/src/modules/identity/config.ts new file mode 100644 index 00000000..303de958 --- /dev/null +++ b/frontend/src/modules/identity/config.ts @@ -0,0 +1,115 @@ +type IdentityEnvironment = Record; + +export interface SupabaseIdentityConfig { + provider: "supabase"; +} + +export interface SelfHostedIdentityConfig { + provider: "self-hosted"; + databaseUrl: string; + userOrigin: string; + adminOrigin: string; + userSecret: string; + adminSecret: string; + resendApiKey: string; + resendFrom: string; +} + +export type IdentityConfig = + | SupabaseIdentityConfig + | SelfHostedIdentityConfig; + +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; +} + +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"); + } + + const userOrigin = readOrigin(env, "AUTH_USER_ORIGIN"); + const adminOrigin = readOrigin(env, "AUTH_ADMIN_ORIGIN"); + if (userOrigin === adminOrigin) { + throw new Error("user and admin origins must be different"); + } + + const userSecret = readSecret(env, "BETTER_AUTH_USER_SECRET"); + const adminSecret = readSecret(env, "BETTER_AUTH_ADMIN_SECRET"); + if (userSecret === adminSecret) { + throw new Error("user and admin secrets must be different"); + } + + return { + provider, + databaseUrl: readPostgresUrl(env), + userOrigin, + adminOrigin, + userSecret, + adminSecret, + resendApiKey: required(env, "RESEND_API_KEY"), + resendFrom: readSender(env), + }; +} diff --git a/frontend/src/modules/identity/contracts.ts b/frontend/src/modules/identity/contracts.ts new file mode 100644 index 00000000..b6e7d9e6 --- /dev/null +++ b/frontend/src/modules/identity/contracts.ts @@ -0,0 +1,31 @@ +export type IdentitySurface = "user" | "admin"; + +export type EmailOtpType = + | "sign-in" + | "email-verification" + | "forget-password"; + +export interface EmailOtpMessage { + email: string; + otp: string; + type: EmailOtpType; + idempotencyKey: string; +} + +export interface EmailOtpSender { + send(message: EmailOtpMessage): Promise; +} + +export interface IdentityUser { + id: string; + email: string; + emailVerified: boolean; + name: string; + image: string | null; + role: string[]; +} + +export interface IdentitySession { + user: IdentityUser; + expiresAt: Date; +} diff --git a/frontend/tests/identity-config.test.ts b/frontend/tests/identity-config.test.ts new file mode 100644 index 00000000..9a7585f8 --- /dev/null +++ b/frontend/tests/identity-config.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readIdentityConfig } from "../src/modules/identity/config.ts"; + +const selfHostedEnvironment = { + AUTH_PROVIDER: "self-hosted", + IDENTITY_DATABASE_URL: + "postgresql://identity_runtime:test-password@postgres:5432/jyotisha?options=-csearch_path%3Didentity", + AUTH_USER_ORIGIN: "https://staging.jyotisha.chat", + AUTH_ADMIN_ORIGIN: "https://admin.staging.jyotisha.chat", + BETTER_AUTH_USER_SECRET: "user-secret-that-is-at-least-32-bytes-long", + BETTER_AUTH_ADMIN_SECRET: "admin-secret-that-is-at-least-32-bytes-long", + RESEND_API_KEY: "re_test_key_that_must_not_be_printed", + RESEND_FROM_EMAIL: "Jyotisha Staging ", +}; + +test("identity provider defaults to supabase without self-hosted settings", () => { + assert.deepEqual(readIdentityConfig({}), { provider: "supabase" }); +}); + +test("identity config accepts a complete self-hosted environment", () => { + const config = readIdentityConfig(selfHostedEnvironment); + + assert.equal(config.provider, "self-hosted"); + if (config.provider !== "self-hosted") { + assert.fail("expected self-hosted identity configuration"); + } + assert.equal(config.userOrigin, "https://staging.jyotisha.chat"); + assert.equal(config.adminOrigin, "https://admin.staging.jyotisha.chat"); + assert.equal(config.resendFrom, selfHostedEnvironment.RESEND_FROM_EMAIL); +}); + +test("identity config rejects unknown providers", () => { + assert.throws( + () => readIdentityConfig({ AUTH_PROVIDER: "firebase" }), + /AUTH_PROVIDER must be supabase or self-hosted/, + ); +}); + +test("self-hosted identity reports missing keys without leaking configured secrets", () => { + const secret = "this-secret-must-never-appear-in-an-error"; + + assert.throws( + () => + readIdentityConfig({ + ...selfHostedEnvironment, + BETTER_AUTH_USER_SECRET: secret, + RESEND_API_KEY: "", + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /RESEND_API_KEY is required/); + assert.doesNotMatch(error.message, new RegExp(secret)); + return true; + }, + ); +}); + +test("self-hosted identity validates database URL, origins, secrets, and sender", () => { + const invalidCases: Array<[string, Record, RegExp]> = [ + [ + "database URL", + { IDENTITY_DATABASE_URL: "https://database.invalid" }, + /IDENTITY_DATABASE_URL must be a PostgreSQL URL/, + ], + [ + "production HTTP origin", + { AUTH_USER_ORIGIN: "http://staging.jyotisha.chat" }, + /AUTH_USER_ORIGIN must use HTTPS/, + ], + [ + "origin path", + { AUTH_ADMIN_ORIGIN: "https://admin.staging.jyotisha.chat/login" }, + /AUTH_ADMIN_ORIGIN must be an origin without a path/, + ], + [ + "short secret", + { BETTER_AUTH_ADMIN_SECRET: "too-short" }, + /BETTER_AUTH_ADMIN_SECRET must be at least 32 characters/, + ], + [ + "shared secret", + { + BETTER_AUTH_ADMIN_SECRET: + selfHostedEnvironment.BETTER_AUTH_USER_SECRET, + }, + /user and admin secrets must be different/, + ], + [ + "shared origin", + { AUTH_ADMIN_ORIGIN: selfHostedEnvironment.AUTH_USER_ORIGIN }, + /user and admin origins must be different/, + ], + [ + "invalid sender", + { RESEND_FROM_EMAIL: "Jyotisha Staging" }, + /RESEND_FROM_EMAIL must contain a valid email address/, + ], + ]; + + for (const [name, override, expected] of invalidCases) { + assert.throws( + () => readIdentityConfig({ ...selfHostedEnvironment, ...override }), + expected, + name, + ); + } +}); + +test("localhost origins may use HTTP for local development", () => { + const config = readIdentityConfig({ + ...selfHostedEnvironment, + AUTH_USER_ORIGIN: "http://localhost:3000", + AUTH_ADMIN_ORIGIN: "http://admin.localhost:3000", + }); + + assert.equal(config.provider, "self-hosted"); +});