From a2d11dcadf832f9fad92eb1f4c83960a2e8141a9 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Tue, 10 Feb 2026 23:41:32 +0100 Subject: [PATCH] refactor: streamline env and directus logic using @mintel/next-utils and fix network isolation --- docker-compose.yml | 6 +- lib/directus.ts | 66 +++++---------------- lib/env.ts | 141 ++++++++++----------------------------------- 3 files changed, 46 insertions(+), 167 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index bf3c7f19..106dfdc1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/lib/directus.ts b/lib/directus.ts index 085c2c8c..2cbeaa81 100644 --- a/lib/directus.ts +++ b/lib/directus.ts @@ -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 | 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; } } diff --git a/lib/env.ts b/lib/env.ts index 21ccee40..b60e8cf9 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -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; + // 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; }