fix(build): make auth + storage modules side-effect-free at import
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m27s
Build & Push Docker Images / build-and-push (push) Failing after 14m25s

Two top-level eager initializers were breaking pnpm build during Next.js
"collect page data" phase under SKIP_ENV_VALIDATION=1:

- src/lib/auth/index.ts created the better-auth singleton at module load,
  triggering its "default secret" check against the unset BETTER_AUTH_SECRET.
- src/lib/minio/index.ts constructed `new Client({...})` at module load with
  env.MINIO_ENDPOINT === undefined, throwing InvalidEndpointError.

Storage config now lives in system_settings (read at runtime by
getStorageBackend()), so the legacy @/lib/minio module's MinIO-client
exports were already unused — only buildStoragePath had real consumers.
Stripped the module to that single pure helper; deleted the dead
minioClient / ensureBucket / getPresignedUrl exports.

For better-auth, kept the existing call-site syntax (`auth.api.foo(...)`
and `typeof auth.$Infer.Session`) by wrapping the singleton in a Proxy
that lazy-instantiates on first property access. Build-time import never
touches env; first runtime request constructs as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 15:38:04 +02:00
parent 42927482cd
commit 2f9bcf00b1
2 changed files with 88 additions and 94 deletions

View File

@@ -39,61 +39,90 @@ const trustedOrigins: (request?: Request) => Promise<string[]> = 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 = `
<p>Hi ${user.name || 'there'},</p>
<p>You requested a password reset for your Port Nimara CRM account.</p>
<p><a href="${url}">Click here to set a new password</a> — the link expires in 1 hour.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`;
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<typeof buildAuth>;
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);
},
});

View File

@@ -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<void> {
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<string> {
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,