Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 37 additions & 9 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
# Backend Dockerfile
FROM node:22-slim AS builder
# Nomad SAN Dockerfile — single container serving both API and frontend
# In SAN mode, the backend serves the built frontend (same-origin).
# This eliminates the need for a separate frontend container and nginx proxy.

# ---- Stage 1: Build frontend ----
FROM node:22-alpine AS frontend-builder

ARG VITE_MAPBOX_TOKEN
ARG VITE_API_BASE_URL
ARG VITE_SIMPLE_AUTH=true

ENV VITE_MAPBOX_TOKEN=$VITE_MAPBOX_TOKEN
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_SIMPLE_AUTH=$VITE_SIMPLE_AUTH

WORKDIR /app

COPY frontend/package*.json ./
COPY package-lock.json ./
RUN npm ci

COPY frontend/ .
RUN npm run build

# ---- Stage 2: Build backend ----
FROM node:22-slim AS backend-builder

WORKDIR /app

# Install GDAL native libraries (required for gdal-async)
# This layer is expensive but rarely changes - cached separately
RUN apt-get update && apt-get install -y \
gdal-bin \
libgdal-dev \
Expand All @@ -17,7 +40,6 @@ COPY package.json package-lock.json ./
COPY backend/package.json ./backend/

# Install dependencies - this layer survives source code changes
# Only busts when package.json or package-lock.json change
RUN --mount=type=cache,target=/root/.npm \
npm ci --workspace=@nomad/backend

Expand All @@ -27,7 +49,7 @@ COPY backend/ ./backend/
# Build backend
RUN npm run build --workspace=@nomad/backend

# Production stage
# ---- Stage 3: Production ----
FROM node:22-slim AS production

# Install GDAL runtime libraries and Docker CLI (for spawning FireSTARR containers)
Expand All @@ -47,10 +69,16 @@ RUN apt-get update && apt-get install -y \

WORKDIR /app

# Copy built app and node_modules from workspace structure
COPY --from=builder /app/backend/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/backend/package.json ./
# Preserve workspace-relative paths so the backend can find frontend/dist
# Backend code: resolve(__dirname, '../../frontend/dist')
# __dirname = /app/backend/dist → ../../frontend/dist = /app/frontend/dist
COPY --from=backend-builder /app/backend/dist ./backend/dist
COPY --from=backend-builder /app/node_modules ./node_modules
COPY --from=backend-builder /app/backend/package.json ./backend/

# Copy built frontend into workspace-relative location
COPY --from=frontend-builder /app/dist ./frontend/dist

EXPOSE 3001
WORKDIR /app/backend
CMD ["node", "dist/index.js"]
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"dev": "cross-env NODE_ENV=development tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/**/*.ts",
Expand Down
25 changes: 14 additions & 11 deletions backend/src/api/routes/v1/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,17 @@ router.post(
]);
}

// Validate geometry and time range BEFORE creating DB records (prevents orphaned rows)
const geometryType = body.ignition.type === 'point' ? GeometryType.Point : GeometryType.Polygon;
const ignitionGeometry = new SpatialGeometry({
type: geometryType,
coordinates: body.ignition.coordinates,
});
const timeRange = new TimeRange(
new Date(body.timeRange.start),
new Date(body.timeRange.end)
);

// Create model with queued status (skip draft)
const modelId = createFireModelId(crypto.randomUUID());
const model = new FireModel({
Expand All @@ -153,6 +164,9 @@ router.post(
userId: req.user, // Capture user ownership
});

logger.model(`Creating ignition geometry: type=${body.ignition.type} -> ${geometryType}`, modelId);
logger.model(`Ignition geometry created: ${ignitionGeometry.type}, coords length: ${Array.isArray(body.ignition.coordinates[0]) ? body.ignition.coordinates.length : 1}`, modelId);

const modelRepo = getModelRepository();
await modelRepo.save(model);

Expand All @@ -169,17 +183,6 @@ router.post(
const jobId = jobResult.value.id;

// Build execution options
const geometryType = body.ignition.type === 'point' ? GeometryType.Point : GeometryType.Polygon;
logger.model(`Creating ignition geometry: type=${body.ignition.type} -> ${geometryType}`, modelId);
const ignitionGeometry = new SpatialGeometry({
type: geometryType,
coordinates: body.ignition.coordinates,
});
logger.model(`Ignition geometry created: ${ignitionGeometry.type}, coords length: ${Array.isArray(body.ignition.coordinates[0]) ? body.ignition.coordinates.length : 1}`, modelId);
const timeRange = new TimeRange(
new Date(body.timeRange.start),
new Date(body.timeRange.end)
);
const executionOptions: ExecutionOptions = {
ignitionGeometry,
timeRange,
Expand Down
40 changes: 31 additions & 9 deletions backend/src/domain/entities/SpatialGeometry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ValidationError } from '../errors/index.js';

/**
* Supported geometry types following GeoJSON specification
*/
Expand Down Expand Up @@ -239,50 +241,70 @@ export class SpatialGeometry {

private validatePosition(pos: Position): void {
if (!Array.isArray(pos) || pos.length < 2) {
throw new Error('Position must be an array of at least 2 numbers [lon, lat]');
throw new ValidationError('Invalid position', [
{ field: 'coordinates', message: 'Position must be an array of at least 2 numbers [lon, lat]' },
]);
}
const [lon, lat] = pos;
if (lon < -180 || lon > 180) {
throw new Error(`Longitude must be between -180 and 180, got ${lon}`);
throw new ValidationError('Invalid coordinates', [
{ field: 'coordinates', message: `Longitude must be between -180 and 180, got ${lon}` },
]);
}
if (lat < -90 || lat > 90) {
throw new Error(`Latitude must be between -90 and 90, got ${lat}`);
throw new ValidationError('Invalid coordinates', [
{ field: 'coordinates', message: `Latitude must be between -90 and 90, got ${lat}` },
]);
}
}

private validateLineString(coords: LineStringCoordinates): void {
if (!Array.isArray(coords) || coords.length < 2) {
throw new Error('LineString requires at least 2 positions');
throw new ValidationError('Invalid geometry', [
{ field: 'coordinates', message: 'LineString requires at least 2 positions' },
]);
}
coords.forEach((pos, i) => {
try {
this.validatePosition(pos);
} catch (e) {
throw new Error(`Invalid position at index ${i}: ${(e as Error).message}`);
if (e instanceof ValidationError) throw e;
throw new ValidationError('Invalid geometry', [
{ field: 'coordinates', message: `Invalid position at index ${i}: ${(e as Error).message}` },
]);
}
});
}

private validatePolygon(coords: PolygonCoordinates): void {
if (!Array.isArray(coords) || coords.length < 1) {
throw new Error('Polygon requires at least one ring');
throw new ValidationError('Invalid geometry', [
{ field: 'coordinates', message: 'Polygon requires at least one ring' },
]);
}
coords.forEach((ring, ringIndex) => {
if (!Array.isArray(ring) || ring.length < 4) {
throw new Error(`Ring ${ringIndex} requires at least 4 positions`);
throw new ValidationError('Invalid geometry', [
{ field: 'coordinates', message: `Ring ${ringIndex} requires at least 4 positions` },
]);
}
ring.forEach((pos, posIndex) => {
try {
this.validatePosition(pos);
} catch (e) {
throw new Error(`Invalid position at ring ${ringIndex}, index ${posIndex}: ${(e as Error).message}`);
if (e instanceof ValidationError) throw e;
throw new ValidationError('Invalid geometry', [
{ field: 'coordinates', message: `Invalid position at ring ${ringIndex}, index ${posIndex}: ${(e as Error).message}` },
]);
}
});
// Check if ring is closed
const first = ring[0];
const last = ring[ring.length - 1];
if (first[0] !== last[0] || first[1] !== last[1]) {
throw new Error(`Ring ${ringIndex} must be closed (first and last position must match)`);
throw new ValidationError('Invalid geometry', [
{ field: 'coordinates', message: `Ring ${ringIndex} must be closed (first and last position must match)` },
]);
}
});
}
Expand Down
14 changes: 11 additions & 3 deletions backend/src/domain/value-objects/TimeRange.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { ValidationError } from '../errors/index.js';

/**
* Immutable value object representing a time range with start, end, and duration.
*
Expand Down Expand Up @@ -171,13 +173,19 @@ export class TimeRange {

private validateDates(start: Date, end: Date): void {
if (!(start instanceof Date) || isNaN(start.getTime())) {
throw new Error('Invalid start date');
throw new ValidationError('Invalid time range', [
{ field: 'timeRange.start', message: 'Invalid start date' },
]);
}
if (!(end instanceof Date) || isNaN(end.getTime())) {
throw new Error('Invalid end date');
throw new ValidationError('Invalid time range', [
{ field: 'timeRange.end', message: 'Invalid end date' },
]);
}
if (end <= start) {
throw new Error('End date must be after start date');
throw new ValidationError('Invalid time range', [
{ field: 'timeRange', message: 'End date must be after start date' },
]);
}
}
}
94 changes: 41 additions & 53 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,26 +90,52 @@ function getCorsOptions(): CorsOptions {
};
}

// SAN mode: same-origin only.
// SAN uses simple auth (X-Nomad-User header) with no server-side validation,
// so cross-origin requests must be blocked to prevent external apps from
// impersonating users against the Nomad backend.
logger.info('SAN mode - same-origin only (cross-origin requests blocked)', 'CORS');
// SAN mode: allow all origins.
// SAN is a single-user standalone deployment on a trusted network.
// Auth is header-based (X-Nomad-User), not cookie-based, so CORS
// provides no meaningful CSRF protection. The server also cannot
// reliably determine its external origin when behind Docker port
// mapping (container sees :3001, browser sees :3901).
// Cross-origin protection is enforced in ACN mode instead.
return {
origin: (origin, callback) => {
// Allow requests with no origin (same-origin, curl, server-to-server)
if (!origin) {
callback(null, true);
return;
}
// Block cross-origin requests in SAN mode
logger.warn(`SAN mode blocked cross-origin request from: ${origin}`, 'CORS');
callback(new Error('Cross-origin requests are not allowed in SAN mode. Use ACN deployment mode for external integration.'));
},
origin: true,
credentials: true,
};
}

// ============================================
// Static File Serving (Production Mode)
// ============================================
// Mounted BEFORE CORS/auth middleware — static files don't need CORS checks.
// Vite emits <script type="module" crossorigin> which causes browsers to send
// an Origin header even for same-origin requests. The SAN CORS policy blocks
// all requests with an Origin header, so static files must bypass it entirely.

const isProduction = process.env.NODE_ENV === 'production';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const frontendDistPath = resolve(__dirname, '../../frontend/dist');

if (isProduction && existsSync(frontendDistPath)) {
logger.startup(`Production mode: serving frontend from ${frontendDistPath}`);

// Serve static files (JS, CSS, images, etc.)
app.use(express.static(frontendDistPath));

// SPA catch-all: serve index.html for any non-API route
// This enables client-side routing (React Router, etc.)
app.get('*', (req, res, next) => {
// Skip API routes - let them fall through to 404 handler
if (req.path.startsWith('/api')) {
return next();
}
res.sendFile(join(frontendDistPath, 'index.html'));
});
} else if (isProduction) {
logger.warn(`Production mode but frontend not found at ${frontendDistPath}`, 'Startup');
logger.warn('Run "npm run build" to build the frontend', 'Startup');
}

// ============================================
// Middleware (order matters!)
// ============================================
Expand Down Expand Up @@ -159,44 +185,6 @@ app.get('/api/health', (_req, res) => {
res.redirect('/api/v1/health');
});

// ============================================
// Static File Serving (Production Mode)
// ============================================

/**
* In production mode, serve the built frontend from frontend/dist.
* This allows running a single server for both API and UI.
*
* The frontend dist path is relative to the backend dist directory:
* - Backend runs from: backend/dist/index.js
* - Frontend built to: frontend/dist/
* - Relative path: ../../frontend/dist
*/
const isProduction = process.env.NODE_ENV === 'production';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const frontendDistPath = resolve(__dirname, '../../frontend/dist');

if (isProduction && existsSync(frontendDistPath)) {
logger.startup(`Production mode: serving frontend from ${frontendDistPath}`);

// Serve static files (JS, CSS, images, etc.)
app.use(express.static(frontendDistPath));

// SPA catch-all: serve index.html for any non-API route
// This enables client-side routing (React Router, etc.)
app.get('*', (req, res, next) => {
// Skip API routes - let them fall through to 404 handler
if (req.path.startsWith('/api')) {
return next();
}
res.sendFile(join(frontendDistPath, 'index.html'));
});
} else if (isProduction) {
logger.warn(`Production mode but frontend not found at ${frontendDistPath}`, 'Startup');
logger.warn('Run "npm run build" to build the frontend', 'Startup');
}

// ============================================
// Error Handling (must be last)
// ============================================
Expand Down
Loading