feat: route public api traffic through managed gateway

Replace the 3-service compose with a 5-service architecture that puts
api-gateway.cjs (hermes-pre-api, hermes-post-api) in front of internal
upstream services (hermes-pre-upstream, hermes-post-upstream), ensuring
all public API traffic passes through the auth/audit layer. Update
Dockerfile, .env.example, .dockerignore, and add a compose contract test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 02:12:05 -06:00
parent c86254667b
commit dd5318368e
5 changed files with 158 additions and 12 deletions
+1
View File
@@ -3,6 +3,7 @@
*.log
*.tgz
*.zip
.superpowers/
coverage/
node_modules/
state/
+10 -1
View File
@@ -2,10 +2,19 @@ HERMES_CONTAINER_USER=0:0
HERMES_SETUP_UI_PORT=7843
HERMES_PRE_AI_API_PORT=8645
HERMES_PRE_AI_PROVIDER=nous
HERMES_POST_AI_API_PORT=8646
HERMES_PRE_AI_PROVIDER=nous
HERMES_POST_AI_PROVIDER=nous
HERMES_HOME_HOST=/opt/hermes-control-plane/hermes
CODEX_HOME_HOST=/opt/hermes-control-plane/codex
CLAUDE_HOME_HOST=/opt/hermes-control-plane/claude
GEMINI_HOME_HOST=/opt/hermes-control-plane/gemini
DATABASE_URL=postgresql://hermes_user:change-me@postgres.example.internal:5432/hermes_control_plane
HERMES_ADMIN_USERNAME=admin
HERMES_ADMIN_PASSWORD=change-this-to-a-long-random-password
HERMES_ADMIN_SESSION_TTL_HOURS=12
HERMES_LOG_RETENTION_DAYS=90
HERMES_AUDIT_MAX_BYTES=10485760
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY index.html app.js style.css server.cjs README.md ./
COPY index.html app.js style.css server.cjs api-gateway.cjs README.md ./
COPY login.html login.js login.css ./
COPY lib/ ./lib/
COPY migrations/ ./migrations/
+68 -10
View File
@@ -46,25 +46,30 @@ services:
<<: *hermes-environment
HERMES_SETUP_UI_HOST: 0.0.0.0
HERMES_SETUP_UI_PORT: 7843
DATABASE_URL: ${DATABASE_URL}
HERMES_ADMIN_USERNAME: ${HERMES_ADMIN_USERNAME}
HERMES_ADMIN_PASSWORD: ${HERMES_ADMIN_PASSWORD}
HERMES_ADMIN_SESSION_TTL_HOURS: ${HERMES_ADMIN_SESSION_TTL_HOURS:-12}
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
volumes: *hermes-volumes
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:7843/api/paths').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:7843/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
hermes-pre-ai-api:
hermes-pre-upstream:
build: *hermes-build
image: hermes-control-plane:local
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
expose:
- "8645"
command:
- /bin/sh
- -lc
- exec "$$HERMES_EXE" proxy start --provider "$$HERMES_PRE_AI_PROVIDER" --host 0.0.0.0 --port 8645
ports:
- "${HERMES_PRE_AI_API_PORT:-8645}:8645"
environment:
<<: *hermes-environment
HERMES_PRE_AI_PROVIDER: ${HERMES_PRE_AI_PROVIDER:-nous}
@@ -76,24 +81,77 @@ services:
retries: 3
start_period: 20s
hermes-post-ai-api:
hermes-post-upstream:
build: *hermes-build
image: hermes-control-plane:local
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
expose:
- "8642"
command:
- /bin/sh
- -lc
- exec "$$HERMES_EXE" proxy start --provider "$$HERMES_POST_AI_PROVIDER" --host 0.0.0.0 --port 8646
- exec "$$HERMES_EXE" gateway run --replace --accept-hooks
environment:
<<: *hermes-environment
API_SERVER_ENABLED: "true"
API_SERVER_HOST: 0.0.0.0
API_SERVER_PORT: 8642
HERMES_POST_AI_PROVIDER: ${HERMES_POST_AI_PROVIDER:-nous}
volumes: *hermes-volumes
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8642/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
hermes-pre-api:
build: *hermes-build
image: hermes-control-plane:local
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/api-gateway.cjs"]
ports:
- "${HERMES_PRE_AI_API_PORT:-8645}:8645"
environment:
DATABASE_URL: ${DATABASE_URL}
HERMES_API_ROUTE_KIND: pre
HERMES_API_GATEWAY_HOST: 0.0.0.0
HERMES_API_GATEWAY_PORT: 8645
HERMES_UPSTREAM_URL: http://hermes-pre-upstream:8645
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
HERMES_AUDIT_MAX_BYTES: ${HERMES_AUDIT_MAX_BYTES:-10485760}
depends_on:
- hermes-pre-upstream
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8645/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
hermes-post-api:
build: *hermes-build
image: hermes-control-plane:local
user: ${HERMES_CONTAINER_USER:-0:0}
restart: unless-stopped
command: ["node", "/app/api-gateway.cjs"]
ports:
- "${HERMES_POST_AI_API_PORT:-8646}:8646"
environment:
<<: *hermes-environment
HERMES_POST_AI_PROVIDER: ${HERMES_POST_AI_PROVIDER:-nous}
volumes: *hermes-volumes
DATABASE_URL: ${DATABASE_URL}
HERMES_API_ROUTE_KIND: post
HERMES_API_GATEWAY_HOST: 0.0.0.0
HERMES_API_GATEWAY_PORT: 8646
HERMES_UPSTREAM_URL: http://hermes-post-upstream:8642
HERMES_LOG_RETENTION_DAYS: ${HERMES_LOG_RETENTION_DAYS:-90}
HERMES_AUDIT_MAX_BYTES: ${HERMES_AUDIT_MAX_BYTES:-10485760}
depends_on:
- hermes-post-upstream
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:8646/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
start_period: 10s
+78
View File
@@ -0,0 +1,78 @@
"use strict"
const test = require("node:test")
const assert = require("node:assert/strict")
const { execSync } = require("child_process")
const path = require("path")
const root = path.join(__dirname, "..")
function getCompose() {
try {
const output = execSync(
"docker compose --env-file .env.example config --format json",
{ cwd: root, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
)
return JSON.parse(output)
} catch (err) {
return null
}
}
test("compose config is valid and has correct service structure", (t) => {
const config = getCompose()
if (!config) {
t.skip("docker compose not available or config invalid")
return
}
const services = config.services || {}
const serviceNames = Object.keys(services)
// Must have exactly these 5 services
assert(serviceNames.includes("hermes-control-plane"), "missing hermes-control-plane")
assert(serviceNames.includes("hermes-pre-upstream"), "missing hermes-pre-upstream")
assert(serviceNames.includes("hermes-post-upstream"), "missing hermes-post-upstream")
assert(serviceNames.includes("hermes-pre-api"), "missing hermes-pre-api")
assert(serviceNames.includes("hermes-post-api"), "missing hermes-post-api")
// Upstream services must NOT have host ports
const preUpstream = services["hermes-pre-upstream"]
const postUpstream = services["hermes-post-upstream"]
const preApi = services["hermes-pre-api"]
const postApi = services["hermes-post-api"]
assert(!preUpstream.ports || preUpstream.ports.length === 0, "hermes-pre-upstream should not publish host ports")
assert(!postUpstream.ports || postUpstream.ports.length === 0, "hermes-post-upstream should not publish host ports")
// Public gateway services must publish correct ports
assert(preApi.ports && preApi.ports.length > 0, "hermes-pre-api should publish ports")
assert(postApi.ports && postApi.ports.length > 0, "hermes-post-api should publish ports")
// Post upstream must have API_SERVER_ENABLED=true
const postUpstreamEnv = postUpstream.environment || {}
assert.equal(String(postUpstreamEnv.API_SERVER_ENABLED), "true", "hermes-post-upstream API_SERVER_ENABLED must be true")
// Control plane and gateway services must receive DATABASE_URL
const controlPlaneEnv = services["hermes-control-plane"].environment || {}
assert("DATABASE_URL" in controlPlaneEnv, "hermes-control-plane must have DATABASE_URL")
const preApiEnv = preApi.environment || {}
assert("DATABASE_URL" in preApiEnv, "hermes-pre-api must have DATABASE_URL")
const postApiEnv = postApi.environment || {}
assert("DATABASE_URL" in postApiEnv, "hermes-post-api must have DATABASE_URL")
// Admin variables only go to control plane
assert("HERMES_ADMIN_USERNAME" in controlPlaneEnv, "hermes-control-plane must have HERMES_ADMIN_USERNAME")
assert(!("HERMES_ADMIN_USERNAME" in preApiEnv), "hermes-pre-api must NOT have HERMES_ADMIN_USERNAME")
assert(!("HERMES_ADMIN_USERNAME" in postApiEnv), "hermes-post-api must NOT have HERMES_ADMIN_USERNAME")
// Gateway services run api-gateway.cjs
const preApiCommand = preApi.command
const postApiCommand = postApi.command
const preApiCmd = Array.isArray(preApiCommand) ? preApiCommand.join(" ") : String(preApiCommand || "")
const postApiCmd = Array.isArray(postApiCommand) ? postApiCommand.join(" ") : String(postApiCommand || "")
assert.match(preApiCmd, /api-gateway\.cjs/, "hermes-pre-api must run api-gateway.cjs")
assert.match(postApiCmd, /api-gateway\.cjs/, "hermes-post-api must run api-gateway.cjs")
})