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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.git
.github
.DS_Store
.npmrc
.cloudflared
.cloudflared/**
.env
.env.*
Comment thread
coderabbitai[bot] marked this conversation as resolved.
*.pem
*.key
*.db
*.sqlite
*.sqlite3
node_modules
dist
coverage
*.log
docs
test
README.md
LICENSE
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,28 @@ jobs:
- run: npm ci
- run: npm run check
- run: npm run build

container:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build smoke image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
load: true
tags: proofgate:smoke
- name: Start smoke container
run: >-
docker run --detach --name proofgate-smoke
--read-only --tmpfs /tmp:size=16m,mode=1777
--cap-drop ALL --security-opt no-new-privileges:true
--publish 4173:4173 proofgate:smoke
- name: Verify health endpoint
run: curl --fail --retry 10 --retry-all-errors --retry-delay 1 http://127.0.0.1:4173/api/health
- name: Stop smoke container
if: always()
run: docker rm --force proofgate-smoke
50 changes: 50 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# syntax=docker/dockerfile:1

FROM --platform=$BUILDPLATFORM node:26-trixie-slim AS build

WORKDIR /build

COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

COPY tsconfig.json tsconfig.build.json ./
COPY src ./src
COPY public ./public
RUN npm run build
RUN npm prune --omit=dev --ignore-scripts

FROM node:26-trixie-slim AS runtime

LABEL org.opencontainers.image.title="ProofGate" \
org.opencontainers.image.description="Read-only release and experiment control room built with concurrent Mozaik agents" \
org.opencontainers.image.url="https://github.com/fortemate/proofgate" \
org.opencontainers.image.source="https://github.com/fortemate/proofgate" \
org.opencontainers.image.documentation="https://github.com/fortemate/proofgate#readme" \
org.opencontainers.image.vendor="Fortemate" \
org.opencontainers.image.licenses="MIT" \
org.opencontainers.image.authors="Jegors Čemisovs" \
org.opencontainers.image.base.name="docker.io/library/node:26-trixie-slim"

RUN groupadd --system --gid 10001 app \
&& useradd --system --uid 10001 --gid app app

WORKDIR /app

COPY --from=build --chown=app:app /build/package.json /build/package-lock.json ./
COPY --from=build --chown=app:app /build/node_modules ./node_modules
COPY --from=build --chown=app:app /build/dist ./dist
COPY --from=build --chown=app:app /build/public ./public

USER app

ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4173 \
MAX_CONCURRENT_RUNS=2

EXPOSE 4173

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:' + process.env.PORT + '/api/health').then((response) => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));"]

CMD ["node", "dist/server.js"]
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,41 @@ npm run demo -- blocked
npm run demo -- failure
```

## Public demo deployment

The Control Room can run as a public, synthetic-only demo behind a
[named Cloudflare Tunnel](https://developers.cloudflare.com/tunnel/setup/).
The tunnel provides the public HTTPS hostname while the origin remains bound to
the homelab loopback interface; no router port needs to be opened.

```mermaid
flowchart LR
J[Judge browser] -->|HTTPS| C[Cloudflare edge]
C -->|named Tunnel| T[cloudflared]
T -->|http://127.0.0.1:4173| P[ProofGate container]
```

Build and start the hardened, non-root container:

```bash
docker compose up --build --detach
curl --fail --retry 10 --retry-all-errors --retry-delay 1 \
http://127.0.0.1:4173/api/health
```

Create a **named** Cloudflare Tunnel route from the chosen public hostname to
`http://localhost:4173`. Keep the application publicly reachable for judges;
do not place Cloudflare Access authentication in front of the submitted demo.

The container accepts at most two evaluations at once by default. Additional
requests receive `429 Too Many Requests` instead of building an unbounded
queue. Override `MAX_CONCURRENT_RUNS` only after testing the homelab capacity.

> [!CAUTION]
> Do not provide `MOZAIK_API_KEY`, pairing credentials, production connectors,
> or private Fortemate data to the public deployment. Mozaik Cloud traces used
> for judging remain available in the recorded demo and screenshots.

Add `--json` to inspect the redacted event timeline, evidence contract, case
digest, and three loop IDs:

Expand Down
27 changes: 27 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
services:
proofgate:
build:
context: .
image: proofgate:local
restart: unless-stopped
environment:
HOST: 0.0.0.0
PORT: 4173
MAX_CONCURRENT_RUNS: 2
ports:
- '127.0.0.1:4173:4173'
read_only: true
tmpfs:
- /tmp:size=16m,mode=1777
mem_limit: 512m
cpus: 1.0
pids_limit: 128
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
logging:
driver: json-file
options:
max-size: 10m
max-file: '3'
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"start": "tsx src/server.ts",
"start:prod": "node dist/server.js",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
Expand Down
169 changes: 137 additions & 32 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,41 @@ import { runProofGate } from './proofgate.js';

const publicDirectory = fileURLToPath(new URL('../public/', import.meta.url));
const maximumBodyBytes = 4_096;
const defaultMaximumConcurrentRuns = 2;

export interface ControlRoomServerOptions {
readonly maximumConcurrentRuns?: number;
}

class RunCapacity {
private activeRuns = 0;

public constructor(private readonly maximumConcurrentRuns: number) {
if (!Number.isInteger(maximumConcurrentRuns) || maximumConcurrentRuns < 1) {
throw new Error('maximumConcurrentRuns must be a positive integer');
}
}

public tryAcquire(): boolean {
if (this.activeRuns >= this.maximumConcurrentRuns) return false;
this.activeRuns += 1;
return true;
}

public release(): void {
this.activeRuns -= 1;
}

public snapshot(): {
readonly activeRuns: number;
readonly maximumConcurrentRuns: number;
} {
return {
activeRuns: this.activeRuns,
maximumConcurrentRuns: this.maximumConcurrentRuns,
};
}
}

const assets = new Map([
['/', { file: 'index.html', contentType: 'text/html; charset=utf-8' }],
Expand Down Expand Up @@ -83,41 +118,64 @@ async function serveAsset(
return true;
}

async function handleRequest(
async function handleRunRequest(
request: IncomingMessage,
response: ServerResponse,
runCapacity: RunCapacity,
): Promise<void> {
const url = new URL(request.url ?? '/', 'http://localhost');

if (request.method === 'GET' && url.pathname === '/api/health') {
sendJson(response, 200, { status: 'ok', mode: 'synthetic-read-only' });
if (!request.headers['content-type']?.startsWith('application/json')) {
sendJson(response, 415, {
error: 'content-type must be application/json',
});
return;
}

if (request.method === 'POST' && url.pathname === '/api/runs') {
if (!request.headers['content-type']?.startsWith('application/json')) {
sendJson(response, 415, {
error: 'content-type must be application/json',
try {
const body = (await readJsonBody(request)) as { fixture?: unknown };
if (!body || typeof body !== 'object' || typeof body.fixture !== 'string') {
throw new Error('fixture must be a string');
}

const fixture = getFixture(body.fixture);
if (!runCapacity.tryAcquire()) {
response.setHeader('Retry-After', '1');
sendJson(response, 429, {
error: 'too many evaluations in progress',
});
return;
}

try {
const body = (await readJsonBody(request)) as { fixture?: unknown };
if (
!body ||
typeof body !== 'object' ||
typeof body.fixture !== 'string'
) {
throw new Error('fixture must be a string');
}
const result = await runProofGate(getFixture(body.fixture));
const result = await runProofGate(fixture);
sendJson(response, 200, result);
} catch (error) {
sendJson(response, 400, {
error: error instanceof Error ? error.message : 'invalid request',
});
} finally {
runCapacity.release();
}
} catch (error) {
sendJson(response, 400, {
error: error instanceof Error ? error.message : 'invalid request',
});
}
}

async function handleRequest(
request: IncomingMessage,
response: ServerResponse,
runCapacity: RunCapacity,
): Promise<void> {
const url = new URL(request.url ?? '/', 'http://localhost');

if (request.method === 'GET' && url.pathname === '/api/health') {
sendJson(response, 200, {
status: 'ok',
mode: 'synthetic-read-only',
...runCapacity.snapshot(),
});
return;
}

if (request.method === 'POST' && url.pathname === '/api/runs') {
await handleRunRequest(request, response, runCapacity);
return;
}

Expand All @@ -128,16 +186,23 @@ async function handleRequest(
sendJson(response, 404, { error: 'not found' });
}

export function createControlRoomServer() {
export function createControlRoomServer(
options: ControlRoomServerOptions = {},
) {
const runCapacity = new RunCapacity(
options.maximumConcurrentRuns ?? defaultMaximumConcurrentRuns,
);
return createServer((request, response) => {
void handleRequest(request, response).catch((error: unknown) => {
console.error(error);
if (!response.headersSent) {
sendJson(response, 500, { error: 'internal server error' });
} else {
response.destroy();
}
});
void handleRequest(request, response, runCapacity).catch(
(error: unknown) => {
console.error(error);
if (!response.headersSent) {
sendJson(response, 500, { error: 'internal server error' });
} else {
response.destroy();
}
},
);
});
}

Expand All @@ -150,16 +215,56 @@ function parsePort(value: string | undefined): number {
return port;
}

function parseMaximumConcurrentRuns(value: string | undefined): number {
if (value === undefined) return defaultMaximumConcurrentRuns;
const maximumConcurrentRuns = Number(value);
if (!Number.isInteger(maximumConcurrentRuns) || maximumConcurrentRuns < 1) {
throw new Error('MAX_CONCURRENT_RUNS must be a positive integer');
}
return maximumConcurrentRuns;
}

function installShutdownHandlers(
server: ReturnType<typeof createControlRoomServer>,
): void {
let shuttingDown = false;
const shutdown = (signal: NodeJS.Signals): void => {
if (shuttingDown) return;
shuttingDown = true;
console.log(`Received ${signal}; closing the Control Room.`);

const deadline = setTimeout(() => {
console.error('Graceful shutdown timed out.');
process.exit(1);
}, 5_000);
deadline.unref();

server.close((error) => {
clearTimeout(deadline);
if (error) console.error(error);
process.exit(error ? 1 : 0);
});
};

process.once('SIGTERM', () => shutdown('SIGTERM'));
process.once('SIGINT', () => shutdown('SIGINT'));
}

const entrypoint = process.argv[1]
? pathToFileURL(resolve(process.argv[1])).href
: undefined;

if (import.meta.url === entrypoint) {
const host = process.env.HOST ?? '127.0.0.1';
const port = parsePort(process.env.PORT);
const server = createControlRoomServer();
const maximumConcurrentRuns = parseMaximumConcurrentRuns(
process.env.MAX_CONCURRENT_RUNS,
);
const server = createControlRoomServer({ maximumConcurrentRuns });
installShutdownHandlers(server);
server.listen(port, host, () => {
console.log(`ProofGate Control Room: http://${host}:${port}`);
console.log(`Concurrent evaluation limit: ${maximumConcurrentRuns}`);
console.log(
'Synthetic evidence only. No production actions are available.',
);
Expand Down
Loading