API - CODEX AGENTS: persistence and lifecycle endpoints

This commit is contained in:
DCCONSTRUCTIONS 2026-05-14 19:26:50 +03:00
parent e95cb3af33
commit 112522c423
17 changed files with 1071 additions and 37 deletions

View File

@ -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

View File

@ -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"]

View File

@ -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.

View File

@ -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:

View File

@ -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

View File

@ -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;

View File

@ -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);

151
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}

View File

@ -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<FastifyInstance> {
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<FastifyInstance> {
});
});
await registerHealthRoutes(app, config);
await registerAgentRoutes(app);
await registerHealthRoutes(app, config, pool);
await registerAgentRoutes(app, { agentsRepository });
return app;
}

61
src/db/migrations.ts Normal file
View File

@ -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<MigrationFile[]> {
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<AppliedMigration[]> {
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();
}
}

21
src/db/pool.ts Normal file
View File

@ -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,
});
}

306
src/repositories/agents.ts Normal file
View File

@ -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<string>(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<AgentRecord> {
const result = await this.pool.query<AgentRow>(
`
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<AgentRecord[]> {
const result = ownerUserId
? await this.pool.query<AgentRow>("SELECT * FROM agents WHERE owner_user_id = $1 ORDER BY created_at DESC", [ownerUserId])
: await this.pool.query<AgentRow>("SELECT * FROM agents ORDER BY created_at DESC");
return result.rows.map(mapAgent);
}
async getAgent(agentId: string): Promise<AgentRecord | null> {
const result = await this.pool.query<AgentRow>("SELECT * FROM agents WHERE id = $1", [agentId]);
return result.rows[0] ? mapAgent(result.rows[0]) : null;
}
async revokeAgent(agentId: string, actorUserId?: string): Promise<AgentRecord | null> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const result = await client.query<AgentRow>(
`
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<AgentGrantRecord> {
assertAllowedScopes(input.scopes);
const projectId = input.projectId ?? "";
const result = await this.pool.query<GrantRow>(
`
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<AgentGrantRecord[]> {
const result = await this.pool.query<GrantRow>("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<AgentTokenRecord> {
const result = await this.pool.query<TokenRow>(
`
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<AgentTokenRecord[]> {
const result = await this.pool.query<TokenRow>(
`
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<AgentTokenRecord | null> {
const result = await this.pool.query<TokenRow>(
`
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<string, unknown> = {}): Promise<void> {
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(),
};
}

View File

@ -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<void> {
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<void> {
app.get("/api/v1/meta/capabilities", async () => ({
ok: true,
presets: {
@ -15,12 +59,211 @@ export async function registerAgentRoutes(app: FastifyInstance): Promise<void> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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,
};
}

View File

@ -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<void> {
export async function registerHealthRoutes(app: FastifyInstance, config: AppConfig, pool: Pool | null): Promise<void> {
app.get("/healthz", async () => ({
ok: true,
service: "nodedc-tasker-codex-api",
}));
app.get("/readyz", async () => ({
ok: true,
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: config.DATABASE_URL ? "configured" : "not_configured",
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";
}
}

41
src/scripts/migrate.ts Normal file
View File

@ -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<void> {
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));
}
}
}

11
src/security/tokens.ts Normal file
View File

@ -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");
}