refactor: streamline env and directus logic using @mintel/next-utils and fix network isolation
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🧪 QA (push) Failing after 1m25s
Build & Deploy / 🏗️ Build (push) Failing after 2m36s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s

This commit is contained in:
2026-02-10 23:41:32 +01:00
parent 1fefb794c1
commit 9bcf946752
3 changed files with 46 additions and 167 deletions

View File

@@ -83,8 +83,10 @@ services:
image: directus/directus:11
restart: always
networks:
- default
- infra
default:
infra:
aliases:
- ${PROJECT_NAME:-klz-cables}-directus
env_file:
- ${ENV_FILE:-.env}
environment:

View File

@@ -1,18 +1,12 @@
import {
createDirectus,
rest,
authentication,
staticToken,
readItems,
readCollections,
} from '@directus/sdk';
import { readItems, readCollections } from '@directus/sdk';
import { createMintelDirectusClient, ensureDirectusAuthenticated } from '@mintel/next-utils';
import { config } from './config';
import { getServerAppServices } from './services/create-services.server';
const { url, adminEmail, password, token, proxyPath, internalUrl } = config.directus;
const { url, proxyPath, internalUrl } = config.directus;
// Use internal URL if on server to bypass Gatekeeper/Auth
// Use proxy path in browser to stay on the same origin
// Use proxy path in browser to stay on the same origin (CORS safe)
const effectiveUrl =
typeof window === 'undefined'
? internalUrl || url
@@ -20,8 +14,8 @@ const effectiveUrl =
? `${window.location.origin}${proxyPath}`
: proxyPath;
// Initialize client with authentication plugin
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
// Initialize client using Mintel standards
const client = createMintelDirectusClient(effectiveUrl);
/**
* Helper to determine if we should show detailed errors
@@ -38,49 +32,15 @@ function formatError(error: any) {
return 'A system error occurred. Our team has been notified.';
}
let authPromise: Promise<void> | null = null;
export async function ensureAuthenticated() {
if (token) {
client.setToken(token);
return;
}
// Check if we already have a valid session token in memory (for login flow)
const existingToken = await client.getToken();
if (existingToken) {
return;
}
if (adminEmail && password) {
if (authPromise) {
return authPromise;
try {
await ensureDirectusAuthenticated(client);
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
authPromise = (async () => {
try {
// Reset current token to ensure fresh login
client.setToken(null as any);
await client.login(adminEmail, password);
console.log(`✅ Directus: Authenticated successfully as ${adminEmail}`);
} catch (e: any) {
if (typeof window === 'undefined') {
getServerAppServices().errors.captureException(e, { part: 'directus_auth' });
}
console.error(`Failed to authenticate with Directus (${adminEmail}):`, e.message);
if (shouldShowDevErrors && e.errors) {
console.error('Directus Auth Details:', JSON.stringify(e.errors, null, 2));
}
// Clear the promise on failure (especially on invalid credentials)
// so we can retry on next request if credentials were updated
authPromise = null;
throw e;
}
})();
return authPromise;
} else if (shouldShowDevErrors && !adminEmail && !password && !token) {
console.warn('Directus: No token or admin credentials provided.');
console.error(`Failed to authenticate with Directus:`, e.message);
throw e;
}
}

View File

@@ -1,124 +1,41 @@
import { z } from 'zod';
/**
* Helper to treat empty strings as undefined.
*/
const preprocessEmptyString = (val: unknown) => (val === '' ? undefined : val);
import { validateMintelEnv, mintelEnvSchema } from '@mintel/next-utils';
/**
* Environment variable schema.
* Extends the default Mintel environment schema.
*/
export const envSchema = z
.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
NEXT_PUBLIC_BASE_URL: z.preprocess(preprocessEmptyString, z.string().url()),
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
export const envSchema = z.object({
...mintelEnvSchema,
// Project specific variables
NEXT_PUBLIC_TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
// Analytics
UMAMI_WEBSITE_ID: z.preprocess(preprocessEmptyString, z.string().optional()),
UMAMI_API_ENDPOINT: z.preprocess(
preprocessEmptyString,
z.string().url().default('https://analytics.infra.mintel.me'),
),
// Directus (Extended from base)
INTERNAL_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_URL: z.string().url().optional(),
INFRA_DIRECTUS_TOKEN: z.string().optional(),
// Error Tracking
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Logging
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Mail
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PORT: z.preprocess(preprocessEmptyString, z.coerce.number().default(587)),
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
MAIL_RECIPIENTS: z.preprocess(
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
z.array(z.string()).default([]),
),
// Directus
DIRECTUS_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('http://localhost:8055'),
),
DIRECTUS_ADMIN_EMAIL: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_ADMIN_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
DIRECTUS_API_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
INTERNAL_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
// Deploy Target
TARGET: z.enum(['development', 'testing', 'staging', 'production']).optional(),
// Gotify
GOTIFY_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
// Gatekeeper
GATEKEEPER_URL: z.preprocess(
preprocessEmptyString,
z.string().url().default('http://gatekeeper:3000'),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false)
),
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false)
),
INFRA_DIRECTUS_URL: z.preprocess(preprocessEmptyString, z.string().url().optional()),
INFRA_DIRECTUS_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
})
.superRefine((data, ctx) => {
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
const isDev = target === 'development' || !target;
const isBuildTimeValidation = process.env.SKIP_RUNTIME_ENV_VALIDATION === 'true';
const isServer = typeof window === 'undefined';
// Only enforce server-only variables when running on the server.
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'MAIL_HOST is required in non-development environments',
path: ['MAIL_HOST'],
});
}
});
export type Env = z.infer<typeof envSchema>;
// Gatekeeper
GATEKEEPER_URL: z.string().url().default('http://gatekeeper:3000'),
GATEKEEPER_BYPASS_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
NEXT_PUBLIC_FEEDBACK_ENABLED: z.preprocess(
(val) => val === 'true' || val === true,
z.boolean().default(false),
),
});
/**
* Collects all environment variables from the process.
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
* Validated environment object.
*/
export const env = validateMintelEnv(envSchema.shape);
/**
* For legacy compatibility with existing code.
*/
export function getRawEnv() {
return {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
UMAMI_WEBSITE_ID: process.env.UMAMI_WEBSITE_ID,
UMAMI_API_ENDPOINT: process.env.UMAMI_API_ENDPOINT,
SENTRY_DSN: process.env.SENTRY_DSN,
LOG_LEVEL: process.env.LOG_LEVEL,
MAIL_HOST: process.env.MAIL_HOST,
MAIL_PORT: process.env.MAIL_PORT,
MAIL_USERNAME: process.env.MAIL_USERNAME,
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
MAIL_FROM: process.env.MAIL_FROM,
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
DIRECTUS_URL: process.env.DIRECTUS_URL,
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
TARGET: process.env.TARGET,
GOTIFY_URL: process.env.GOTIFY_URL,
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
GATEKEEPER_URL: process.env.GATEKEEPER_URL,
NEXT_PUBLIC_FEEDBACK_ENABLED: process.env.NEXT_PUBLIC_FEEDBACK_ENABLED,
GATEKEEPER_BYPASS_ENABLED: process.env.GATEKEEPER_BYPASS_ENABLED,
INFRA_DIRECTUS_URL: process.env.INFRA_DIRECTUS_URL,
INFRA_DIRECTUS_TOKEN: process.env.INFRA_DIRECTUS_TOKEN,
};
return env;
}