Remove all build-time variables and secure Wren API
Some checks failed
Build and Push Docker Images / docker (push) Failing after 2m20s

BREAKING CHANGE: All environment variables are now runtime-configurable

Changes:
- Removed ALL build-time NEXT_PUBLIC_* variables from Dockerfile and CI/CD
- Created server-side proxy routes for Wren API (/api/wren/*)
- Refactored wrenClient.ts to use proxy endpoints (reduced from 400+ to 200 lines)
- Updated checkoutClient.ts and emailClient.ts to remove NEXT_PUBLIC_ fallbacks
- Hardcoded metadataBase in layout.tsx (no longer depends on env var)
- Updated .env.local to use runtime-only variables (WREN_API_TOKEN, NocoDB config)

Security improvements:
- Wren API token never exposed to browser
- All secrets stay server-side
- No sensitive data baked into build

Configuration:
- Wren API: Set WREN_API_TOKEN in docker-compose or .env
- NocoDB: Set NOCODB_* variables in docker-compose or .env
- No Gitea secrets/variables needed for build (only registry credentials)

Docker build is now truly environment-agnostic - same image works in
any environment with different runtime configuration.
This commit is contained in:
Matt 2025-11-03 11:03:42 +01:00
parent c08c46aa6c
commit cfa7e88ed2
8 changed files with 186 additions and 260 deletions

View File

@ -30,9 +30,6 @@ jobs:
file: ./Dockerfile
platforms: linux/amd64
push: true
build-args: |
NEXT_PUBLIC_API_BASE_URL=${{ vars.NEXT_PUBLIC_API_BASE_URL }}
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY }}
tags: |
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-latest
${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_USERNAME }}/${{ vars.IMAGE_NAME }}:frontend-main-${{ github.sha }}

View File

@ -10,17 +10,8 @@ RUN npm ci
# Copy the rest of the app
COPY . .
# Accept build arguments for NEXT_PUBLIC_ variables
# These MUST be provided at build time
ARG NEXT_PUBLIC_API_BASE_URL
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
# Set as environment variables so Next.js can bake them into the build
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
# Build Next.js app (standalone mode)
# NEXT_PUBLIC_ variables are now baked in at build time
# All environment variables are runtime-configurable via .env or docker-compose
RUN npm run build
# Production Stage - Next.js standalone server

View File

@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* POST /api/wren/offset-orders
* Proxy endpoint to create offset orders with Wren API
* This keeps the WREN_API_TOKEN secure on the server
*
* Request body:
* {
* tons: number,
* portfolioId: number,
* dryRun?: boolean,
* source?: string,
* note?: string
* }
*/
export async function POST(request: NextRequest) {
try {
const apiToken = process.env.WREN_API_TOKEN;
if (!apiToken) {
console.error('WREN_API_TOKEN is not configured');
return NextResponse.json(
{ error: 'Wren API is not configured' },
{ status: 500 }
);
}
// Parse request body
const body = await request.json();
const { tons, portfolioId, dryRun = false, source, note } = body;
// Validate required fields
if (!tons || !portfolioId) {
return NextResponse.json(
{ error: 'Missing required fields: tons and portfolioId' },
{ status: 400 }
);
}
// Create offset order payload
const orderPayload: any = {
tons,
portfolio_id: portfolioId,
dry_run: dryRun,
};
if (source) orderPayload.source = source;
if (note) orderPayload.note = note;
// Make request to Wren API
const response = await fetch('https://www.wren.co/api/offset_orders', {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(orderPayload),
});
if (!response.ok) {
const errorText = await response.text();
console.error('Wren API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to create offset order with Wren' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error creating Wren offset order:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to create offset order' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
/**
* GET /api/wren/portfolios
* Proxy endpoint to fetch portfolios from Wren API
* This keeps the WREN_API_TOKEN secure on the server
*/
export async function GET() {
try {
const apiToken = process.env.WREN_API_TOKEN;
if (!apiToken) {
console.error('WREN_API_TOKEN is not configured');
return NextResponse.json(
{ error: 'Wren API is not configured' },
{ status: 500 }
);
}
const response = await fetch('https://www.wren.co/api/portfolios', {
headers: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('Wren API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to fetch portfolios from Wren' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Error fetching Wren portfolios:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to fetch portfolios' },
{ status: 500 }
);
}
}

View File

@ -16,7 +16,7 @@ export const metadata: Metadata = {
authors: [{ name: 'Puffin Offset' }],
creator: 'Puffin Offset',
publisher: 'Puffin Offset',
metadataBase: new URL(process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com'),
metadataBase: new URL('https://puffinoffset.com'),
alternates: {
canonical: '/',
},

View File

@ -1,18 +1,18 @@
import axios from 'axios';
import { logger } from '../utils/logger';
// Get API base URL from runtime config (window.env) or build-time config
// Get API base URL from runtime config (window.env)
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
// to ensure window.env is populated by env-config.js
const getApiBaseUrl = (): string => {
// Check window.env first (runtime config from env.sh)
// Check window.env first (runtime config from env.sh or docker-compose)
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
return window.env.API_BASE_URL;
}
// Fall back to build-time env or production default
// Next.js requires direct static reference to NEXT_PUBLIC_ variables
return process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com/api';
// Fall back to production default if not configured
// All configuration is now runtime-only via environment variables
return 'https://puffinoffset.com/api';
};
export interface CreateCheckoutSessionParams {

View File

@ -1,18 +1,18 @@
import axios from 'axios';
import { logger } from '../utils/logger';
// Get API base URL from runtime config (window.env) or build-time config
// Get API base URL from runtime config (window.env)
// IMPORTANT: Call this function at REQUEST TIME, not at module load time,
// to ensure window.env is populated by env-config.js
const getApiBaseUrl = (): string => {
// Check window.env first (runtime config from env.sh)
// Check window.env first (runtime config from env.sh or docker-compose)
if (typeof window !== 'undefined' && window.env?.API_BASE_URL) {
return window.env.API_BASE_URL;
}
// Fall back to build-time env or production default
// Next.js requires direct static reference to NEXT_PUBLIC_ variables
return process.env.NEXT_PUBLIC_API_BASE_URL || 'https://puffinoffset.com/api';
// Fall back to production default if not configured
// All configuration is now runtime-only via environment variables
return 'https://puffinoffset.com/api';
};
export interface ContactEmailData {

View File

@ -1,11 +1,10 @@
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import axios from 'axios';
import type { OffsetOrder, Portfolio } from '../types';
import { config } from '../utils/config';
import { logger } from '../utils/logger';
// Default portfolio for fallback
const DEFAULT_PORTFOLIO: Portfolio = {
id: 2, // Updated to use ID 2 as in the tutorial
id: 2,
name: "Community Tree Planting",
description: "A curated selection of high-impact carbon removal projects focused on carbon sequestration through tree planting.",
projects: [
@ -28,85 +27,10 @@ const DEFAULT_PORTFOLIO: Portfolio = {
currency: 'USD'
};
// Create API client with error handling, timeout, and retry logic
const createApiClient = () => {
if (!config.wrenApiKey) {
console.error('Wren API token is missing! Token:', config.wrenApiKey);
console.error('Environment:', window?.env ? JSON.stringify(window.env) : 'No window.env available');
throw new Error('Wren API token is not configured');
}
logger.log('[wrenClient] Creating API client with key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
const client = axios.create({
// Updated base URL to match the tutorial exactly
baseURL: 'https://www.wren.co/api',
headers: {
'Authorization': `Bearer ${config.wrenApiKey}`,
'Content-Type': 'application/json'
},
timeout: 10000, // 10 second timeout
validateStatus: (status: number) => status >= 200 && status < 500, // Handle 4xx errors gracefully
});
// Add request interceptor for logging
client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
if (!config.headers?.Authorization) {
throw new Error('API token is required');
}
logger.log('[wrenClient] Making API request to:', config.url);
return config;
},
(error: Error) => {
console.error('[wrenClient] Request configuration error:', error.message);
return Promise.reject(error);
}
);
// Add response interceptor for error handling
client.interceptors.response.use(
(response: AxiosResponse) => {
logger.log('[wrenClient] Received API response:', response.status);
return response;
},
(error: unknown) => {
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNABORTED') {
logger.warn('[wrenClient] Request timeout, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
if (!error.response) {
logger.warn('[wrenClient] Network error, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
if (error.response.status === 401) {
logger.warn('[wrenClient] Authentication failed, using fallback data');
return Promise.resolve({ data: { portfolios: [DEFAULT_PORTFOLIO] } });
}
console.error('[wrenClient] API error:', error.response?.status, error.response?.data);
}
return Promise.reject(error);
}
);
return client;
};
// Safe error logging function that handles non-serializable objects
const logError = (error: unknown) => {
if (error instanceof Error) {
const errorInfo = {
name: error.name,
message: error.message,
stack: error.stack
};
console.error('[wrenClient] API Error:', JSON.stringify(errorInfo, null, 2));
} else {
console.error('[wrenClient] Unknown error:', String(error));
}
};
/**
* Get portfolios from Wren API via server-side proxy
* This keeps the Wren API token secure on the server
*/
export async function getPortfolios(): Promise<Portfolio[]> {
const startTime = Date.now();
console.log('🔵 [WREN API] ========================================');
@ -114,19 +38,9 @@ export async function getPortfolios(): Promise<Portfolio[]> {
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
try {
if (!config.wrenApiKey) {
console.warn('⚠️ [WREN API] No API token configured, using fallback portfolio');
logger.warn('[wrenClient] No Wren API token configured, using fallback portfolio');
return [DEFAULT_PORTFOLIO];
}
console.log('🔵 [WREN API] Making request to proxy: /api/wren/portfolios');
console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
logger.log('[wrenClient] Getting portfolios with token:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
const api = createApiClient();
console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/portfolios');
const response = await api.get('/portfolios');
const response = await axios.get('/api/wren/portfolios');
const duration = Date.now() - startTime;
console.log('✅ [WREN API] GET /portfolios - Success');
@ -159,12 +73,10 @@ export async function getPortfolios(): Promise<Portfolio[]> {
// Convert from snake_case to camelCase for projects
const projects = portfolio.projects?.map((project: any) => {
// Ensure cost_per_ton is properly mapped
const projectPricePerTon = project.cost_per_ton !== undefined && project.cost_per_ton !== null
? (typeof project.cost_per_ton === 'number' ? project.cost_per_ton : parseFloat(project.cost_per_ton))
: pricePerTon;
// Ensure percentage is properly captured
const projectPercentage = project.percentage !== undefined && project.percentage !== null
? (typeof project.percentage === 'number' ? project.percentage : parseFloat(project.percentage))
: undefined;
@ -174,13 +86,10 @@ export async function getPortfolios(): Promise<Portfolio[]> {
name: project.name,
description: project.description || '',
shortDescription: project.short_description || project.description || '',
imageUrl: project.image_url, // Map from snake_case API response
imageUrl: project.image_url,
pricePerTon: projectPricePerTon,
percentage: projectPercentage, // Include percentage field
certificationStatus: project.certification_status, // Map certification status from API
// Remove fields that aren't in the API
// The required type fields are still in the type definition for compatibility
// but we no longer populate them with default values
percentage: projectPercentage,
certificationStatus: project.certification_status,
location: '',
type: '',
verificationStandard: '',
@ -206,51 +115,44 @@ export async function getPortfolios(): Promise<Portfolio[]> {
if (axios.isAxiosError(error)) {
console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
console.error('❌ [WREN API] Status Text:', error.response?.statusText || 'N/A');
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2));
console.error('❌ [WREN API] Request URL:', error.config?.url);
} else {
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
}
console.log('🔵 [WREN API] ========================================');
logError(error);
logger.warn('[wrenClient] Failed to fetch portfolios from API, using fallback');
return [DEFAULT_PORTFOLIO];
}
}
/**
* Create offset order with Wren API via server-side proxy
* This keeps the Wren API token secure on the server
*/
export async function createOffsetOrder(
portfolioId: number,
tons: number,
dryRun: boolean = false
dryRun: boolean = false,
source?: string,
note?: string
): Promise<OffsetOrder> {
const startTime = Date.now();
console.log('🔵 [WREN API] ========================================');
console.log('🔵 [WREN API] POST /offset-orders - Request initiated');
console.log('🔵 [WREN API] Timestamp:', new Date().toISOString());
console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2));
console.log('🔵 [WREN API] Parameters:', JSON.stringify({ portfolioId, tons, dryRun, source, note }, null, 2));
try {
if (!config.wrenApiKey) {
console.error('❌ [WREN API] Cannot create order - missing API token');
console.error('[wrenClient] Cannot create order - missing API token');
throw new Error('Carbon offset service is currently unavailable. Please contact support.');
}
console.log('🔵 [WREN API] Making request to proxy: /api/wren/offset-orders');
logger.log(`[wrenClient] Creating offset order via proxy: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
console.log('🔵 [WREN API] API Key:', config.wrenApiKey ? '********' + config.wrenApiKey.slice(-4) : 'MISSING');
logger.log(`[wrenClient] Creating offset order: portfolio=${portfolioId}, tons=${tons}, dryRun=${dryRun}`);
const api = createApiClient();
console.log('🔵 [WREN API] Making request to: https://www.wren.co/api/offset-orders');
console.log('🔵 [WREN API] Request payload:', JSON.stringify({ portfolioId, tons, dryRun }, null, 2));
// Removed the /api prefix to match the working example
const response = await api.post('/offset-orders', {
// Using exactly the format shown in the API tutorial
portfolioId, // Use the provided portfolio ID instead of hardcoding
const response = await axios.post('/api/wren/offset-orders', {
tons,
dryRun // Use the provided dryRun parameter
portfolioId,
dryRun,
source,
note
});
const duration = Date.now() - startTime;
@ -258,127 +160,39 @@ export async function createOffsetOrder(
console.log('✅ [WREN API] POST /offset-orders - Success');
console.log('✅ [WREN API] Status:', response.status);
console.log('✅ [WREN API] Duration:', duration + 'ms');
console.log('✅ [WREN API] Order ID:', response.data.id);
console.log('✅ [WREN API] Amount Charged:', response.data.amount_paid_by_customer);
console.log('🔵 [WREN API] ========================================');
// Add detailed response logging
logger.log('[wrenClient] Offset order response:',
response.status,
response.data ? 'has data' : 'no data');
logger.log(`[wrenClient] Order created successfully: ${response.data.id}`);
if (response.status === 400) {
console.error('❌ [WREN API] Bad request - Status 400');
console.error('❌ [WREN API] Bad request details:', response.data);
console.error('[wrenClient] Bad request details:', response.data);
throw new Error(`Failed to create offset order: ${JSON.stringify(response.data)}`);
}
const order = response.data;
if (!order) {
console.error('❌ [WREN API] Empty response received');
throw new Error('Empty response received from offset order API');
}
console.log('✅ [WREN API] Order ID:', order.id || 'N/A');
console.log('✅ [WREN API] Amount Charged:', order.amountCharged ? `$${order.amountCharged}` : 'N/A');
console.log('✅ [WREN API] Tons:', order.tons || 'N/A');
console.log('✅ [WREN API] Status:', order.status || 'N/A');
console.log('✅ [WREN API] Dry Run:', order.dryRun !== undefined ? order.dryRun : 'N/A');
console.log('🔵 [WREN API] ========================================')
// Log to help diagnose issues
logger.log('[wrenClient] Order data keys:', Object.keys(order).join(', '));
if (order.portfolio) {
logger.log('[wrenClient] Portfolio data keys:', Object.keys(order.portfolio).join(', '));
}
// Get price from API response which uses cost_per_ton
let pricePerTon = 18;
if (order.portfolio?.cost_per_ton !== undefined) {
pricePerTon = typeof order.portfolio.cost_per_ton === 'number' ? order.portfolio.cost_per_ton : parseFloat(order.portfolio.cost_per_ton) || 18;
} else if (order.portfolio?.costPerTon !== undefined) {
pricePerTon = typeof order.portfolio.costPerTon === 'number' ? order.portfolio.costPerTon : parseFloat(order.portfolio.costPerTon) || 18;
} else if (order.portfolio?.pricePerTon !== undefined) {
pricePerTon = typeof order.portfolio.pricePerTon === 'number' ? order.portfolio.pricePerTon : parseFloat(order.portfolio.pricePerTon) || 18;
}
// Create a safe method to extract properties with fallbacks
const getSafeProp = (obj: any, prop: string, fallback: any) => {
if (!obj) return fallback;
return obj[prop] !== undefined ? obj[prop] : fallback;
};
// Use safe accessor to avoid undefined errors
const portfolio = order.portfolio || {};
// Adjusted to use camelCase as per API docs response format
return {
id: getSafeProp(order, 'id', ''),
amountCharged: getSafeProp(order, 'amountCharged', 0),
currency: getSafeProp(order, 'currency', 'USD'),
tons: getSafeProp(order, 'tons', 0),
portfolio: {
id: getSafeProp(portfolio, 'id', 2),
name: getSafeProp(portfolio, 'name', 'Community Tree Planting'),
description: getSafeProp(portfolio, 'description', ''),
projects: getSafeProp(portfolio, 'projects', []),
pricePerTon,
currency: getSafeProp(order, 'currency', 'USD')
},
status: getSafeProp(order, 'status', ''),
createdAt: getSafeProp(order, 'createdAt', new Date().toISOString()),
dryRun: getSafeProp(order, 'dryRun', true)
id: response.data.id,
amountCharged: response.data.amount_paid_by_customer,
currency: (response.data.currency?.toUpperCase() || 'USD') as 'USD' | 'EUR' | 'GBP' | 'CHF',
tons: response.data.tons,
portfolio: response.data.portfolio,
status: 'completed',
createdAt: new Date().toISOString(),
dryRun,
source,
note
};
} catch (error: unknown) {
} catch (error) {
const duration = Date.now() - startTime;
console.error('❌ [WREN API] POST /offset-orders - Failed');
console.error('❌ [WREN API] Duration:', duration + 'ms');
logError(error);
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
console.error('❌ [WREN API] Status:', axiosError.response?.status || 'No response');
console.error('❌ [WREN API] Status Text:', axiosError.response?.statusText || 'N/A');
console.error('❌ [WREN API] Error Data:', JSON.stringify(axiosError.response?.data, null, 2));
console.error('❌ [WREN API] Request URL:', axiosError.config?.url);
console.error('❌ [WREN API] Request Method:', axiosError.config?.method?.toUpperCase());
console.error('❌ [WREN API] Request Data:', JSON.stringify(axiosError.config?.data, null, 2));
console.log('🔵 [WREN API] ========================================');
console.error('[wrenClient] Axios error details:', {
status: axiosError.response?.status,
statusText: axiosError.response?.statusText,
data: axiosError.response?.data,
config: {
url: axiosError.config?.url,
method: axiosError.config?.method,
headers: axiosError.config?.headers ? 'Headers present' : 'No headers',
baseURL: axiosError.config?.baseURL,
data: axiosError.config?.data
}
});
if (axiosError.response?.status === 400) {
// Provide more specific error for 400 Bad Request
const responseData = axiosError.response.data as any;
const errorMessage = responseData?.message || responseData?.error || 'Invalid request format';
throw new Error(`Bad request: ${errorMessage}`);
}
if (axiosError.code === 'ECONNABORTED') {
throw new Error('Request timed out. Please try again.');
}
if (!axiosError.response) {
throw new Error('Network error. Please check your connection and try again.');
}
if (axiosError.response.status === 401) {
throw new Error('Carbon offset service authentication failed. Please check your API token.');
}
console.error('❌ [WREN API] Status:', error.response?.status || 'No response');
console.error('❌ [WREN API] Error Data:', JSON.stringify(error.response?.data, null, 2));
console.error('❌ [WREN API] Request URL:', error.config?.url);
} else {
console.error('❌ [WREN API] Error:', error instanceof Error ? error.message : String(error));
}
console.log('🔵 [WREN API] ========================================');
}
throw new Error('Failed to create offset order. Please try again.');
logger.error('[wrenClient] Failed to create offset order');
throw new Error('Failed to create carbon offset order. Please try again or contact support.');
}
}