feat(opax-web): import P1A Operations Console

This commit is contained in:
Chris Christiansen 2026-09-13 11:21:00 +00:00
parent fceec08823
commit 429cc95c9a
24 changed files with 1639 additions and 0 deletions

15
opax-web/.dockerignore Normal file
View File

@ -0,0 +1,15 @@
# Ekskluder lokale avhengighetsmapper fra build context
node_modules
**/node_modules
# Ekskluder lokale build-artefakter
dist
**/dist
# Ekskluder miljøfiler og Git-historikk
.env
.env.*
.git
# Ekskluder npm-debug-logger
npm-debug.log*

5
opax-web/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.env
dist/
backend/.env

50
opax-web/Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# === STADIUM 1: BUILD ===
# Bygger frontend-appen og installerer alle workspace-dependencies for build
FROM node:20-slim AS build
WORKDIR /app
# 1. Kopier kun manifests for å utnytte Docker layer caching
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/
# 2. Installer ALLE avhengigheter (inkl. dev) basert på lockfilen
RUN npm ci
# 3. Kopier resten av frontend-kilden og bygg den
COPY frontend/ ./frontend/
RUN npm run build --workspace=frontend
# === STADIUM 2: RUNTIME ===
# Bygger et slankt produksjons-image med kun det nødvendigste
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
# 1. Kopier kun manifests for en ren produksjonsinstallasjon
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/
# 2. Installer KUN produksjonsavhengigheter for hele workspacet
# --omit=dev skal hindre at frontend dev-dependencies (vite, etc.) installeres
RUN npm ci --omit=dev
# 3. Kopier backend-kildekoden, eid av non-root 'node'-bruker
COPY --chown=node:node backend/ ./backend/
# 4. Kopier det ferdigbygde frontend-resultatet fra BUILD-stadiet
COPY --chown=node:node --from=build /app/frontend/dist ./frontend/dist
# 5. Bruk non-root bruker for å kjøre applikasjonen
USER node
# Port-dokumentasjon (Cloud Run vil bruke PORT env var)
EXPOSE 8080
# Start backend-serveren
CMD ["node", "backend/server.js"]

View File

@ -0,0 +1,55 @@
# OPAX / Web TUI Intern Dokumentasjon
Denne filen beskriver hvordan man kjører og overvåker backend-tjenesten for OPAX / Web TUI.
## Kjøre i produksjonsmodus
Applikasjonen er designet for å kjøre i en Node.js-container. For å starte serveren i produksjonsmodus, sørg for at følgende er satt:
1. **Miljøvariabel:** `NODE_ENV` må være satt til `production`.
2. **Secrets:** Følgende miljøvariabler må være tilgjengelige i kjøremiljøet, f.eks. via Secret Manager:
* `GOOGLE_CLIENT_ID`
* `GOOGLE_CLIENT_SECRET`
* `SESSION_SECRET`
3. **Konfigurasjon:**
* `FRONTEND_URL`: Den fulle URL-en til frontend-applikasjonen (f.eks. `https://opax.vauco.no`).
* `ALLOWED_EMAILS`: En komma-separert liste med e-postadresser som har lov til å logge inn.
* `PORT`: Porten serveren skal lytte på (default er `8080`).
Serveren startes ved å kjøre hovedfilen:
```bash
node opax-web/backend/server.js
```
## Health Check
Tjenesten har et health check-endepunkt for ekstern overvåking.
- **URL:** `/health`
- **Metode:** `GET`
- **Suksessrespons (HTTP 200):**
```json
{
"status": "ok"
}
```
## API-endepunkter (Socket.IO)
Klienten kommuniserer med serveren via Socket.IO for å hente Git-informasjon. All kommunikasjon krever en aktiv, autentisert sesjon.
1. **Hent Git Status**
- **Klient-event:** `git:getStatus`
- **Server-respons (suksess):** `git:status:result` med payload `{ data: { status: string, details: string } }`
- **Server-respons (feil):** `git:status:error` med payload `{ message: string }`
2. **Hent Aktiv Branch**
- **Klient-event:** `git:getBranch`
- **Server-respons (suksess):** `git:branch:result` med payload `{ data: { current_branch: string } }`
- **Server-respons (feil):** `git:branch:error` med payload `{ message: string }`
## Driftstips
- **Overvåking:** Health-check-endepunktet `/health` kan brukes av en ekstern monitortjeneste (f.eks. Google Cloud Monitoring Uptime Check). Konfigurer monitoren til å sende en GET-forespørsel hvert minutt og varsle ved ikke-200-status over en periode på for eksempel 3-5 minutter.
- **Logging:** All applikasjonslogging (inkludert feil) skrives til `stdout`/`stderr`. I et container-miljø som Cloud Run, blir disse loggene automatisk samlet inn og kan sees i Google Cloud Logging. Bruk filter i Logging for å isolere logger fra denne spesifikke Cloud Run-tjenesten.

41
opax-web/README.md Normal file
View File

@ -0,0 +1,41 @@
# OPAX Web TUI
This project provides a starter scaffold for a Google-login-gated web control plane. It consists of a React frontend and a Node.js backend.
The frontend provides a terminal-like interface for interacting with the system. The backend handles user authentication via Google OAuth2 and provides a secure boundary for potential agent integrations.
## Local Development
To run the application locally, first install the dependencies:
```bash
npm install
```
Then, start the development server:
```bash
npm run dev
```
### Configuration
The backend requires several environment variables for configuration. For local development, you can create a `.env` file in the `opax-web/backend/` directory.
- Do not commit `.env` files to Git.
- **NEVER** use or copy production secrets into a local `.env` file.
- For configuration that requires secrets, use local, rotatable test values.
- Production secrets must be stored and delivered via the dedicated secrets solution described in `SECURITY.md`.
Required configuration keys:
- `FRONTEND_URL`
- `ALLOWED_EMAILS`
- `GOOGLE_CLIENT_ID`
- `GOOGLE_CLIENT_SECRET`
- `SESSION_SECRET`
**Note:** The outbound agent integration is currently disabled by default (fail-closed). The backend will not attempt to connect to any external agent services.
## Security
**IMPORTANT:** Never commit passwords, tokens, OAuth client secrets, or service account keys to the Git repository. If any credential is accidentally exposed, it **MUST** be revoked and rotated immediately.
For more details, see `SECURITY.md`.

14
opax-web/SECURITY.md Normal file
View File

@ -0,0 +1,14 @@
# Security Policy
## Responsible Disclosure
To report a security vulnerability, please send a private email to `chris.christiansen@vauco.no`.
Do not include sensitive information, customer data, or secrets in public GitHub issues or chat discussions.
## Credential Security
- **NEVER** commit credentials, secrets, or tokens to the Git repository.
- **NEVER** include credentials in logs, frontend code, or any other uncontrolled files.
- If a credential is accidentally exposed, it **MUST** be rotated immediately.
- For production environments, all secrets must be stored in a dedicated secret management service (e.g., Google Secret Manager) and accessed using least-privilege IAM principles.

View File

@ -0,0 +1,7 @@
PORT=8080
FRONTEND_URL=http://localhost:5173
GOOGLE_CLIENT_ID=replace-me
GOOGLE_CLIENT_SECRET=store-only-in-secret-manager
SESSION_SECRET=generate-a-long-random-secret
ALLOWED_EMAILS=your-email@example.com
MCP_GATEWAY_URL=https://your-controlled-gateway.example

View File

@ -0,0 +1,139 @@
import { randomUUID } from 'node:crypto';
const TIMEOUT_MS = 10000;
const MAX_REQUEST_SIZE = 16384;
const MAX_RESPONSE_SIZE = 1024 * 1024;
const GENERIC_ERROR_MESSAGE = 'Agent request failed.';
const READ_ONLY_TOOLS = new Set([
'get_health',
'get_build_status',
'get_state',
'get_telemetry',
]);
export class AgentNotConfiguredError extends Error {
constructor() {
super('Agent integration is not configured.');
this.name = 'AgentNotConfiguredError';
}
}
function validateMcpUrl(urlString, allowedOrigin) {
const url = new URL(urlString);
if (url.username || url.password || url.hash) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
if (url.protocol === 'http:') {
const isLoopback =
url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]';
if (!isLoopback) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
return url;
}
if (url.protocol !== 'https:' || !allowedOrigin) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const configuredOrigin = new URL(allowedOrigin);
if (url.origin !== configuredOrigin.origin) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
return url;
}
export async function callAgent(tool, args) {
const mcpUrl = process.env.OPAX_MCP_URL?.trim();
const mcpSecret = process.env.MCP_SECRET?.trim();
if (!mcpUrl || !mcpSecret) {
throw new AgentNotConfiguredError();
}
try {
const allowedOrigin = process.env.OPAX_MCP_ALLOWED_ORIGIN?.trim();
if (!READ_ONLY_TOOLS.has(tool)) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const validatedUrl = validateMcpUrl(mcpUrl, allowedOrigin);
const requestBody = JSON.stringify({
jsonrpc: '2.0',
id: randomUUID(),
method: 'tools/call',
params: {
name: tool,
arguments: args,
},
});
if (requestBody.length > MAX_REQUEST_SIZE) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(validatedUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${mcpSecret}`,
},
body: requestBody,
signal: controller.signal,
});
if (!response.ok) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const responseText = await response.text();
if (responseText.length > MAX_RESPONSE_SIZE) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const rpcResponse = JSON.parse(responseText);
if (rpcResponse.error) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const content = rpcResponse.result?.content;
if (!Array.isArray(content) || content.length === 0) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
const firstContent = content[0];
if (
firstContent?.type !== 'text' ||
typeof firstContent.text !== 'string' ||
firstContent.text.length > MAX_RESPONSE_SIZE
) {
throw new Error(GENERIC_ERROR_MESSAGE);
}
return JSON.parse(firstContent.text);
} finally {
clearTimeout(timeoutId);
}
} catch {
throw new Error(GENERIC_ERROR_MESSAGE);
}
}

View File

@ -0,0 +1,403 @@
import { after, afterEach, before, beforeEach, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { once } from 'node:events';
import {
AgentNotConfiguredError,
callAgent,
} from './agentClient.js';
const DUMMY_SECRET = 'test-secret-not-production';
const DUMMY_TOOL = 'get_health';
const GENERIC_ERROR_MESSAGE = 'Agent request failed.';
const MAX_RESPONSE_SIZE = 1024 * 1024;
const ENV_KEYS = [
'OPAX_MCP_URL',
'MCP_SECRET',
'OPAX_MCP_ALLOWED_ORIGIN',
];
const originalEnv = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
);
const originalFetch = globalThis.fetch;
function restoreEnvironment() {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) {
delete process.env[key];
} else {
process.env[key] = originalEnv[key];
}
}
}
function clearTestEnvironment() {
for (const key of ENV_KEYS) {
delete process.env[key];
}
}
function configureLocalMock(url) {
process.env.OPAX_MCP_URL = url;
process.env.MCP_SECRET = DUMMY_SECRET;
delete process.env.OPAX_MCP_ALLOWED_ORIGIN;
}
async function expectGenericFailure(action) {
await assert.rejects(action, {
message: GENERIC_ERROR_MESSAGE,
});
}
async function expectNoFetch(action) {
let fetchCalled = false;
globalThis.fetch = async () => {
fetchCalled = true;
throw new Error('fetch must not be called');
};
try {
await action();
assert.equal(fetchCalled, false);
} finally {
globalThis.fetch = originalFetch;
}
}
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
'Content-Type': 'application/json',
});
response.end(JSON.stringify(body));
}
describe('callAgent', { concurrency: false }, () => {
beforeEach(() => {
clearTestEnvironment();
globalThis.fetch = originalFetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
restoreEnvironment();
});
after(() => {
globalThis.fetch = originalFetch;
restoreEnvironment();
});
describe('configuration and local validation', () => {
it('rejects a missing MCP URL with AgentNotConfiguredError', async () => {
process.env.MCP_SECRET = DUMMY_SECRET;
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
});
it('rejects a missing MCP secret with AgentNotConfiguredError', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
});
it('rejects whitespace-only MCP configuration', async () => {
process.env.OPAX_MCP_URL = ' ';
process.env.MCP_SECRET = DUMMY_SECRET;
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = ' ';
await assert.rejects(
() => callAgent(DUMMY_TOOL, {}),
AgentNotConfiguredError,
);
});
it('rejects invalid and unsafe URLs before fetch', async () => {
const unsafeUrls = [
'not a URL',
'ftp://127.0.0.1:12345',
'http://example.com',
'http://user:password@127.0.0.1:12345',
'http://127.0.0.1:12345#fragment',
];
for (const url of unsafeUrls) {
process.env.OPAX_MCP_URL = url;
process.env.MCP_SECRET = DUMMY_SECRET;
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, {})),
);
}
});
it('rejects HTTPS without an allowed origin before fetch', async () => {
process.env.OPAX_MCP_URL = 'https://example.invalid';
process.env.MCP_SECRET = DUMMY_SECRET;
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, {})),
);
});
it('rejects HTTPS with a mismatched origin before fetch', async () => {
process.env.OPAX_MCP_URL = 'https://example.invalid';
process.env.MCP_SECRET = DUMMY_SECRET;
process.env.OPAX_MCP_ALLOWED_ORIGIN = 'https://allowed.invalid';
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, {})),
);
});
it('rejects non-allowlisted and mutating tools before fetch', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = DUMMY_SECRET;
for (const tool of ['unknown_tool', 'commit_and_push_files']) {
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(tool, {})),
);
}
});
it('rejects circular arguments before fetch', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = DUMMY_SECRET;
const circular = {};
circular.self = circular;
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, circular)),
);
});
it('rejects an oversized request before fetch', async () => {
process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345';
process.env.MCP_SECRET = DUMMY_SECRET;
const largeArguments = {
data: 'a'.repeat(17000),
};
await expectNoFetch(() =>
expectGenericFailure(() => callAgent(DUMMY_TOOL, largeArguments)),
);
});
});
describe('local MCP mock', () => {
let server;
let serverUrl;
let handler;
before(async () => {
server = createServer((request, response) => {
Promise.resolve(handler?.(request, response))
.catch(() => {
if (!response.headersSent) {
response.writeHead(500);
}
response.end();
});
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
serverUrl = `http://127.0.0.1:${address.port}`;
});
beforeEach(() => {
configureLocalMock(serverUrl);
handler = (_request, response) => {
response.writeHead(500);
response.end();
};
});
after(async () => {
await new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});
it('returns parsed inner JSON from a valid JSON-RPC response', async () => {
const expected = {
ok: true,
status: 'healthy',
};
handler = (_request, response) => {
sendJson(response, 200, {
jsonrpc: '2.0',
id: 'mock-response-id',
result: {
content: [{
type: 'text',
text: JSON.stringify(expected),
}],
},
});
};
const result = await callAgent(DUMMY_TOOL, {
check: true,
});
assert.deepEqual(result, expected);
});
it('sends the required JSON-RPC request contract', async () => {
const argumentsValue = {
scope: 'local-test',
};
let received;
handler = async (request, response) => {
let body = '';
for await (const chunk of request) {
body += chunk;
}
received = {
method: request.method,
contentType: request.headers['content-type'],
authorization: request.headers.authorization,
body: JSON.parse(body),
};
sendJson(response, 200, {
jsonrpc: '2.0',
id: received.body.id,
result: {
content: [{
type: 'text',
text: '{}',
}],
},
});
};
await callAgent(DUMMY_TOOL, argumentsValue);
assert.equal(received.method, 'POST');
assert.equal(received.contentType, 'application/json');
assert.equal(
received.authorization,
`Bearer ${DUMMY_SECRET}`,
);
assert.equal(received.body.jsonrpc, '2.0');
assert.equal(received.body.method, 'tools/call');
assert.equal(typeof received.body.id, 'string');
assert.ok(received.body.id.length > 0);
assert.equal(received.body.params.name, DUMMY_TOOL);
assert.deepEqual(
received.body.params.arguments,
argumentsValue,
);
});
it('normalizes an HTTP error', async () => {
handler = (_request, response) => {
response.writeHead(500);
response.end();
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
it('normalizes malformed outer JSON', async () => {
handler = (_request, response) => {
response.writeHead(200, {
'Content-Type': 'application/json',
});
response.end('not-json');
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
it('normalizes a JSON-RPC error', async () => {
handler = (_request, response) => {
sendJson(response, 200, {
jsonrpc: '2.0',
id: 'mock-response-id',
error: {
code: -32000,
message: 'mock failure',
},
});
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
it('normalizes invalid content responses', async () => {
const invalidResponses = [
{
result: {
content: [],
},
},
{
result: {
content: [{
type: 'image',
text: '{}',
}],
},
},
{
result: {
content: [{
type: 'text',
text: 'not-json',
}],
},
},
];
for (const invalidResponse of invalidResponses) {
handler = (_request, response) => {
sendJson(response, 200, {
jsonrpc: '2.0',
id: 'mock-response-id',
...invalidResponse,
});
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
}
});
it('normalizes a response body larger than 1 MiB', async () => {
handler = (_request, response) => {
response.writeHead(200, {
'Content-Type': 'text/plain',
});
response.end('a'.repeat(MAX_RESPONSE_SIZE + 1));
};
await expectGenericFailure(() => callAgent(DUMMY_TOOL, {}));
});
});
});

View File

@ -0,0 +1,70 @@
import { Router } from 'express';
import { callAgent } from '../agentClient.js';
const router = Router();
const unavailable = {
status: 'unavailable',
summary: null,
message: 'Unavailable',
};
const adaptHealthSummary = (raw) => {
if (raw?.status !== 'ok' || !raw.service || !raw.version) {
return null;
}
return {
status: raw.status,
service: raw.service,
version: raw.version,
};
};
router.get('/status', async (req, res) => {
let health;
try {
const rawHealth = await callAgent('get_health', {});
const summary = adaptHealthSummary(rawHealth);
if (summary) {
health = { status: 'success', summary, message: 'Available' };
} else {
health = unavailable;
console.warn('[Console API] Platform health data has an unexpected shape.');
}
} catch (error) {
console.warn('[Console API] Platform health data is unavailable.');
health = unavailable;
}
res.json({
health,
buildStatus: unavailable,
platformState: unavailable,
});
});
router.get('/projects', (req, res) => {
res.json({
status: 'unavailable',
message: 'No project registry configured yet.',
projects: [],
});
});
router.get('/work-queue', (req, res) => {
res.json({
status: 'unavailable',
message: 'No ticket read model configured yet.',
tickets: [],
});
});
router.get('/approvals', (req, res) => {
res.json({
status: 'unavailable',
message: 'No approval read model configured yet.',
approvals: [],
});
});
export default router;

122
opax-web/backend/server.js Normal file
View File

@ -0,0 +1,122 @@
import { callAgent } from './agentClient.js';
import { registerBrowserSocketHandlers } from './socketHandlers.js';
import 'dotenv/config';
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import { OAuth2Client } from 'google-auth-library';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import path from 'path';
import { fileURLToPath } from 'url';
import consoleApiRouter from './routes/consoleApi.js';
const app = express();
const http = createServer(app);
const front = process.env.FRONTEND_URL || 'http://localhost:5173';
const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean));
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const frontendDistPath = path.join(__dirname, '../frontend/dist');
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1);
}
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret || (sessionSecret === 'CHANGE_ME' && process.env.NODE_ENV !== 'production')) {
throw new Error('A valid SESSION_SECRET must be set.');
}
const sm = session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax'
},
});
const io = new Server(http, { cors: { origin: front, credentials: true } });
app.use(cors({ origin: front, credentials: true }));
app.use(sm);
app.use(express.static(frontendDistPath));
io.use((s, n) => sm(s.request, {}, n));
app.get('/health', (_, r) => r.json({ status: 'ok' }));
app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' })));
app.get('/auth/callback', async (req, res, next) => {
try {
let { tokens } = await oauth.getToken(req.query.code);
let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload();
if (!p?.email || !emails.has(p.email)) return res.status(403).send('Not authorised');
req.session.user = { email: p.email, name: p.name || p.email };
res.redirect('/');
} catch (e) {
next(e);
}
});
app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.status(401).end()));
app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true })));
const ensureAuthenticated = (req, res, next) => {
if (req.session.user) {
return next();
}
res.status(401).end();
};
app.use('/api/console', ensureAuthenticated, consoleApiRouter);
io.on('connection', (s) => {
const u = s.request.session?.user;
if (!u) return s.disconnect();
registerBrowserSocketHandlers(s, { callAgentFn: callAgent });
});
app.get('*' , (req, res) => {
if (req.method !== 'GET') {
return res.status(404).end();
}
const isApiRoute = /^\/(auth|socket\.io|health|api)(\/.*|\/?)$/.test(req.path);
if (isApiRoute) {
return res.status(404).end();
}
if (req.accepts('html')) {
res.sendFile(frontendIndexPath, (err) => {
if (err && !res.headersSent) {
res.status(404).end();
}
});
} else {
if (!res.headersSent) {
res.status(404).end();
}
}
});
// Central Error Handler
app.use((err, req, res, next) => {
// TODO: Integrate with an external error tracking service like Sentry
console.error(`[Express Error] Path: ${req.path}, Error:`, err);
// Avoid leaking stack trace to client
if (res.headersSent) {
return next(err);
}
res.status(500).json({ error: 'Internal Server Error' });
});
http.listen(Number(process.env.PORT || 8080), '0.0.0.0');

View File

@ -0,0 +1,40 @@
// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT)
const SAFE_ACTIONS = {};
/**
* Registrerer sikre, nettleser-eksponerte Socket.IO-handlere.
* @param {import('socket.io').Socket} socket - Den tilkoblede socketen.
* @param {{callAgentFn: function}} dependencies - Injisert avhengighet for å kalle MCP.
*/
export function registerBrowserSocketHandlers(socket, { callAgentFn }) {
// Avvis den gamle, usikre mcp-call-handleren
socket.on('mcp-call', (payload) => {
const safeId = (payload && typeof payload.id === 'string' && payload.id.length <= 128) ? payload.id : null;
socket.emit('mcp-result', {
id: safeId,
error: 'Denne funksjonen er ikke tilgjengelig.',
});
});
// Registrer de nye, sikre handlerne
for (const eventName in SAFE_ACTIONS) {
const config = SAFE_ACTIONS[eventName];
socket.on(eventName, async () => {
if (!socket.request.session?.user) {
return socket.emit(`${config.responseEvent}:error`, { message: 'Du må være logget inn.' });
}
try {
const result = await callAgentFn(config.tool, {});
const normalizedData = config.normalize(result);
socket.emit(`${config.responseEvent}:result`, { data: normalizedData });
} catch (e) {
console.error(`[Socket Handler Error] Event: ${eventName}, User: ${socket.request.session.user.email}, Error:`, e);
// e.message logges ikke eller sendes til klienten.
socket.emit(`${config.responseEvent}:error`, { message: config.errorMessage });
}
});
}
}

View File

@ -0,0 +1,204 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { registerBrowserSocketHandlers } from './socketHandlers.js';
// --- Test-hjelpere ---
class FakeSocket {
constructor(user = null) {
this.request = { session: { user } };
this.handlers = new Map();
this.emitted = [];
}
on(eventName, handler) {
this.handlers.set(eventName, handler);
}
emit(eventName, payload) {
this.emitted.push({ eventName, payload });
}
async trigger(eventName, payload) {
const handler = this.handlers.get(eventName);
if (handler) {
await handler(payload);
}
}
findEmitted(eventName) {
return this.emitted.find(e => e.eventName === eventName);
}
}
function createFakeCallAgent() {
const fakeCallAgent = async (tool, args) => {
fakeCallAgent.calls.push({ tool, args });
if (fakeCallAgent.nextError) {
throw fakeCallAgent.nextError;
}
return fakeCallAgent.nextResult;
};
fakeCallAgent.calls = [];
fakeCallAgent.nextResult = null;
fakeCallAgent.nextError = null;
fakeCallAgent.setNextResult = (result) => {
fakeCallAgent.nextResult = result;
fakeCallAgent.nextError = null;
};
fakeCallAgent.setNextError = (error) => {
fakeCallAgent.nextError = error;
fakeCallAgent.nextResult = null;
};
return fakeCallAgent;
}
// --- Tester ---
describe('registerBrowserSocketHandlers', () => {
let fakeSocket;
let fakeCallAgent;
beforeEach(() => {
fakeCallAgent = createFakeCallAgent();
});
describe('for unauthenticated user', () => {
beforeEach(() => {
fakeSocket = new FakeSocket(null); // Ingen bruker i session
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('git:getStatus is rejected and does not call agent', async () => {
await fakeSocket.trigger('git:getStatus');
const errorEvent = fakeSocket.findEmitted('git:status:error');
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, 1);
});
it('git:getBranch is rejected and does not call agent', async () => {
await fakeSocket.trigger('git:getBranch');
const errorEvent = fakeSocket.findEmitted('git:branch:error');
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, 1);
});
});
describe('for authenticated user', () => {
beforeEach(() => {
fakeSocket = new FakeSocket({ email: 'test@example.com' });
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('git:getStatus calls agent correctly and returns normalized data', async () => {
const mcpResult = { status: 'dirty', details: 'M file.txt', secret: 'should-be-stripped' };
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getStatus');
assert.strictEqual(fakeCallAgent.calls.length, 1);
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_status', args: {} });
const resultEvent = fakeSocket.findEmitted('git:status:result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload.data, { status: 'dirty', details: 'M file.txt' });
});
it('git:getBranch calls agent correctly and returns normalized data', async () => {
const mcpResult = { current_branch: 'main', url: 'https://internal.example.invalid' };
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getBranch');
assert.strictEqual(fakeCallAgent.calls.length, 1);
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_branch', args: {} });
const resultEvent = fakeSocket.findEmitted('git:branch:result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload.data, { current_branch: 'main' });
});
it('emits static error if git:getStatus agent response is malformed', async () => {
const malformedResults = [null, {}, [], 'a string', { status: 123, details: 'a' }];
for (const mcpResult of malformedResults) {
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getStatus');
}
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:status:error'));
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente Git-status.'));
});
it('emits static error if git:getBranch agent response is malformed', async () => {
const malformedResults = [null, {}, [], 'a string', { current_branch: 123 }];
for (const mcpResult of malformedResults) {
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getBranch');
}
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:branch:error'));
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente aktiv branch.'));
});
it('masks agent error for git:getStatus and returns static message', async () => {
const internalError = new Error('Contains test-secret-must-not-leak');
fakeCallAgent.setNextError(internalError);
await fakeSocket.trigger('git:getStatus');
const errorEvent = fakeSocket.findEmitted('git:status:error');
assert.ok(errorEvent, 'Expected an error event');
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente Git-status.' });
assert.strictEqual(fakeSocket.findEmitted('git:status:result'), undefined);
});
it('masks agent error for git:getBranch and returns static message', async () => {
const internalError = new Error('Another secret for https://internal.example.invalid');
fakeCallAgent.setNextError(internalError);
await fakeSocket.trigger('git:getBranch');
const errorEvent = fakeSocket.findEmitted('git:branch:error');
assert.ok(errorEvent, 'Expected an error event');
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente aktiv branch.' });
assert.strictEqual(fakeSocket.findEmitted('git:branch:result'), undefined);
});
});
describe('legacy mcp-call handler', () => {
beforeEach(() => {
fakeSocket = new FakeSocket({ email: 'test@example.com' });
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('rejects calls and does not call agent', async () => {
const payload = { id: 'test-id', tool: 'commit_and_push_files', args: { message: 'evil' } };
await fakeSocket.trigger('mcp-call', payload);
assert.strictEqual(fakeCallAgent.calls.length, 0);
const resultEvent = fakeSocket.findEmitted('mcp-result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload, { id: 'test-id', error: 'Denne funksjonen er ikke tilgjengelig.' });
});
it('handles malformed payloads gracefully and uses null id', async () => {
const malformed = [null, 'a string', { id: 'a'.repeat(129) }];
for (const payload of malformed) {
await fakeSocket.trigger('mcp-call', payload);
}
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, malformed.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'mcp-result'));
assert.ok(fakeSocket.emitted.every(e => e.payload.id === null));
});
});
});

View File

@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>

View File

@ -0,0 +1,39 @@
import React, { useState, useEffect } from 'react';
import OperationsConsole from './pages/OperationsConsole';
interface User {
email: string;
name?: string;
}
function App() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetch('/auth/me', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then(setUser);
}, []);
if (!user) {
return (
<main className="login">
<h1>OPAX</h1>
<a href="/auth/google">Continue with Google</a>
</main>
);
}
return (
<main>
<header>
<b>OPAX / Operations Console</b>
<span>{user.email}</span>
</header>
<OperationsConsole />
</main>
);
}
export default App;

View File

@ -0,0 +1,15 @@
import React from 'react';
const AgentConsoleView = () => {
return (
<div className="console-card">
<h2>Agent Console</h2>
<div className="workflow-diagram">
<span>Draft</span> <span>Plan</span> <span>Scope Approval</span> <span>Implementation</span> <span>Diff Approval</span> <span>Validation</span> <span>Deployment Approval</span> <span>Deploy</span>
</div>
<p className="unavailable-message">Ticket-bound agent execution is not configured yet.</p>
</div>
);
};
export default AgentConsoleView;

View File

@ -0,0 +1,12 @@
import React from 'react';
const ApprovalsView = () => {
return (
<div className="console-card">
<h2>Approvals</h2>
<p className="unavailable-message">No approval read model configured yet.</p>
</div>
);
};
export default ApprovalsView;

View File

@ -0,0 +1,78 @@
import React from 'react';
type ConsoleStatus = 'success' | 'unavailable' | 'loading';
type SafeHealthSummary = {
status: 'ok';
service: string;
version: string;
};
type ConsoleStatusItem = {
status: Exclude<ConsoleStatus, 'loading'>;
summary: SafeHealthSummary | null;
message: 'Available' | 'Unavailable';
};
interface ConsoleStatusResponse {
health?: ConsoleStatusItem;
buildStatus?: ConsoleStatusItem;
platformState?: ConsoleStatusItem;
}
interface StatusBlockProps {
title: string;
status: ConsoleStatus;
summary: SafeHealthSummary | null;
}
const StatusBlock: React.FC<StatusBlockProps> = ({ title, status, summary }) => {
return (
<div className={`status-block status-${status}`}>
<h3>{title}</h3>
{status === 'loading' && <p>Loading...</p>}
{status === 'unavailable' && <p>Unavailable</p>}
{status === 'success' && summary && (
<ul>
<li><strong>Status:</strong> {summary.status}</li>
<li><strong>Service:</strong> {summary.service}</li>
<li><strong>Version:</strong> {summary.version}</li>
</ul>
)}
</div>
);
};
interface PlatformStatusProps {
statusData: ConsoleStatusResponse | null;
loading: boolean;
}
const unavailableStatus: ConsoleStatus = 'unavailable';
const PlatformStatus: React.FC<PlatformStatusProps> = ({ statusData, loading }) => {
return (
<div className="console-card">
<h2>Platform Overview</h2>
<div className="status-container">
<StatusBlock
title="Health"
status={loading ? 'loading' : statusData?.health?.status ?? unavailableStatus}
summary={statusData?.health?.summary ?? null}
/>
<StatusBlock
title="Latest Build"
status={loading ? 'loading' : statusData?.buildStatus?.status ?? unavailableStatus}
summary={null} // Schema not confirmed, so always null for now
/>
<StatusBlock
title="Platform State"
status={loading ? 'loading' : statusData?.platformState?.status ?? unavailableStatus}
summary={null} // Schema not confirmed, so always null for now
/>
</div>
</div>
);
};
export default PlatformStatus;

View File

@ -0,0 +1,12 @@
import React from 'react';
const ProjectRegistryView = () => {
return (
<div className="console-card">
<h2>Projects</h2>
<p className="unavailable-message">No project registry configured yet.</p>
</div>
);
};
export default ProjectRegistryView;

View File

@ -0,0 +1,12 @@
import React from 'react';
const WorkQueueView = () => {
return (
<div className="console-card">
<h2>Work Queue</h2>
<p className="unavailable-message">No ticket read model configured yet.</p>
</div>
);
};
export default WorkQueueView;

View File

@ -0,0 +1 @@
import React from'react';import{createRoot}from'react-dom/client';import App from'./App';import'./style.css';createRoot(document.getElementById('root')!).render(<App/>);

View File

@ -0,0 +1,138 @@
import React, { useState, useEffect, useCallback } from 'react';
import PlatformStatus from '../components/PlatformStatus';
import ProjectRegistryView from '../components/ProjectRegistryView';
import WorkQueueView from '../components/WorkQueueView';
import ApprovalsView from '../components/ApprovalsView';
import AgentConsoleView from '../components/AgentConsoleView';
// --- Types for Safe Data Handling ---
type ConsoleStatus = 'success' | 'unavailable';
type SafeHealthSummary = {
status: 'ok';
service: string;
version: string;
};
type ConsoleStatusItem = {
status: ConsoleStatus;
summary: SafeHealthSummary | null;
message: 'Available' | 'Unavailable';
};
export interface ConsoleStatusResponse {
health: ConsoleStatusItem;
buildStatus: ConsoleStatusItem;
platformState: ConsoleStatusItem;
}
const unavailableItem: ConsoleStatusItem = {
status: 'unavailable',
summary: null,
message: 'Unavailable',
};
// --- Normalizer Function ---
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isHealthSummary(value: unknown): value is SafeHealthSummary {
return (
isRecord(value) &&
value.status === 'ok' &&
typeof value.service === 'string' &&
typeof value.version === 'string'
);
}
function normalizeStatusResponse(raw: unknown): ConsoleStatusResponse {
let health: ConsoleStatusItem = unavailableItem;
if (isRecord(raw) && isRecord(raw.health)) {
const healthItem = raw.health;
if (
healthItem.status === 'success' &&
isHealthSummary(healthItem.summary)
) {
health = {
status: 'success',
summary: {
status: 'ok',
service: healthItem.summary.service,
version: healthItem.summary.version,
},
message: 'Available',
};
}
}
return {
health,
buildStatus: unavailableItem,
platformState: unavailableItem,
};
}
// --- Component ---
const OperationsConsole = () => {
const [statusData, setStatusData] = useState<ConsoleStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefreshed, setLastRefreshed] = useState<string>('');
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch('/api/console/status', { credentials: 'include' });
if (!res.ok) {
// Handle auth errors or server errors safely
if (res.status === 401) {
throw new Error('Access denied. Please log in again.');
} else {
throw new Error('Console data is unavailable.');
}
}
const rawData = await res.json();
setStatusData(normalizeStatusResponse(rawData));
setLastRefreshed(new Date().toLocaleTimeString());
} catch {
setError('Console data is unavailable. Please try again.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
return (
<div className="console-page">
<div className="page-header">
<h1>OPAX Operations Console</h1>
<div className="header-actions">
<span>Last refreshed: {lastRefreshed || 'never'}</span>
<button onClick={fetchData} disabled={loading} className="primary">
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{error && <p className="error">{error}</p>}
<PlatformStatus statusData={statusData} loading={loading} />
<ProjectRegistryView />
<WorkQueueView />
<ApprovalsView />
<AgentConsoleView />
</div>
);
};
export default OperationsConsole;

View File

@ -0,0 +1,165 @@
:root {
--font-family: monospace;
--background-color: #0d1117;
--text-color: #e6edf3;
--text-color-secondary: #8b949e;
--border-color: #30363d;
--container-background: #161b22;
--button-background: #21262d;
--button-background-hover: #30363d;
--button-primary-background: #238636;
--button-primary-background-hover: #2ea043;
--error-color: #f85149;
--spacing-unit: 16px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--background-color);
color: var(--text-color);
font-family: var(--font-family);
font-size: 14px;
}
main {
height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: var(--spacing-unit);
background-color: var(--container-background);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
}
header span {
color: var(--text-color-secondary);
}
.content-wrapper {
padding: calc(var(--spacing-unit) * 2);
max-width: 800px;
margin: 0 auto;
width: 100%;
}
.page-header {
margin-bottom: calc(var(--spacing-unit) * 2);
text-align: center;
}
.page-header h1 {
font-size: 24px;
margin-bottom: calc(var(--spacing-unit) / 2);
}
.page-header p {
font-size: 16px;
color: var(--text-color-secondary);
max-width: 600px;
margin: 0 auto;
}
.actions-container {
display: grid;
grid-template-columns: 1fr;
gap: calc(var(--spacing-unit) * 2);
}
@media (min-width: 768px) {
.actions-container {
grid-template-columns: 1fr 1fr;
}
}
.action-card {
background-color: var(--container-background);
border: 1px solid var(--border-color);
padding: var(--spacing-unit);
border-radius: 6px;
}
.action-card h2 {
margin-top: 0;
font-size: 18px;
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--spacing-unit);
margin-bottom: var(--spacing-unit);
}
button {
width: 100%;
background: var(--button-background);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 10px var(--spacing-unit);
border-radius: 6px;
font-family: inherit;
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s;
}
button:hover:not(:disabled) {
background-color: var(--button-background-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button.primary {
background-color: var(--button-primary-background);
border-color: var(--button-primary-background-hover);
}
button.primary:hover:not(:disabled) {
background-color: var(--button-primary-background-hover);
}
.result-display {
margin-top: var(--spacing-unit);
padding: var(--spacing-unit);
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 6px;
white-space: pre-wrap;
word-wrap: break-word;
font-size: 13px;
min-height: 100px;
}
.result-display strong {
color: var(--text-color-secondary);
}
.error {
color: var(--error-color);
margin-top: calc(var(--spacing-unit) / 2);
font-size: 13px;
}
.login {
height: 100vh;
display: grid;
place-content: center;
gap: 20px;
text-align: center;
}
.login a {
color: #58a6ff;
font-size: 18px;
}

View File

@ -0,0 +1 @@
import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});