feat(opax-web): import P1A Operations Console
This commit is contained in:
parent
fceec08823
commit
429cc95c9a
15
opax-web/.dockerignore
Normal file
15
opax-web/.dockerignore
Normal 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
5
opax-web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
.env
|
||||
dist/
|
||||
|
||||
backend/.env
|
||||
50
opax-web/Dockerfile
Normal file
50
opax-web/Dockerfile
Normal 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"]
|
||||
55
opax-web/README-opax-web.md
Normal file
55
opax-web/README-opax-web.md
Normal 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
41
opax-web/README.md
Normal 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
14
opax-web/SECURITY.md
Normal 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.
|
||||
7
opax-web/backend/.env.example
Normal file
7
opax-web/backend/.env.example
Normal 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
|
||||
139
opax-web/backend/agentClient.js
Normal file
139
opax-web/backend/agentClient.js
Normal 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);
|
||||
}
|
||||
}
|
||||
403
opax-web/backend/agentClient.test.js
Normal file
403
opax-web/backend/agentClient.test.js
Normal 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, {}));
|
||||
});
|
||||
});
|
||||
});
|
||||
70
opax-web/backend/routes/consoleApi.js
Normal file
70
opax-web/backend/routes/consoleApi.js
Normal 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
122
opax-web/backend/server.js
Normal 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');
|
||||
40
opax-web/backend/socketHandlers.js
Normal file
40
opax-web/backend/socketHandlers.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
204
opax-web/backend/socketHandlers.test.js
Normal file
204
opax-web/backend/socketHandlers.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
1
opax-web/frontend/index.html
Normal file
1
opax-web/frontend/index.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||
39
opax-web/frontend/src/App.tsx
Normal file
39
opax-web/frontend/src/App.tsx
Normal 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;
|
||||
|
||||
15
opax-web/frontend/src/components/AgentConsoleView.tsx
Normal file
15
opax-web/frontend/src/components/AgentConsoleView.tsx
Normal 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;
|
||||
12
opax-web/frontend/src/components/ApprovalsView.tsx
Normal file
12
opax-web/frontend/src/components/ApprovalsView.tsx
Normal 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;
|
||||
78
opax-web/frontend/src/components/PlatformStatus.tsx
Normal file
78
opax-web/frontend/src/components/PlatformStatus.tsx
Normal 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;
|
||||
12
opax-web/frontend/src/components/ProjectRegistryView.tsx
Normal file
12
opax-web/frontend/src/components/ProjectRegistryView.tsx
Normal 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;
|
||||
12
opax-web/frontend/src/components/WorkQueueView.tsx
Normal file
12
opax-web/frontend/src/components/WorkQueueView.tsx
Normal 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;
|
||||
1
opax-web/frontend/src/main.tsx
Normal file
1
opax-web/frontend/src/main.tsx
Normal 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/>);
|
||||
138
opax-web/frontend/src/pages/OperationsConsole.tsx
Normal file
138
opax-web/frontend/src/pages/OperationsConsole.tsx
Normal 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;
|
||||
165
opax-web/frontend/src/style.css
Normal file
165
opax-web/frontend/src/style.css
Normal 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;
|
||||
}
|
||||
1
opax-web/frontend/vite.config.ts
Normal file
1
opax-web/frontend/vite.config.ts
Normal 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}}}});
|
||||
Loading…
Reference in New Issue
Block a user