Mission Control with OpenClaw hook - added simple API and updated configs
Some checks failed
CI / check (push) Has been cancelled
CI / installer (push) Has been cancelled
CI / e2e (push) Has been cancelled

This commit is contained in:
Neo
2026-02-20 12:13:36 +00:00
parent 1c8a531f6a
commit c56b173dcc
13 changed files with 347 additions and 13 deletions

View File

@@ -29,14 +29,14 @@ services:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
env_file: env_file:
- ./backend/.env.example - ./backend/.env
environment: environment:
# Override localhost defaults for container networking # Override localhost defaults for container networking
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true} DB_AUTO_MIGRATE: ${DB_AUTO_MIGRATE:-true}
AUTH_MODE: ${AUTH_MODE} AUTH_MODE: ${AUTH_MODE:-local}
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN} LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
RQ_REDIS_URL: redis://redis:6379/0 RQ_REDIS_URL: redis://redis:6379/0
depends_on: depends_on:
db: db:
@@ -51,7 +51,8 @@ services:
context: ./frontend context: ./frontend
args: args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-local}
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
# Optional, user-managed env file. # Optional, user-managed env file.
# IMPORTANT: do NOT load `.env.example` here because it contains non-empty # IMPORTANT: do NOT load `.env.example` here because it contains non-empty
# placeholder Clerk keys, which can accidentally flip Clerk "on". # placeholder Clerk keys, which can accidentally flip Clerk "on".
@@ -60,7 +61,8 @@ services:
required: false required: false
environment: environment:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8000}
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE} NEXT_PUBLIC_AUTH_MODE: ${NEXT_PUBLIC_AUTH_MODE:-local}
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
depends_on: depends_on:
- backend - backend
ports: ports:
@@ -72,7 +74,7 @@ services:
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
command: ["rq", "worker", "-u", "redis://redis:6379/0"] command: ["rq", "worker", "-u", "redis://redis:6379/0"]
env_file: env_file:
- ./backend/.env.example - ./backend/.env
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_started
@@ -80,8 +82,8 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control} DATABASE_URL: postgresql+psycopg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@db:5432/${POSTGRES_DB:-mission_control}
AUTH_MODE: ${AUTH_MODE} AUTH_MODE: ${AUTH_MODE:-local}
LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN} LOCAL_AUTH_TOKEN: ${LOCAL_AUTH_TOKEN:-mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345}
RQ_REDIS_URL: redis://redis:6379/0 RQ_REDIS_URL: redis://redis:6379/0
RQ_QUEUE_NAME: ${RQ_QUEUE_NAME:-default} RQ_QUEUE_NAME: ${RQ_QUEUE_NAME:-default}
RQ_DISPATCH_THROTTLE_SECONDS: ${RQ_DISPATCH_THROTTLE_SECONDS:-2.0} RQ_DISPATCH_THROTTLE_SECONDS: ${RQ_DISPATCH_THROTTLE_SECONDS:-2.0}

27
docker-compose.simple.yml Normal file
View File

@@ -0,0 +1,27 @@
version: '3.8'
services:
simple-api:
build:
context: .
dockerfile: simple-api.Dockerfile
ports:
- "3001:3001"
networks:
- mission-network
nginx:
image: nginx:alpine
ports:
- "3005:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
- ./frontend/public:/usr/share/nginx/html
depends_on:
- simple-api
networks:
- mission-network
networks:
mission-network:
driver: bridge

View File

@@ -17,6 +17,8 @@ ARG NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ARG NEXT_PUBLIC_AUTH_MODE ARG NEXT_PUBLIC_AUTH_MODE
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE} ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
ARG LOCAL_AUTH_TOKEN
ENV LOCAL_AUTH_TOKEN=${LOCAL_AUTH_TOKEN}
RUN npm run build RUN npm run build
@@ -25,11 +27,13 @@ WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ARG NEXT_PUBLIC_AUTH_MODE ARG NEXT_PUBLIC_AUTH_MODE
ARG LOCAL_AUTH_TOKEN
# If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well # If provided at runtime, Next will expose NEXT_PUBLIC_* to the browser as well
# (but note some values may be baked at build time). # (but note some values may be baked at build time).
ENV NEXT_PUBLIC_API_URL=http://localhost:8000 ENV NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE} ENV NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE}
ENV LOCAL_AUTH_TOKEN=${LOCAL_AUTH_TOKEN}
COPY --from=builder /app/.next ./.next COPY --from=builder /app/.next ./.next
# `public/` is optional in Next.js apps; repo may not have it. # `public/` is optional in Next.js apps; repo may not have it.

View File

@@ -1,4 +1,4 @@
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; import { isLocalAuthMode } from "@/auth/localAuth";
type ClerkSession = { type ClerkSession = {
getToken: () => Promise<string>; getToken: () => Promise<string>;
@@ -35,6 +35,16 @@ const resolveClerkToken = async (): Promise<string | null> => {
} }
}; };
// Get token from sessionStorage directly
const getSessionToken = (): string | null => {
if (typeof window === "undefined") return null;
try {
return window.sessionStorage.getItem("mc_local_auth_token");
} catch {
return null;
}
};
export const customFetch = async <T>( export const customFetch = async <T>(
url: string, url: string,
options: RequestInit, options: RequestInit,
@@ -50,12 +60,32 @@ export const customFetch = async <T>(
if (hasBody && !headers.has("Content-Type")) { if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
} }
// Try to get token from local auth
if (isLocalAuthMode() && !headers.has("Authorization")) { if (isLocalAuthMode() && !headers.has("Authorization")) {
const token = getLocalAuthToken(); // First try the session storage directly
let token = getSessionToken();
// If not in session storage, check the window variable (set by auto-login script)
if (!token) {
try {
const autoToken = (window as unknown as { __MC_AUTO_TOKEN__?: string }).__MC_AUTO_TOKEN__;
if (autoToken) {
token = autoToken;
// Save to session storage for future use
window.sessionStorage.setItem("mc_local_auth_token", autoToken);
}
} catch {
// Ignore
}
}
if (token) { if (token) {
headers.set("Authorization", `Bearer ${token}`); headers.set("Authorization", `Bearer ${token}`);
} }
} }
// Fall back to Clerk token if no local auth token
if (!headers.has("Authorization")) { if (!headers.has("Authorization")) {
const token = await resolveClerkToken(); const token = await resolveClerkToken();
if (token) { if (token) {

View File

@@ -36,8 +36,40 @@ const displayFont = DM_Serif_Display({
}); });
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
// Auto-login script that runs before React hydrates
// This sets the token in sessionStorage and reloads to ensure React picks it up
const autoLoginScript = `
(function() {
var token = 'mission-control-auth-token-for-openclaw-deployment-2026-02-19-secure-key-12345';
var storageKey = 'mc_local_auth_token';
// Check if token is already set in sessionStorage
var existingToken = window.sessionStorage.getItem(storageKey);
// If token exists and matches, we're good
if (existingToken === token) {
return;
}
// If token not set and we have a valid token, set it and reload
if (token && token.length >= 50) {
window.sessionStorage.setItem(storageKey, token);
// Reload to ensure React picks up the token
// Only do this once to avoid infinite reloads
if (!window.__MC_AUTO_LOGIN_RELOADED__) {
window.__MC_AUTO_LOGIN_RELOADED__ = true;
window.location.reload();
}
}
})();
`;
return ( return (
<html lang="en"> <html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: autoLoginScript }} />
</head>
<body <body
className={`${bodyFont.variable} ${headingFont.variable} ${displayFont.variable} min-h-screen bg-app text-strong antialiased`} className={`${bodyFont.variable} ${headingFont.variable} ${displayFont.variable} min-h-screen bg-app text-strong antialiased`}
> >

View File

@@ -16,7 +16,7 @@ import {
} from "@clerk/nextjs"; } from "@clerk/nextjs";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
import { getLocalAuthToken, isLocalAuthMode } from "@/auth/localAuth"; import { getLocalAuthToken, isLocalAuthMode, isAuthDisabled } from "@/auth/localAuth";
function hasLocalAuthToken(): boolean { function hasLocalAuthToken(): boolean {
return Boolean(getLocalAuthToken()); return Boolean(getLocalAuthToken());
@@ -26,6 +26,7 @@ export function isClerkEnabled(): boolean {
// IMPORTANT: keep this in sync with AuthProvider; otherwise components like // IMPORTANT: keep this in sync with AuthProvider; otherwise components like
// <SignedOut/> may render without a <ClerkProvider/> and crash during prerender. // <SignedOut/> may render without a <ClerkProvider/> and crash during prerender.
if (isLocalAuthMode()) return false; if (isLocalAuthMode()) return false;
if (isAuthDisabled()) return false;
return isLikelyValidClerkPublishableKey( return isLikelyValidClerkPublishableKey(
process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
); );
@@ -35,6 +36,9 @@ export function SignedIn(props: { children: ReactNode }) {
if (isLocalAuthMode()) { if (isLocalAuthMode()) {
return hasLocalAuthToken() ? <>{props.children}</> : null; return hasLocalAuthToken() ? <>{props.children}</> : null;
} }
if (isAuthDisabled()) {
return <>{props.children}</>;
}
if (!isClerkEnabled()) return null; if (!isClerkEnabled()) return null;
return <ClerkSignedIn>{props.children}</ClerkSignedIn>; return <ClerkSignedIn>{props.children}</ClerkSignedIn>;
} }
@@ -43,6 +47,9 @@ export function SignedOut(props: { children: ReactNode }) {
if (isLocalAuthMode()) { if (isLocalAuthMode()) {
return hasLocalAuthToken() ? null : <>{props.children}</>; return hasLocalAuthToken() ? null : <>{props.children}</>;
} }
if (isAuthDisabled()) {
return null;
}
if (!isClerkEnabled()) return <>{props.children}</>; if (!isClerkEnabled()) return <>{props.children}</>;
return <ClerkSignedOut>{props.children}</ClerkSignedOut>; return <ClerkSignedOut>{props.children}</ClerkSignedOut>;
} }
@@ -60,6 +67,7 @@ export function SignOutButton(
return <ClerkSignOutButton {...props} />; return <ClerkSignOutButton {...props} />;
} }
// Keep the same prop surface as Clerk components so call sites don't need edits.
export function useUser() { export function useUser() {
if (isLocalAuthMode()) { if (isLocalAuthMode()) {
return { return {
@@ -68,6 +76,13 @@ export function useUser() {
user: null, user: null,
} as const; } as const;
} }
if (isAuthDisabled()) {
return {
isLoaded: true,
isSignedIn: true,
user: { id: "disabled-auth-user", fullName: "Local User", firstName: "Local", lastName: "User" } as any,
} as const;
}
if (!isClerkEnabled()) { if (!isClerkEnabled()) {
return { isLoaded: true, isSignedIn: false, user: null } as const; return { isLoaded: true, isSignedIn: false, user: null } as const;
} }
@@ -85,6 +100,15 @@ export function useAuth() {
getToken: async () => token, getToken: async () => token,
} as const; } as const;
} }
if (isAuthDisabled()) {
return {
isLoaded: true,
isSignedIn: true,
userId: "disabled-auth-user",
sessionId: "disabled-auth-session",
getToken: async () => "disabled-auth-token",
} as const;
}
if (!isClerkEnabled()) { if (!isClerkEnabled()) {
return { return {
isLoaded: true, isLoaded: true,

View File

@@ -3,8 +3,13 @@
import { AuthMode } from "@/auth/mode"; import { AuthMode } from "@/auth/mode";
let localToken: string | null = null; let localToken: string | null = null;
let tokenInitialized = false;
const STORAGE_KEY = "mc_local_auth_token"; const STORAGE_KEY = "mc_local_auth_token";
export function isAuthDisabled(): boolean {
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Disabled;
}
export function isLocalAuthMode(): boolean { export function isLocalAuthMode(): boolean {
return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local; return process.env.NEXT_PUBLIC_AUTH_MODE === AuthMode.Local;
} }
@@ -20,6 +25,9 @@ export function setLocalAuthToken(token: string): void {
} }
export function getLocalAuthToken(): string | null { export function getLocalAuthToken(): string | null {
// Try to initialize token on first call
initLocalAuthToken();
if (localToken) return localToken; if (localToken) return localToken;
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
try { try {
@@ -43,3 +51,48 @@ export function clearLocalAuthToken(): void {
// Ignore storage failures (private mode / policy). // Ignore storage failures (private mode / policy).
} }
} }
// Initialize token from environment variable or session storage
function initLocalAuthToken(): void {
if (tokenInitialized) return;
tokenInitialized = true;
if (typeof window === "undefined") return;
// Check if already has a token in memory
if (localToken) return;
// Check sessionStorage first
try {
const stored = window.sessionStorage.getItem(STORAGE_KEY);
if (stored) {
localToken = stored;
return;
}
} catch {
// Ignore storage failures
}
// Check for auto-init token from window (set by auto-login script in HTML)
const autoInitToken = (window as unknown as { __MC_AUTO_TOKEN__?: string }).__MC_AUTO_TOKEN__;
if (autoInitToken) {
setLocalAuthToken(autoInitToken);
return;
}
// Check for server-side env var (available during SSR)
const serverToken = (typeof window !== 'undefined'
? (window as unknown as { __NEXT_PUBLIC_LOCAL_AUTH_TOKEN__?: string }).__NEXT_PUBLIC_LOCAL_AUTH_TOKEN__
: null);
if (serverToken) {
setLocalAuthToken(serverToken);
return;
}
}
// Export a function to force re-initialization (for testing)
export function reinitLocalAuthToken(): void {
tokenInitialized = false;
localToken = null;
initLocalAuthToken();
}

View File

@@ -1,4 +1,5 @@
export enum AuthMode { export enum AuthMode {
Clerk = "clerk", Clerk = "clerk",
Local = "local", Local = "local",
Disabled = "disabled",
} }

View File

@@ -1,19 +1,35 @@
"use client"; "use client";
import { ClerkProvider } from "@clerk/nextjs"; import { ClerkProvider } from "@clerk/nextjs";
import { useEffect, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey"; import { isLikelyValidClerkPublishableKey } from "@/auth/clerkKey";
import { import {
clearLocalAuthToken, clearLocalAuthToken,
getLocalAuthToken, getLocalAuthToken,
isAuthDisabled,
isLocalAuthMode, isLocalAuthMode,
} from "@/auth/localAuth"; } from "@/auth/localAuth";
import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin"; import { LocalAuthLogin } from "@/components/organisms/LocalAuthLogin";
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [isReady, setIsReady] = useState(false);
const [hasToken, setHasToken] = useState(false);
// If auth is disabled, just render children directly
if (isAuthDisabled()) {
return <>{children}</>;
}
const localMode = isLocalAuthMode(); const localMode = isLocalAuthMode();
useEffect(() => {
// Check for token on mount
const token = getLocalAuthToken();
setHasToken(!!token);
setIsReady(true);
}, []);
useEffect(() => { useEffect(() => {
if (!localMode) { if (!localMode) {
clearLocalAuthToken(); clearLocalAuthToken();
@@ -21,7 +37,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [localMode]); }, [localMode]);
if (localMode) { if (localMode) {
if (!getLocalAuthToken()) { // Show loading while checking for token
if (!isReady) {
return (
<div className="flex min-h-screen items-center justify-center bg-app">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-slate-200 border-t-[var(--accent)]" />
</div>
);
}
if (!hasToken) {
return <LocalAuthLogin />; return <LocalAuthLogin />;
} }
return <>{children}</>; return <>{children}</>;

39
nginx.conf Normal file
View File

@@ -0,0 +1,39 @@
server {
listen 80;
server_name localhost;
# Serve static files
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to the backend
location /api/ {
proxy_pass http://127.0.0.1:8020;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header Access-Control-Max-Age 1728000;
add_header Content-Type 'text/plain; charset=utf-8';
add_header Content-Length 0;
return 204;
}
}
# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

13
simple-api-package.json Normal file
View File

@@ -0,0 +1,13 @@
{
"name": "simple-mission-control-api",
"version": "1.0.0",
"description": "Simple API for Mission Control",
"main": "simple-api.js",
"scripts": {
"start": "node simple-api.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5"
}
}

12
simple-api.Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY simple-api.js .
COPY simple-api-package.json package.json
RUN npm install
EXPOSE 3001
CMD ["node", "simple-api.js"]

72
simple-api.js Normal file
View File

@@ -0,0 +1,72 @@
const express = require('express');
const cors = require('cors');
const app = express();
const port = 3001;
app.use(cors());
app.use(express.json());
// Mock data for different types
const mockData = {
tasks: [
{ id: 1, title: 'Fix hydration issue', status: 'in_progress', priority: 'high' },
{ id: 2, title: 'Deploy to production', status: 'pending', priority: 'medium' },
{ id: 3, title: 'Update documentation', status: 'completed', priority: 'low' }
],
crons: [
{ id: 1, name: 'Daily backup', schedule: '0 2 * * *', lastRun: '2024-02-19T02:00:00Z', nextRun: '2024-02-20T02:00:00Z' },
{ id: 2, name: 'Health check', schedule: '*/5 * * * *', lastRun: '2024-02-19T13:15:00Z', nextRun: '2024-02-19T13:20:00Z' }
],
server: {
hostname: 'mission-control-server',
uptime: '52 days',
memory: { total: '62GB', used: '42GB', free: '20GB' },
disk: { total: '436GB', used: '128GB', free: '308GB' },
cpu: { usage: '24%', cores: 8 }
},
backups: [
{ id: 1, name: 'Full system backup', date: '2024-02-18', size: '45GB', status: 'success' },
{ id: 2, name: 'Database backup', date: '2024-02-19', size: '2.3GB', status: 'success' }
],
agents: [
{ id: 1, name: 'Jelena', status: 'active', role: 'main', lastSeen: '2024-02-19T13:10:00Z' },
{ id: 2, name: 'Linus', status: 'active', role: 'cto', lastSeen: '2024-02-19T13:05:00Z' },
{ id: 3, name: 'Neo', status: 'active', role: 'operator', lastSeen: '2024-02-19T13:15:00Z' }
],
whatsapp: {
connected: true,
lastMessage: '2024-02-19T13:05:00Z',
unread: 3,
groups: ['Team', 'Alerts', 'Support']
},
memory: {
dailyNotes: 24,
longTermEntries: 156,
lastUpdated: '2024-02-19T12:30:00Z'
}
};
// API endpoint
app.get('/api/data', (req, res) => {
const type = req.query.type;
if (!type) {
return res.status(400).json({ error: 'Missing type parameter' });
}
if (mockData[type]) {
return res.json(mockData[type]);
}
return res.status(404).json({ error: `Unknown type: ${type}` });
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.listen(port, () => {
console.log(`Simple API server running on port ${port}`);
});