From 9677c455b3399b0f0e165831a437d732b725c4b0 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 15 May 2026 00:47:59 +0300 Subject: [PATCH] OPS - GATEWAY: prepare Ops Agents Synology deploy --- .env.example | 3 +- .env.synology.example | 15 ++ .gitignore | 2 +- Dockerfile | 1 + README.md | 4 +- docker-compose.local.yml | 4 +- docker-compose.synology.yml | 47 +++++ docs/ARCHITECTURE.md | 4 +- docs/SYNOLOGY_DEPLOY.md | 105 +++++++++++ docs/UX_FLOW.md | 5 +- migrations/003_token_suffix.sql | 2 + src/app.ts | 2 + src/assets/nodedc-logo.svg | 1 + src/assets/ops-agent-error.svg | 86 +++++++++ src/config.ts | 2 +- src/repositories/agents.ts | 19 +- src/routes/agents.ts | 30 +++- src/routes/health.ts | 10 +- src/routes/mcp.ts | 11 +- src/routes/public.ts | 304 ++++++++++++++++++++++++++++++++ 20 files changed, 620 insertions(+), 37 deletions(-) create mode 100644 .env.synology.example create mode 100644 docker-compose.synology.yml create mode 100644 docs/SYNOLOGY_DEPLOY.md create mode 100644 migrations/003_token_suffix.sql create mode 100644 src/assets/nodedc-logo.svg create mode 100644 src/assets/ops-agent-error.svg create mode 100644 src/routes/public.ts diff --git a/.env.example b/.env.example index cb3f193..e38f09d 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ NODE_ENV=development HOST=0.0.0.0 PORT=4100 +HOST_PORT=4100 LOG_LEVEL=info -NODEDC_AGENT_GATEWAY_PUBLIC_URL=http://agents.local.nodedc +NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-gateway-internal-token NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc diff --git a/.env.synology.example b/.env.synology.example new file mode 100644 index 0000000..f1505ef --- /dev/null +++ b/.env.synology.example @@ -0,0 +1,15 @@ +NODE_ENV=production +HOST=0.0.0.0 +PORT=4100 +HOST_PORT=18190 +LOG_LEVEL=info + +NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru +NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN=replace-with-strong-gateway-internal-token +NODEDC_LAUNCHER_INTERNAL_URL=http://127.0.0.1:18080 +NODEDC_TASKER_INTERNAL_URL=http://127.0.0.1:18090 +NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-platform-internal-access-token + +POSTGRES_DB=nodedc_agent_gateway +POSTGRES_USER=nodedc_agent_gateway +POSTGRES_PASSWORD=replace-with-strong-postgres-password diff --git a/.gitignore b/.gitignore index 0eb7429..d89f1ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ dist/ .env .env.* !.env.example +!.env.synology.example coverage/ .DS_Store npm-debug.log* - diff --git a/Dockerfile b/Dockerfile index 6e41dd4..6102d56 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist ./dist +COPY --from=build /app/src/assets ./dist/assets COPY migrations ./migrations COPY docker-entrypoint.sh ./docker-entrypoint.sh RUN chmod +x ./docker-entrypoint.sh diff --git a/README.md b/README.md index b86fad4..7e99321 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ docker compose --env-file .env -f docker-compose.local.yml up -d --build curl http://127.0.0.1:4100/readyz ``` -The `agent-gateway` container waits for local Postgres, runs migrations on startup, and exposes the same `:4100` endpoint used by Tasker (`PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100`). +The `agent-gateway` container waits for local Postgres, runs migrations on startup, and exposes the same `:4100` internal endpoint used by Tasker (`PLANE_NODEDC_AGENT_GATEWAY_URL=http://host.docker.internal:4100`). `HOST_PORT` controls the host-side port for reverse proxy deployments; Synology should use `docker-compose.synology.yml` with `127.0.0.1:18190:4100` because `18090` is reserved for Tasker. The user-facing setup packet uses `NODEDC_AGENT_GATEWAY_PUBLIC_URL`; product defaults point to `https://ops-agents.nodedc.ru`, not localhost. + +Synology deployment notes live in `docs/SYNOLOGY_DEPLOY.md`. Direct Node.js development: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6e1d538..0248923 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -25,7 +25,7 @@ services: PORT: ${PORT:-4100} LOG_LEVEL: ${LOG_LEVEL:-info} DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-local-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} - NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-http://localhost:4100} + NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN:-local-dev-codex-agent-gateway-token-change-me} NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://launcher.local.nodedc} NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://task.local.nodedc} @@ -38,7 +38,7 @@ services: - "launcher.local.nodedc:host-gateway" - "task.local.nodedc:host-gateway" ports: - - "${PORT:-4100}:${PORT:-4100}" + - "${HOST_PORT:-4100}:${PORT:-4100}" healthcheck: test: [ diff --git a/docker-compose.synology.yml b/docker-compose.synology.yml new file mode 100644 index 0000000..e2d7631 --- /dev/null +++ b/docker-compose.synology.yml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:17-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-nodedc_agent_gateway} + POSTGRES_USER: ${POSTGRES_USER:-nodedc_agent_gateway} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-replace-with-strong-postgres-password} + volumes: + - agent-gateway-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 10 + + agent-gateway: + build: + context: . + init: true + environment: + NODE_ENV: ${NODE_ENV:-production} + HOST: 0.0.0.0 + PORT: ${PORT:-4100} + LOG_LEVEL: ${LOG_LEVEL:-info} + DATABASE_URL: postgres://${POSTGRES_USER:-nodedc_agent_gateway}:${POSTGRES_PASSWORD:-replace-with-strong-postgres-password}@postgres:5432/${POSTGRES_DB:-nodedc_agent_gateway} + NODEDC_AGENT_GATEWAY_PUBLIC_URL: ${NODEDC_AGENT_GATEWAY_PUBLIC_URL:-https://ops-agents.nodedc.ru} + NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: ${NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN} + NODEDC_LAUNCHER_INTERNAL_URL: ${NODEDC_LAUNCHER_INTERNAL_URL:-http://127.0.0.1:18080} + NODEDC_TASKER_INTERNAL_URL: ${NODEDC_TASKER_INTERNAL_URL:-http://127.0.0.1:18090} + NODEDC_INTERNAL_ACCESS_TOKEN: ${NODEDC_INTERNAL_ACCESS_TOKEN} + depends_on: + postgres: + condition: service_healthy + ports: + - "127.0.0.1:${HOST_PORT:-18190}:${PORT:-4100}" + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:' + (process.env.PORT || 4100) + '/readyz').then(async r => { const b = await r.json(); process.exit(r.ok && b.ok ? 0 : 1); }).catch(() => process.exit(1))\"", + ] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + agent-gateway-postgres: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d68f67e..66c0b33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -249,13 +249,13 @@ This mode is policy-visible but cannot be fully enforced if Codex runs entirely Initial local target: ```text -agents.local.nodedc -> Agent Gateway +ops-agents.local.nodedc -> Agent Gateway ``` Production-like domains should follow platform conventions: ```text -agents.nodedc.ru or agents. +ops-agents.nodedc.ru or ops-agents. ``` The service should support: diff --git a/docs/SYNOLOGY_DEPLOY.md b/docs/SYNOLOGY_DEPLOY.md new file mode 100644 index 0000000..9e7b648 --- /dev/null +++ b/docs/SYNOLOGY_DEPLOY.md @@ -0,0 +1,105 @@ +# Synology deploy checklist + +This service is the NODE.DC Operational Agents Gateway for Tasker/Operational Core MCP traffic. + +## Network model + +- Public URL: `https://ops-agents.nodedc.ru`. +- Synology reverse proxy: `HTTPS 443` → `HTTP 127.0.0.1:18190`. +- Container app port stays `4100`. +- Docker host port is controlled by `HOST_PORT=18190`. +- Do not use `18090` for this module: that host port is reserved by Tasker / Operational Core. +- No router changes are required if `443` already reaches Synology and Synology owns the reverse proxy rule. + +## Required env + +Create `.env` from `.env.synology.example` and replace every `replace-with-*` value: + +```env +NODE_ENV=production +HOST=0.0.0.0 +PORT=4100 +HOST_PORT=18190 +LOG_LEVEL=info + +NODEDC_AGENT_GATEWAY_PUBLIC_URL=https://ops-agents.nodedc.ru +NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN= +NODEDC_LAUNCHER_INTERNAL_URL= +NODEDC_TASKER_INTERNAL_URL= +NODEDC_INTERNAL_ACCESS_TOKEN= + +POSTGRES_DB=nodedc_agent_gateway +POSTGRES_USER=nodedc_agent_gateway +POSTGRES_PASSWORD= +``` + +`NODEDC_TASKER_INTERNAL_URL` and `NODEDC_LAUNCHER_INTERNAL_URL` must be reachable from the Synology host/container. If Tasker and Launcher are still local-only on a workstation, deploy the gateway next to them or expose a private internal route first. + +## Backup before deploy + +Create a timestamped backup directory on the Synology host: + +```bash +export BACKUP_DIR="$HOME/nodedc-backups/ops-agents-$(date +%Y%m%d-%H%M%S)" +mkdir -p "$BACKUP_DIR" +``` + +Backup current compose/env files if the service already exists: + +```bash +cp -a docker-compose.synology.yml .env "$BACKUP_DIR"/ 2>/dev/null || true +docker compose --env-file .env -f docker-compose.synology.yml ps > "$BACKUP_DIR/compose-ps.txt" 2>/dev/null || true +``` + +Backup database if Postgres is already running: + +```bash +docker compose --env-file .env -f docker-compose.synology.yml exec -T postgres \ + pg_dump -U "${POSTGRES_USER:-nodedc_agent_gateway}" "${POSTGRES_DB:-nodedc_agent_gateway}" \ + > "$BACKUP_DIR/postgres.sql" +``` + +## Deploy + +```bash +docker compose --env-file .env -f docker-compose.synology.yml pull +docker compose --env-file .env -f docker-compose.synology.yml up -d --build +``` + +If the repository is deployed from source and not from a registry image, `up -d --build` is enough. The production compose does not publish Postgres and binds the gateway to `127.0.0.1:18190`; DSM reverse proxy should target that local address. + +## Verification + +Local host checks: + +```bash +curl -fsS http://127.0.0.1:18190/healthz +curl -fsS http://127.0.0.1:18190/readyz +curl -fsS -i http://127.0.0.1:18190/mcp | head +``` + +Public checks after DNS/reverse proxy: + +```bash +curl -fsS https://ops-agents.nodedc.ru/healthz +curl -fsS https://ops-agents.nodedc.ru/readyz +curl -fsS -i https://ops-agents.nodedc.ru/mcp | head +``` + +Expected behavior: + +- `/` returns the public NODE.DC Operational Agents Gateway page. +- `GET /mcp` returns a browser-safe informational page with HTTP `405`. +- `POST /mcp` is the real MCP JSON-RPC endpoint and requires `Authorization: Bearer `. +- `/readyz` does not expose internal Launcher/Tasker URLs or token configuration. + +## Tasker integration + +Tasker must call the gateway by internal URL: + +```env +PLANE_NODEDC_AGENT_GATEWAY_URL=http://:18190 +PLANE_NODEDC_AGENT_GATEWAY_TOKEN= +``` + +After changing Tasker backend env, rebuild/restart the Tasker backend runtime so workspace settings can create agents and tokens through the gateway. diff --git a/docs/UX_FLOW.md b/docs/UX_FLOW.md index 7a47a41..aa67fab 100644 --- a/docs/UX_FLOW.md +++ b/docs/UX_FLOW.md @@ -118,12 +118,13 @@ The UI should provide: - show allowed workspace/project list; - show revoke button. -The token itself should be shown once and never stored in frontend state longer than needed. +The token itself should be returned once, shown masked in the active setup packet, and never returned by the backend again. +The frontend may keep the full secret only in the current browser session for copy actions until reload/navigation. Recommended user-facing artifact: ```text -TASKER_AGENT.md +OPS_AGENT.md ``` This file contains instructions, not the raw secret. Secrets should be passed through environment variables or Codex secret storage. diff --git a/migrations/003_token_suffix.sql b/migrations/003_token_suffix.sql new file mode 100644 index 0000000..f29a399 --- /dev/null +++ b/migrations/003_token_suffix.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent_tokens +ADD COLUMN IF NOT EXISTS token_suffix text; diff --git a/src/app.ts b/src/app.ts index 7af1551..9ccc21e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerHealthRoutes } from "./routes/health.js"; import { registerMcpRoutes } from "./routes/mcp.js"; +import { registerPublicRoutes } from "./routes/public.js"; import { registerToolRoutes } from "./routes/tools.js"; import { ForbiddenError } from "./security/authorization.js"; import { UnauthorizedError } from "./security/bearer.js"; @@ -124,6 +125,7 @@ export async function buildApp(config: AppConfig): Promise { }); }); + await registerPublicRoutes(app); await registerHealthRoutes(app, config, pool); await registerAgentRoutes(app, { agentsRepository, diff --git a/src/assets/nodedc-logo.svg b/src/assets/nodedc-logo.svg new file mode 100644 index 0000000..92b19d8 --- /dev/null +++ b/src/assets/nodedc-logo.svg @@ -0,0 +1 @@ + diff --git a/src/assets/ops-agent-error.svg b/src/assets/ops-agent-error.svg new file mode 100644 index 0000000..2d69593 --- /dev/null +++ b/src/assets/ops-agent-error.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/config.ts b/src/config.ts index 34e643d..837d2e9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,7 +7,7 @@ const configSchema = z.object({ HOST: z.string().min(1).default("0.0.0.0"), PORT: z.coerce.number().int().min(1).max(65535).default(4100), LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"), - NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://agents.local.nodedc"), + NODEDC_AGENT_GATEWAY_PUBLIC_URL: z.string().url().default("http://ops-agents.local.nodedc"), NODEDC_AGENT_GATEWAY_INTERNAL_TOKEN: z.string().min(1).optional(), NODEDC_LAUNCHER_INTERNAL_URL: z.string().url().default("http://launcher.local.nodedc"), NODEDC_TASKER_INTERNAL_URL: z.string().url().default("http://task.local.nodedc"), diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts index 1b4d04b..627ce40 100644 --- a/src/repositories/agents.ts +++ b/src/repositories/agents.ts @@ -39,6 +39,7 @@ export type AgentTokenRecord = { agentId: string; name: string; status: AgentTokenStatus; + tokenSuffix: string | null; expiresAt: string | null; lastUsedAt: string | null; createdAt: string; @@ -78,6 +79,7 @@ type TokenRow = { agent_id: string; name: string; status: AgentTokenStatus; + token_suffix: string | null; expires_at: Date | null; last_used_at: Date | null; created_at: Date; @@ -87,6 +89,7 @@ type SessionRow = AgentRow & { token_id: string; token_name: string; token_status: AgentTokenStatus; + token_suffix: string | null; token_expires_at: Date | null; token_last_used_at: Date | null; token_created_at: Date; @@ -274,11 +277,11 @@ export class AgentsRepository { async createToken(agentId: string, input: CreateTokenInput): Promise { const result = await this.pool.query( ` - INSERT INTO agent_tokens(agent_id, token_hash, name, expires_at) - VALUES ($1, $2, $3, $4) - RETURNING id, agent_id, name, status, expires_at, last_used_at, created_at + INSERT INTO agent_tokens(agent_id, token_hash, token_suffix, name, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at `, - [agentId, hashAgentToken(input.token), input.name, input.expiresAt ?? null] + [agentId, hashAgentToken(input.token), input.token.slice(-8), input.name, input.expiresAt ?? null] ); await this.createAuditEvent(agentId, "agent.token.created", undefined, { @@ -293,7 +296,7 @@ export class AgentsRepository { async listTokens(agentId: string): Promise { const result = await this.pool.query( ` - SELECT id, agent_id, name, status, expires_at, last_used_at, created_at + SELECT id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at FROM agent_tokens WHERE agent_id = $1 ORDER BY created_at DESC @@ -309,7 +312,7 @@ export class AgentsRepository { UPDATE agent_tokens SET status = 'revoked' WHERE agent_id = $1 AND id = $2 - RETURNING id, agent_id, name, status, expires_at, last_used_at, created_at + RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at `, [agentId, tokenId] ); @@ -337,6 +340,7 @@ export class AgentsRepository { agent_tokens.id AS token_id, agent_tokens.name AS token_name, agent_tokens.status AS token_status, + agent_tokens.token_suffix AS token_suffix, agent_tokens.expires_at AS token_expires_at, agent_tokens.last_used_at AS token_last_used_at, agent_tokens.created_at AS token_created_at @@ -361,7 +365,7 @@ export class AgentsRepository { UPDATE agent_tokens SET last_used_at = now() WHERE id = $1 - RETURNING id, agent_id, name, status, expires_at, last_used_at, created_at + RETURNING id, agent_id, name, status, token_suffix, expires_at, last_used_at, created_at `, [row.token_id] ); @@ -520,6 +524,7 @@ function mapToken(row: TokenRow): AgentTokenRecord { agentId: row.agent_id, name: row.name, status: row.status, + tokenSuffix: row.token_suffix, expiresAt: row.expires_at?.toISOString() ?? null, lastUsedAt: row.last_used_at?.toISOString() ?? null, createdAt: row.created_at.toISOString(), diff --git a/src/routes/agents.ts b/src/routes/agents.ts index b782607..6cf6f2f 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -16,6 +16,26 @@ type AgentRouteDeps = { internalAccessToken?: string; }; +const MAX_AGENT_AVATAR_URL_LENGTH = 400_000; + +function isAllowedAvatarUrl(value: string): boolean { + if (value.startsWith("data:image/")) { + return /^data:image\/(png|jpeg|jpg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(value); + } + + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +const agentAvatarUrlSchema = z + .string() + .max(MAX_AGENT_AVATAR_URL_LENGTH) + .refine(isAllowedAvatarUrl, "avatar_url must be http(s) URL or image data URL"); + const agentParamsSchema = z.object({ agentId: z.string().uuid(), }); @@ -44,18 +64,18 @@ const createAgentBodySchema = z.object({ owner_user_id: z.string().min(1), owner_email: z.string().email().nullish(), display_name: z.string().min(1).max(120), - avatar_url: z.string().url().nullish(), + avatar_url: agentAvatarUrlSchema.nullish(), }); const createOwnerAgentBodySchema = z.object({ owner_email: z.string().email().nullish(), display_name: z.string().min(1).max(120), - avatar_url: z.string().url().nullish(), + avatar_url: agentAvatarUrlSchema.nullish(), }); const updateOwnerAgentBodySchema = z.object({ display_name: z.string().min(1).max(120).optional(), - avatar_url: z.string().url().nullable().optional(), + avatar_url: agentAvatarUrlSchema.nullable().optional(), actor_user_id: z.string().min(1).optional(), }); @@ -559,6 +579,7 @@ function serializeToken(token: AgentTokenRecord): Record { agent_id: token.agentId, name: token.name, status: token.status, + token_suffix: token.tokenSuffix, expires_at: token.expiresAt, last_used_at: token.lastUsedAt, created_at: token.createdAt, @@ -622,6 +643,7 @@ function buildSetupPacketForAgent(agent: AgentRecord, grants: AgentGrantRecord[] agentId: agent.id, name: "Agent token placeholder", status: "active", + tokenSuffix: null, expiresAt: null, lastUsedAt: null, createdAt: new Date(0).toISOString(), @@ -640,7 +662,7 @@ function buildAgentsMd(session: AgentSessionRecord, endpoint: string): string { .join("\n"); return [ - "# NODE.DC Tasker Agent Rules", + "# NODE.DC Ops Agent Rules", "", `MCP endpoint: ${endpoint}`, "", diff --git a/src/routes/health.ts b/src/routes/health.ts index 8eee8b7..11b520a 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -6,7 +6,7 @@ import type { AppConfig } from "../config.js"; export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig, pool: Pool | null): Promise { app.get("/healthz", async () => ({ ok: true, - service: "nodedc-tasker-codex-api", + service: "nodedc-ops-agents-gateway", })); app.get("/readyz", async () => { @@ -15,13 +15,7 @@ export async function registerHealthRoutes(app: FastifyInstance, config: AppConf return { ok, - service: "nodedc-tasker-codex-api", - dependencies: { - database, - launcher: config.NODEDC_LAUNCHER_INTERNAL_URL, - tasker: config.NODEDC_TASKER_INTERNAL_URL, - internal_token: config.NODEDC_INTERNAL_ACCESS_TOKEN ? "configured" : "not_configured", - }, + service: "nodedc-ops-agents-gateway", }; }); } diff --git a/src/routes/mcp.ts b/src/routes/mcp.ts index 4611a23..98848be 100644 --- a/src/routes/mcp.ts +++ b/src/routes/mcp.ts @@ -8,6 +8,7 @@ import type { AgentsRepository } from "../repositories/agents.js"; import { ForbiddenError } from "../security/authorization.js"; import { UnauthorizedError } from "../security/bearer.js"; import type { TaskerClient } from "../tasker/client.js"; +import { sendPublicPage } from "./public.js"; import { authenticateAgent } from "./session.js"; const MCP_PROTOCOL_VERSION = "2025-06-18"; @@ -48,13 +49,7 @@ const toolsCallParamsSchema = z.object({ }); export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps): Promise { - app.get("/mcp", async (_request, reply) => - reply.status(405).send({ - ok: false, - error: "sse_not_supported", - message: "This MCP endpoint supports JSON-RPC over HTTP POST. Server-sent events are not enabled.", - }) - ); + app.get("/mcp", async (_request, reply) => sendPublicPage(reply, "mcp")); app.delete("/mcp", async (_request, reply) => reply.status(405).send({ ok: false, error: "sessions_not_stateful" })); @@ -87,7 +82,7 @@ export async function registerMcpRoutes(app: FastifyInstance, deps: McpRouteDeps }, }, serverInfo: { - name: "nodedc-tasker-codex-api", + name: "nodedc-ops-agents-gateway", version: "0.1.0", }, }, diff --git a/src/routes/public.ts b/src/routes/public.ts new file mode 100644 index 0000000..3b5be8a --- /dev/null +++ b/src/routes/public.ts @@ -0,0 +1,304 @@ +import { readFile } from "node:fs/promises"; + +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +const heroAssetPath = new URL("../assets/ops-agent-error.svg", import.meta.url); +const logoAssetPath = new URL("../assets/nodedc-logo.svg", import.meta.url); +let heroImageCache: Buffer | null = null; +let logoImageCache: Buffer | null = null; + +type PublicPageVariant = "home" | "mcp" | "not-found"; + +type PublicPageContent = { + buttonHref?: string; + buttonLabel?: string; + eyebrow: string; + statusCode: number; + text: string; + title: string; +}; + +const pageContent: Record = { + home: { + buttonHref: "/mcp", + buttonLabel: "MCP endpoint", + eyebrow: "NODE.DC Operational Agents", + statusCode: 200, + title: "Operational Agents Gateway", + text: "Служебный gateway для подключения MCP-клиентов к Operational Core. Для работы нужен agent token из настроек workspace.", + }, + mcp: { + eyebrow: "MCP endpoint", + statusCode: 405, + title: "Этот endpoint работает через MCP-клиент", + text: "Браузер здесь ничего не настраивает. Подключайте Codex или другой MCP-клиент через HTTP POST JSON-RPC с Bearer token агента.", + }, + "not-found": { + buttonHref: "/", + buttonLabel: "К gateway", + eyebrow: "NODE.DC Operational Agents", + statusCode: 404, + title: "Endpoint не найден", + text: "Проверьте адрес. Для MCP-подключения используется /mcp, для проверки доступности — /healthz.", + }, +}; + +export async function registerPublicRoutes(app: FastifyInstance): Promise { + app.get("/", async (_request, reply) => sendPublicPage(reply, "home")); + + app.get("/assets/ops-agent-error.svg", async (_request, reply) => { + heroImageCache ??= await readFile(heroAssetPath); + return reply.header("Cache-Control", "public, max-age=86400").type("image/svg+xml").send(heroImageCache); + }); + + app.get("/assets/nodedc-logo.svg", async (_request, reply) => { + logoImageCache ??= await readFile(logoAssetPath); + return reply.header("Cache-Control", "public, max-age=86400").type("image/svg+xml").send(logoImageCache); + }); + + app.setNotFoundHandler((request, reply) => { + if (wantsHtml(request) && !request.url.startsWith("/api/")) { + return sendPublicPage(reply, "not-found"); + } + + return reply.status(404).send({ + ok: false, + error: "not_found", + }); + }); +} + +export function sendPublicPage(reply: FastifyReply, variant: PublicPageVariant): FastifyReply { + const content = pageContent[variant]; + return reply + .status(content.statusCode) + .type("text/html; charset=utf-8") + .header("Cache-Control", "no-store") + .send(renderPublicPage(content)); +} + +function wantsHtml(request: FastifyRequest): boolean { + const accept = request.headers.accept; + return !accept || accept.includes("text/html") || accept.includes("*/*"); +} + +function renderPublicPage(content: PublicPageContent): string { + const button = content.buttonHref + ? `${escapeHtml(content.buttonLabel ?? "Open")}` + : ""; + + return ` + + + + + + ${escapeHtml(content.title)} · NODE.DC + + + +
+
+ + + +
+
+
+
+
+
+
+ +
+
+

${escapeHtml(content.eyebrow)}

+

${escapeHtml(content.title)}

+

${escapeHtml(content.text)}

+
+ ${button ? `
${button}
` : ""} +
+
+ +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +}