diff --git a/.env.example b/.env.example index 62b9239..02a3fc7 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token -# Phase 1 can run without DB. Phase 2 will require it. -DATABASE_URL= +POSTGRES_DB=nodedc_agent_gateway +POSTGRES_USER=nodedc_agent_gateway +POSTGRES_PASSWORD=replace-with-local-postgres-password +POSTGRES_PORT=54100 +DATABASE_URL=postgres://nodedc_agent_gateway:replace-with-local-postgres-password@localhost:54100/nodedc_agent_gateway diff --git a/Dockerfile b/Dockerfile index f0673ef..3013e01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY package*.json tsconfig.json ./ COPY src ./src +COPY migrations ./migrations RUN npm run build FROM node:24-alpine AS runtime @@ -15,7 +16,7 @@ WORKDIR /app ENV NODE_ENV=production COPY --from=deps /app/node_modules ./node_modules COPY --from=build /app/dist ./dist +COPY migrations ./migrations COPY package*.json ./ EXPOSE 4100 CMD ["node", "dist/server.js"] - diff --git a/README.md b/README.md index 0bdf3bd..fadda45 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,40 @@ External Codex instances never receive Plane session cookies, raw Tasker API tokens, database access, or a generic HTTP proxy into Tasker. All writes go through NODE.DC Agent Gateway, are scoped by agent grants, and are recorded as actions of a dedicated agent identity owned by a human platform user. + +## Current implementation + +- Fastify service with `/healthz`, `/readyz`, and capability metadata. +- Postgres migrations for agents, grants, token hashes, pairing codes, audit events, and idempotency keys. +- Internal REST endpoints for agent profile, grant, and token lifecycle. +- Opaque agent tokens are generated once and stored only as SHA-256 hashes. +- MCP and Tasker write execution are documented but not implemented yet. + +## Local development + +```bash +cp .env.example .env +docker compose --env-file .env -f docker-compose.local.yml up -d postgres +npm install +npm run migrate +npm run dev +``` + +Useful checks: + +```bash +npm run check +npm run build +curl http://127.0.0.1:4100/readyz +curl http://127.0.0.1:4100/api/v1/meta/capabilities +``` + +Create a local test agent: + +```bash +curl -X POST http://127.0.0.1:4100/api/v1/agents \ + -H 'Content-Type: application/json' \ + -d '{"owner_user_id":"local-user","owner_email":"local@example.test","display_name":"Local Codex"}' +``` + +Do not expose these lifecycle endpoints publicly before the Launcher/internal auth layer is added. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 37c2811..8bdf6e3 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,9 +1,30 @@ 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-local-postgres-password} + ports: + - "${POSTGRES_PORT:-54100}:5432" + 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: . env_file: - .env + depends_on: + postgres: + condition: service_healthy ports: - "${PORT:-4100}:${PORT:-4100}" +volumes: + agent-gateway-postgres: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 24b3f9d..75707fc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -133,30 +133,37 @@ agent_grant id agent_id workspace_slug - project_id + project_id (empty string internally means workspace-level grant) scopes[] mode: voluntary | reporting created_by_user_id created_at + updated_at pairing_code id agent_id code_hash + status: active | used | expired | revoked expires_at - consumed_at + created_at + used_at agent_audit_event id agent_id - owner_user_id - operation - workspace_slug - project_id - issue_id - idempotency_key - result + event_type + actor_user_id + metadata created_at + +idempotency_key + key + agent_id + request_hash + response_body + created_at + expires_at ``` ## Actor model diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 714aff8..32c5595 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ Last updated: 2026-05-14. ## Phase 0. Architecture baseline -Status: current phase. +Status: done in `97d98a7`. Deliverables: @@ -23,6 +23,8 @@ Exit criteria: ## Phase 1. Agent Gateway skeleton +Status: in progress. Initial service, migrations, persistence endpoints, token hashing, local Postgres compose, and smoke checks are implemented. + Create standalone service with: - Dockerfile; diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..89fbd3a --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,86 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS schema_migrations ( + id text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS agents ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + owner_user_id text NOT NULL, + owner_email text, + display_name text NOT NULL, + avatar_url text, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'revoked')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agents_owner_user_id_idx ON agents(owner_user_id); +CREATE INDEX IF NOT EXISTS agents_owner_email_idx ON agents(owner_email); + +CREATE TABLE IF NOT EXISTS agent_grants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + workspace_slug text NOT NULL, + project_id text NOT NULL DEFAULT '', + scopes text[] NOT NULL DEFAULT '{}', + mode text NOT NULL DEFAULT 'voluntary' CHECK (mode IN ('voluntary', 'reporting')), + created_by_user_id text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(agent_id, workspace_slug, project_id) +); + +CREATE INDEX IF NOT EXISTS agent_grants_agent_id_idx ON agent_grants(agent_id); +CREATE INDEX IF NOT EXISTS agent_grants_workspace_slug_idx ON agent_grants(workspace_slug); + +CREATE TABLE IF NOT EXISTS agent_tokens ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + name text NOT NULL, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked', 'expired')), + expires_at timestamptz, + last_used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agent_tokens_agent_id_idx ON agent_tokens(agent_id); +CREATE INDEX IF NOT EXISTS agent_tokens_status_idx ON agent_tokens(status); + +CREATE TABLE IF NOT EXISTS pairing_codes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id uuid NOT NULL REFERENCES agents(id) ON DELETE CASCADE, + code_hash text NOT NULL UNIQUE, + status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'used', 'expired', 'revoked')), + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + used_at timestamptz +); + +CREATE INDEX IF NOT EXISTS pairing_codes_agent_id_idx ON pairing_codes(agent_id); +CREATE INDEX IF NOT EXISTS pairing_codes_status_idx ON pairing_codes(status); + +CREATE TABLE IF NOT EXISTS agent_audit_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id uuid REFERENCES agents(id) ON DELETE SET NULL, + event_type text NOT NULL, + actor_user_id text, + metadata jsonb NOT NULL DEFAULT '{}'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS agent_audit_events_agent_id_idx ON agent_audit_events(agent_id); +CREATE INDEX IF NOT EXISTS agent_audit_events_event_type_idx ON agent_audit_events(event_type); + +CREATE TABLE IF NOT EXISTS idempotency_keys ( + key text PRIMARY KEY, + agent_id uuid REFERENCES agents(id) ON DELETE CASCADE, + request_hash text NOT NULL, + response_body jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); + +CREATE INDEX IF NOT EXISTS idempotency_keys_expires_at_idx ON idempotency_keys(expires_at); diff --git a/package-lock.json b/package-lock.json index 8af441e..7cbf07f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,12 @@ "version": "0.1.0", "dependencies": { "fastify": "^5.8.5", + "pg": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^25.7.0", + "@types/pg": "^8.20.0", "tsx": "^4.22.0", "typescript": "^6.0.3" }, @@ -589,6 +591,18 @@ "undici-types": "~7.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -933,6 +947,95 @@ "node": ">=14.0.0" } }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -970,6 +1073,45 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -1185,6 +1327,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", diff --git a/package.json b/package.json index 3650b5e..fcb9cc2 100644 --- a/package.json +++ b/package.json @@ -10,16 +10,19 @@ "dev": "tsx watch src/server.ts", "build": "tsc -p tsconfig.json", "check": "tsc --noEmit -p tsconfig.json", + "migrate": "tsx src/scripts/migrate.ts", + "migrate:dist": "node dist/scripts/migrate.js", "start": "node dist/server.js" }, "dependencies": { "fastify": "^5.8.5", + "pg": "^8.20.0", "zod": "^4.4.3" }, "devDependencies": { "@types/node": "^25.7.0", + "@types/pg": "^8.20.0", "tsx": "^4.22.0", "typescript": "^6.0.3" } } - diff --git a/src/app.ts b/src/app.ts index beeecd9..8913cdb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,17 +1,44 @@ import Fastify, { type FastifyInstance } from "fastify"; +import { ZodError } from "zod"; import type { AppConfig } from "./config.js"; +import { createPool, DatabaseNotConfiguredError } from "./db/pool.js"; +import { AgentsRepository } from "./repositories/agents.js"; import { registerAgentRoutes } from "./routes/agents.js"; import { registerHealthRoutes } from "./routes/health.js"; export async function buildApp(config: AppConfig): Promise { + const pool = createPool(config); + const agentsRepository = pool ? new AgentsRepository(pool) : null; const app = Fastify({ logger: { level: config.LOG_LEVEL, }, }); + app.addHook("onClose", async () => { + await pool?.end(); + }); + app.setErrorHandler((error, _request, reply) => { + if (error instanceof ZodError) { + void reply.status(400).send({ + ok: false, + error: "validation_error", + details: error.issues, + }); + return; + } + + if (error instanceof DatabaseNotConfiguredError) { + void reply.status(503).send({ + ok: false, + error: "database_not_configured", + message: "DATABASE_URL is required for Agent Gateway persistence endpoints.", + }); + return; + } + app.log.error(error); void reply.status(500).send({ ok: false, @@ -20,9 +47,8 @@ export async function buildApp(config: AppConfig): Promise { }); }); - await registerHealthRoutes(app, config); - await registerAgentRoutes(app); + await registerHealthRoutes(app, config, pool); + await registerAgentRoutes(app, { agentsRepository }); return app; } - diff --git a/src/db/migrations.ts b/src/db/migrations.ts new file mode 100644 index 0000000..691e647 --- /dev/null +++ b/src/db/migrations.ts @@ -0,0 +1,61 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; + +import type { Pool } from "pg"; + +export type AppliedMigration = { + id: string; +}; + +type MigrationFile = { + id: string; + sql: string; +}; + +async function loadMigrationFiles(migrationsDir: string): Promise { + const files = (await fs.readdir(migrationsDir)).filter((file) => file.endsWith(".sql")).sort(); + + return Promise.all( + files.map(async (file) => ({ + id: file, + sql: await fs.readFile(path.join(migrationsDir, file), "utf8"), + })) + ); +} + +export async function runMigrations(pool: Pool, migrationsDir = path.resolve(process.cwd(), "migrations")): Promise { + const migrations = await loadMigrationFiles(migrationsDir); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id text PRIMARY KEY, + applied_at timestamptz NOT NULL DEFAULT now() + ) + `); + + const appliedRows = await client.query<{ id: string }>("SELECT id FROM schema_migrations"); + const appliedIds = new Set(appliedRows.rows.map((row) => row.id)); + const newlyApplied: AppliedMigration[] = []; + + for (const migration of migrations) { + if (appliedIds.has(migration.id)) { + continue; + } + + await client.query(migration.sql); + await client.query("INSERT INTO schema_migrations(id) VALUES ($1)", [migration.id]); + newlyApplied.push({ id: migration.id }); + } + + await client.query("COMMIT"); + return newlyApplied; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} diff --git a/src/db/pool.ts b/src/db/pool.ts new file mode 100644 index 0000000..a14eae4 --- /dev/null +++ b/src/db/pool.ts @@ -0,0 +1,21 @@ +import { Pool } from "pg"; + +import type { AppConfig } from "../config.js"; + +export class DatabaseNotConfiguredError extends Error { + constructor() { + super("DATABASE_URL is not configured."); + this.name = "DatabaseNotConfiguredError"; + } +} + +export function createPool(config: AppConfig): Pool | null { + if (!config.DATABASE_URL) { + return null; + } + + return new Pool({ + connectionString: config.DATABASE_URL, + max: 10, + }); +} diff --git a/src/repositories/agents.ts b/src/repositories/agents.ts new file mode 100644 index 0000000..90efedd --- /dev/null +++ b/src/repositories/agents.ts @@ -0,0 +1,306 @@ +import { randomUUID } from "node:crypto"; + +import type { Pool } from "pg"; + +import { allowedAgentScopes, type AgentScope } from "../domain/scopes.js"; +import { hashAgentToken } from "../security/tokens.js"; + +const allowedScopeSet = new Set(allowedAgentScopes); + +export type AgentStatus = "active" | "disabled" | "revoked"; +export type AgentGrantMode = "voluntary" | "reporting"; +export type AgentTokenStatus = "active" | "revoked" | "expired"; + +export type AgentRecord = { + id: string; + ownerUserId: string; + ownerEmail: string | null; + displayName: string; + avatarUrl: string | null; + status: AgentStatus; + createdAt: string; + updatedAt: string; +}; + +export type AgentGrantRecord = { + id: string; + agentId: string; + workspaceSlug: string; + projectId: string | null; + scopes: AgentScope[]; + mode: AgentGrantMode; + createdByUserId: string; + createdAt: string; + updatedAt: string; +}; + +export type AgentTokenRecord = { + id: string; + agentId: string; + name: string; + status: AgentTokenStatus; + expiresAt: string | null; + lastUsedAt: string | null; + createdAt: string; +}; + +type AgentRow = { + id: string; + owner_user_id: string; + owner_email: string | null; + display_name: string; + avatar_url: string | null; + status: AgentStatus; + created_at: Date; + updated_at: Date; +}; + +type GrantRow = { + id: string; + agent_id: string; + workspace_slug: string; + project_id: string; + scopes: AgentScope[]; + mode: AgentGrantMode; + created_by_user_id: string; + created_at: Date; + updated_at: Date; +}; + +type TokenRow = { + id: string; + agent_id: string; + name: string; + status: AgentTokenStatus; + expires_at: Date | null; + last_used_at: Date | null; + created_at: Date; +}; + +export type CreateAgentInput = { + ownerUserId: string; + ownerEmail?: string | null; + displayName: string; + avatarUrl?: string | null; +}; + +export type UpsertGrantInput = { + workspaceSlug: string; + projectId?: string | null; + scopes: AgentScope[]; + mode: AgentGrantMode; + createdByUserId: string; +}; + +export type CreateTokenInput = { + token: string; + name: string; + expiresAt?: string | null; +}; + +export class AgentsRepository { + constructor(private readonly pool: Pool) {} + + async createAgent(input: CreateAgentInput): Promise { + const result = await this.pool.query( + ` + INSERT INTO agents(id, owner_user_id, owner_email, display_name, avatar_url) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, + [randomUUID(), input.ownerUserId, input.ownerEmail ?? null, input.displayName, input.avatarUrl ?? null] + ); + + return mapAgent(result.rows[0]); + } + + async listAgents(ownerUserId?: string): Promise { + const result = ownerUserId + ? await this.pool.query("SELECT * FROM agents WHERE owner_user_id = $1 ORDER BY created_at DESC", [ownerUserId]) + : await this.pool.query("SELECT * FROM agents ORDER BY created_at DESC"); + + return result.rows.map(mapAgent); + } + + async getAgent(agentId: string): Promise { + const result = await this.pool.query("SELECT * FROM agents WHERE id = $1", [agentId]); + return result.rows[0] ? mapAgent(result.rows[0]) : null; + } + + async revokeAgent(agentId: string, actorUserId?: string): Promise { + const client = await this.pool.connect(); + + try { + await client.query("BEGIN"); + const result = await client.query( + ` + UPDATE agents + SET status = 'revoked', updated_at = now() + WHERE id = $1 + RETURNING * + `, + [agentId] + ); + + if (!result.rows[0]) { + await client.query("ROLLBACK"); + return null; + } + + await client.query("UPDATE agent_tokens SET status = 'revoked' WHERE agent_id = $1 AND status = 'active'", [agentId]); + await client.query( + "INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id) VALUES ($1, $2, $3)", + [agentId, "agent.revoked", actorUserId ?? null] + ); + await client.query("COMMIT"); + return mapAgent(result.rows[0]); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } + + async upsertGrant(agentId: string, input: UpsertGrantInput): Promise { + assertAllowedScopes(input.scopes); + + const projectId = input.projectId ?? ""; + const result = await this.pool.query( + ` + INSERT INTO agent_grants(agent_id, workspace_slug, project_id, scopes, mode, created_by_user_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (agent_id, workspace_slug, project_id) + DO UPDATE SET + scopes = EXCLUDED.scopes, + mode = EXCLUDED.mode, + created_by_user_id = EXCLUDED.created_by_user_id, + updated_at = now() + RETURNING * + `, + [agentId, input.workspaceSlug, projectId, input.scopes, input.mode, input.createdByUserId] + ); + + await this.createAuditEvent(agentId, "agent.grant.upserted", input.createdByUserId, { + workspaceSlug: input.workspaceSlug, + projectId: input.projectId ?? null, + scopes: input.scopes, + mode: input.mode, + }); + + return mapGrant(result.rows[0]); + } + + async listGrants(agentId: string): Promise { + const result = await this.pool.query("SELECT * FROM agent_grants WHERE agent_id = $1 ORDER BY created_at DESC", [agentId]); + return result.rows.map(mapGrant); + } + + 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 + `, + [agentId, hashAgentToken(input.token), input.name, input.expiresAt ?? null] + ); + + await this.createAuditEvent(agentId, "agent.token.created", undefined, { + tokenId: result.rows[0].id, + name: input.name, + expiresAt: input.expiresAt ?? null, + }); + + return mapToken(result.rows[0]); + } + + async listTokens(agentId: string): Promise { + const result = await this.pool.query( + ` + SELECT id, agent_id, name, status, expires_at, last_used_at, created_at + FROM agent_tokens + WHERE agent_id = $1 + ORDER BY created_at DESC + `, + [agentId] + ); + return result.rows.map(mapToken); + } + + async revokeToken(agentId: string, tokenId: string, actorUserId?: string): Promise { + const result = await this.pool.query( + ` + 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 + `, + [agentId, tokenId] + ); + + if (!result.rows[0]) { + return null; + } + + await this.createAuditEvent(agentId, "agent.token.revoked", actorUserId, { tokenId }); + return mapToken(result.rows[0]); + } + + async createAuditEvent(agentId: string | null, eventType: string, actorUserId?: string, metadata: Record = {}): Promise { + await this.pool.query( + ` + INSERT INTO agent_audit_events(agent_id, event_type, actor_user_id, metadata) + VALUES ($1, $2, $3, $4) + `, + [agentId, eventType, actorUserId ?? null, metadata] + ); + } +} + +function assertAllowedScopes(scopes: AgentScope[]): void { + const deniedScope = scopes.find((scope) => !allowedScopeSet.has(scope)); + + if (deniedScope) { + throw new Error(`Unsupported agent scope: ${deniedScope}`); + } +} + +function mapAgent(row: AgentRow): AgentRecord { + return { + id: row.id, + ownerUserId: row.owner_user_id, + ownerEmail: row.owner_email, + displayName: row.display_name, + avatarUrl: row.avatar_url, + status: row.status, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +function mapGrant(row: GrantRow): AgentGrantRecord { + return { + id: row.id, + agentId: row.agent_id, + workspaceSlug: row.workspace_slug, + projectId: row.project_id === "" ? null : row.project_id, + scopes: row.scopes, + mode: row.mode, + createdByUserId: row.created_by_user_id, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + }; +} + +function mapToken(row: TokenRow): AgentTokenRecord { + return { + id: row.id, + agentId: row.agent_id, + name: row.name, + status: row.status, + 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 681771d..fdd9d53 100644 --- a/src/routes/agents.ts +++ b/src/routes/agents.ts @@ -1,9 +1,53 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyReply } from "fastify"; +import { z } from "zod"; import { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js"; +import { DatabaseNotConfiguredError } from "../db/pool.js"; import { mcpToolDefinitions } from "../mcp/tools.js"; +import type { AgentGrantRecord, AgentRecord, AgentTokenRecord, AgentsRepository } from "../repositories/agents.js"; +import { generateAgentToken } from "../security/tokens.js"; -export async function registerAgentRoutes(app: FastifyInstance): Promise { +type AgentRouteDeps = { + agentsRepository: AgentsRepository | null; +}; + +const agentParamsSchema = z.object({ + agentId: z.string().uuid(), +}); + +const tokenParamsSchema = agentParamsSchema.extend({ + tokenId: z.string().uuid(), +}); + +const listAgentsQuerySchema = z.object({ + owner_user_id: z.string().min(1).optional(), +}); + +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(), +}); + +const upsertGrantBodySchema = z.object({ + workspace_slug: z.string().min(1), + project_id: z.string().min(1).nullish(), + scopes: z.array(z.enum(allowedAgentScopes)).min(1), + mode: z.enum(["voluntary", "reporting"]).default("voluntary"), + created_by_user_id: z.string().min(1), +}); + +const createTokenBodySchema = z.object({ + name: z.string().min(1).max(120).default("Local Codex token"), + expires_at: z.string().datetime().nullish(), +}); + +const actorBodySchema = z.object({ + actor_user_id: z.string().min(1).optional(), +}); + +export async function registerAgentRoutes(app: FastifyInstance, deps: AgentRouteDeps): Promise { app.get("/api/v1/meta/capabilities", async () => ({ ok: true, presets: { @@ -15,12 +59,211 @@ export async function registerAgentRoutes(app: FastifyInstance): Promise { mcp_tools: mcpToolDefinitions, })); - app.post("/api/v1/agents", async (_request, reply) => - reply.status(501).send({ - ok: false, - error: "not_implemented", - message: "Agent persistence starts in Phase 1 after database migrations are added.", - }) - ); + app.post("/api/v1/agents", async (request, reply) => { + const repository = requireRepository(deps); + const body = createAgentBodySchema.parse(request.body); + const agent = await repository.createAgent({ + ownerUserId: body.owner_user_id, + ownerEmail: body.owner_email, + displayName: body.display_name, + avatarUrl: body.avatar_url, + }); + + return reply.status(201).send({ + ok: true, + agent: serializeAgent(agent), + }); + }); + + app.get("/api/v1/agents", async (request) => { + const repository = requireRepository(deps); + const query = listAgentsQuerySchema.parse(request.query); + const agents = await repository.listAgents(query.owner_user_id); + + return { + ok: true, + agents: agents.map(serializeAgent), + }; + }); + + app.get("/api/v1/agents/:agentId", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + return { + ok: true, + agent: serializeAgent(agent), + }; + }); + + app.post("/api/v1/agents/:agentId/revoke", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const body = actorBodySchema.parse(request.body ?? {}); + const agent = await repository.revokeAgent(agentId, body.actor_user_id); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + return { + ok: true, + agent: serializeAgent(agent), + }; + }); + + app.post("/api/v1/agents/:agentId/grants", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const body = upsertGrantBodySchema.parse(request.body); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + const grant = await repository.upsertGrant(agentId, { + workspaceSlug: body.workspace_slug, + projectId: body.project_id, + scopes: body.scopes, + mode: body.mode, + createdByUserId: body.created_by_user_id, + }); + + return reply.status(201).send({ + ok: true, + grant: serializeGrant(grant), + }); + }); + + app.get("/api/v1/agents/:agentId/grants", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + const grants = await repository.listGrants(agentId); + return { + ok: true, + grants: grants.map(serializeGrant), + }; + }); + + app.post("/api/v1/agents/:agentId/tokens", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const body = createTokenBodySchema.parse(request.body ?? {}); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + const token = generateAgentToken(); + const tokenRecord = await repository.createToken(agentId, { + token, + name: body.name, + expiresAt: body.expires_at, + }); + + return reply.status(201).send({ + ok: true, + token, + token_record: serializeToken(tokenRecord), + }); + }); + + app.get("/api/v1/agents/:agentId/tokens", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId } = agentParamsSchema.parse(request.params); + const agent = await repository.getAgent(agentId); + + if (!agent) { + return sendNotFound(reply, "agent_not_found"); + } + + const tokens = await repository.listTokens(agentId); + return { + ok: true, + tokens: tokens.map(serializeToken), + }; + }); + + app.post("/api/v1/agents/:agentId/tokens/:tokenId/revoke", async (request, reply) => { + const repository = requireRepository(deps); + const { agentId, tokenId } = tokenParamsSchema.parse(request.params); + const body = actorBodySchema.parse(request.body ?? {}); + const tokenRecord = await repository.revokeToken(agentId, tokenId, body.actor_user_id); + + if (!tokenRecord) { + return sendNotFound(reply, "token_not_found"); + } + + return { + ok: true, + token_record: serializeToken(tokenRecord), + }; + }); } +function requireRepository(deps: AgentRouteDeps): AgentsRepository { + if (!deps.agentsRepository) { + throw new DatabaseNotConfiguredError(); + } + + return deps.agentsRepository; +} + +function sendNotFound(reply: FastifyReply, error: string): FastifyReply { + return reply.status(404).send({ + ok: false, + error, + }); +} + +function serializeAgent(agent: AgentRecord): Record { + return { + id: agent.id, + owner_user_id: agent.ownerUserId, + owner_email: agent.ownerEmail, + display_name: agent.displayName, + avatar_url: agent.avatarUrl, + status: agent.status, + created_at: agent.createdAt, + updated_at: agent.updatedAt, + }; +} + +function serializeGrant(grant: AgentGrantRecord): Record { + return { + id: grant.id, + agent_id: grant.agentId, + workspace_slug: grant.workspaceSlug, + project_id: grant.projectId, + scopes: grant.scopes, + mode: grant.mode, + created_by_user_id: grant.createdByUserId, + created_at: grant.createdAt, + updated_at: grant.updatedAt, + }; +} + +function serializeToken(token: AgentTokenRecord): Record { + return { + id: token.id, + agent_id: token.agentId, + name: token.name, + status: token.status, + expires_at: token.expiresAt, + last_used_at: token.lastUsedAt, + created_at: token.createdAt, + }; +} diff --git a/src/routes/health.ts b/src/routes/health.ts index 8846f92..8eee8b7 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,22 +1,36 @@ import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; import type { AppConfig } from "../config.js"; -export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig): Promise { +export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig, pool: Pool | null): Promise { app.get("/healthz", async () => ({ ok: true, service: "nodedc-tasker-codex-api", })); - app.get("/readyz", async () => ({ - ok: true, - service: "nodedc-tasker-codex-api", - dependencies: { - database: config.DATABASE_URL ? "configured" : "not_configured", - launcher: config.NODEDC_LAUNCHER_INTERNAL_URL, - tasker: config.NODEDC_TASKER_INTERNAL_URL, - internal_token: config.NODEDC_INTERNAL_ACCESS_TOKEN ? "configured" : "not_configured", - }, - })); + app.get("/readyz", async () => { + const database = pool ? await getDatabaseStatus(pool) : "not_configured"; + const ok = database === "available" || (database === "not_configured" && config.NODE_ENV === "development"); + + 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", + }, + }; + }); } +async function getDatabaseStatus(pool: Pool): Promise<"available" | "unavailable"> { + try { + await pool.query("SELECT 1"); + return "available"; + } catch { + return "unavailable"; + } +} diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts new file mode 100644 index 0000000..905edd5 --- /dev/null +++ b/src/scripts/migrate.ts @@ -0,0 +1,41 @@ +import { Pool } from "pg"; + +import { loadConfig } from "../config.js"; +import { runMigrations } from "../db/migrations.js"; + +const config = loadConfig(); + +if (!config.DATABASE_URL) { + throw new Error("DATABASE_URL is required to run migrations."); +} + +const pool = new Pool({ connectionString: config.DATABASE_URL }); + +try { + await waitForDatabase(pool); + const applied = await runMigrations(pool); + if (applied.length === 0) { + console.log("No migrations to apply."); + } else { + console.log(`Applied migrations: ${applied.map((migration) => migration.id).join(", ")}`); + } +} finally { + await pool.end(); +} + +async function waitForDatabase(pool: Pool): Promise { + const maxAttempts = 30; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await pool.query("SELECT 1"); + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } +} diff --git a/src/security/tokens.ts b/src/security/tokens.ts new file mode 100644 index 0000000..508ce40 --- /dev/null +++ b/src/security/tokens.ts @@ -0,0 +1,11 @@ +import { createHash, randomBytes } from "node:crypto"; + +const TOKEN_PREFIX = "ndcag"; + +export function generateAgentToken(): string { + return `${TOKEN_PREFIX}_${randomBytes(32).toString("base64url")}`; +} + +export function hashAgentToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +}