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,7 +39,21 @@ const trustedOrigins: (request?: Request) => Promise<string[]> = async (request)
return ['http://localhost:3000', 'http://localhost:3001'];
};
export const auth = betterAuth({
/**
* `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',
}),
@@ -96,6 +110,21 @@ export const auth = betterAuth({
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);
},
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;

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,