diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts index edcc7de..45c4a63 100644 --- a/src/lib/auth/index.ts +++ b/src/lib/auth/index.ts @@ -39,61 +39,90 @@ const trustedOrigins: (request?: Request) => Promise = async (request) return ['http://localhost:3000', 'http://localhost:3001']; }; -export const auth = betterAuth({ - database: drizzleAdapter(db, { - provider: 'pg', - }), +/** + * `betterAuth(...)` is wrapped in a lazy initializer so the auth singleton + * is constructed on first property access (i.e. first request) rather than + * at module import. This is required so that Next.js's "collect page data" + * phase during `pnpm build` doesn't trigger better-auth's "default secret" + * check against the unset BETTER_AUTH_SECRET — at build time the auth + * config is never accessed, and at runtime the env is fully populated. + * + * Call sites continue to use `auth.api.foo(...)` unchanged; the Proxy + * intercepts the property access and resolves the real instance just-in- + * time. `typeof auth.$Infer.Session` is a type-only access and never + * triggers the Proxy at runtime. + */ +function buildAuth() { + return betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + }), - trustedOrigins, + trustedOrigins, - emailAndPassword: { - enabled: true, - minPasswordLength: 9, - // Accounts are admin-created only - no self-service email verification flow. - requireEmailVerification: false, - // Self-service password reset for CRM users. The reset link lands - // on the existing /reset-password page (which already handles - // better-auth's token + new-password POST). The email send goes - // through the shared SMTP infra so EMAIL_REDIRECT_TO honours it - // in dev. - sendResetPassword: async ({ user, url }) => { - const { sendEmail } = await import('@/lib/email'); - const subject = 'Reset your Port Nimara CRM password'; - const html = ` + emailAndPassword: { + enabled: true, + minPasswordLength: 9, + // Accounts are admin-created only - no self-service email verification flow. + requireEmailVerification: false, + // Self-service password reset for CRM users. The reset link lands + // on the existing /reset-password page (which already handles + // better-auth's token + new-password POST). The email send goes + // through the shared SMTP infra so EMAIL_REDIRECT_TO honours it + // in dev. + sendResetPassword: async ({ user, url }) => { + const { sendEmail } = await import('@/lib/email'); + const subject = 'Reset your Port Nimara CRM password'; + const html = `

Hi ${user.name || 'there'},

You requested a password reset for your Port Nimara CRM account.

Click here to set a new password — the link expires in 1 hour.

If you didn't request this, you can safely ignore this email.

`; - const text = `Reset your password: ${url}`; - await sendEmail(user.email, subject, html, undefined, text); + const text = `Reset your password: ${url}`; + await sendEmail(user.email, subject, html, undefined, text); + }, }, - }, - session: { - // Enable cookie-level session caching to reduce DB reads (5-minute cache). - cookieCache: { - enabled: true, - maxAge: 5 * 60, + session: { + // Enable cookie-level session caching to reduce DB reads (5-minute cache). + cookieCache: { + enabled: true, + maxAge: 5 * 60, + }, + // Absolute session lifetime: 24 hours. + expiresIn: 60 * 60 * 24, + // Refresh the session whenever the user is active in the last 25% of its lifetime (6h). + updateAge: 60 * 60 * 6, }, - // Absolute session lifetime: 24 hours. - expiresIn: 60 * 60 * 24, - // Refresh the session whenever the user is active in the last 25% of its lifetime (6h). - updateAge: 60 * 60 * 6, - }, - advanced: { - cookiePrefix: 'pn-crm', - defaultCookieAttributes: { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict' as const, + advanced: { + cookiePrefix: 'pn-crm', + defaultCookieAttributes: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict' as const, + }, }, - }, - logger: { - disabled: false, - level: 'error' as const, + logger: { + disabled: false, + level: 'error' as const, + }, + }); +} + +type AuthInstance = ReturnType; + +let _authInstance: AuthInstance | null = null; +function getAuth(): AuthInstance { + if (!_authInstance) _authInstance = buildAuth(); + return _authInstance; +} + +export const auth = new Proxy({} as AuthInstance, { + get(_target, prop) { + return Reflect.get(getAuth(), prop); }, }); diff --git a/src/lib/minio/index.ts b/src/lib/minio/index.ts index 86ac465..86527f3 100644 --- a/src/lib/minio/index.ts +++ b/src/lib/minio/index.ts @@ -1,65 +1,30 @@ -import { Client } from 'minio'; - -import { env } from '@/lib/env'; -import { logger } from '@/lib/logger'; - -export const minioClient = new Client({ - endPoint: env.MINIO_ENDPOINT, - port: env.MINIO_PORT, - useSSL: env.MINIO_USE_SSL, - accessKey: env.MINIO_ACCESS_KEY, - secretKey: env.MINIO_SECRET_KEY, -}); - -const BUCKET = env.MINIO_BUCKET; - /** - * Ensures the configured bucket exists, creating it if not. + * Storage path helper. * - * Gated by MINIO_AUTO_CREATE_BUCKET=true so a misconfigured prod - * deploy can't accidentally mint a fresh empty bucket and start - * writing into it (silently losing access to the intended one). - * The pluggable S3 backend in `src/lib/storage/s3.ts` already - * applies the same gate; this legacy export keeps the contract - * consistent for any caller still importing from `@/lib/minio`. - */ -export async function ensureBucket(): Promise { - try { - const exists = await minioClient.bucketExists(BUCKET); - if (!exists) { - if (process.env.MINIO_AUTO_CREATE_BUCKET !== 'true') { - throw new Error( - `MinIO bucket '${BUCKET}' does not exist. Create it manually or set ` + - `MINIO_AUTO_CREATE_BUCKET=true.`, - ); - } - await minioClient.makeBucket(BUCKET); - logger.info({ bucket: BUCKET }, 'MinIO bucket auto-created (MINIO_AUTO_CREATE_BUCKET=true)'); - } else { - logger.debug({ bucket: BUCKET }, 'MinIO bucket exists'); - } - } catch (err) { - logger.error({ err, bucket: BUCKET }, 'Failed to ensure MinIO bucket'); - throw err; - } -} - -/** - * Generates a pre-signed GET URL for an object. + * Historically this module also exported a top-level `minioClient`, + * `ensureBucket()`, and `getPresignedUrl()` that read MINIO_* env vars + * eagerly at import-time. That broke the Next.js production build + * (the route-data collection phase imports route modules transitively, + * and modules touching `env.MINIO_*` blew up under SKIP_ENV_VALIDATION + * with `InvalidEndpointError: Invalid endPoint : undefined`). * - * Default expiry is 15 minutes (900 seconds) per SECURITY-GUIDELINES.md §7.1. + * Storage config now lives in `system_settings` rows and is read at + * runtime by the pluggable backend in `@/lib/storage` (see + * `getStorageBackend()` and `presignDownloadUrl()`). Build-time env + * vars are no longer the source of truth. + * + * The only piece worth keeping in the legacy module path was this pure + * `buildStoragePath` helper, which doesn't touch env at all and is + * imported by several services. Everything else has been deleted. */ -export async function getPresignedUrl(objectKey: string, expirySeconds = 900): Promise { - return minioClient.presignedGetObject(BUCKET, objectKey, expirySeconds); -} /** * Constructs a storage path from typed components. * * Format: `{portSlug}/{entity}/{entityId}/{fileId}.{extension}` * - * No user-supplied input should ever be used as path components - only UUIDs - * and controlled slugs (SECURITY-GUIDELINES.md §3.4, §7.1). + * No user-supplied input should ever be used as path components — only + * UUIDs and controlled slugs (SECURITY-GUIDELINES.md §3.4, §7.1). */ export function buildStoragePath( portSlug: string,