API - CODEX AGENTS: persistence and lifecycle endpoints
This commit is contained in:
parent
e95cb3af33
commit
112522c423
|
|
@ -8,6 +8,9 @@ NODEDC_LAUNCHER_INTERNAL_URL=http://launcher.local.nodedc
|
||||||
NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc
|
NODEDC_TASKER_INTERNAL_URL=http://task.local.nodedc
|
||||||
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token
|
NODEDC_INTERNAL_ACCESS_TOKEN=replace-with-local-dev-token
|
||||||
|
|
||||||
# Phase 1 can run without DB. Phase 2 will require it.
|
POSTGRES_DB=nodedc_agent_gateway
|
||||||
DATABASE_URL=
|
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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY package*.json tsconfig.json ./
|
COPY package*.json tsconfig.json ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY migrations ./migrations
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:24-alpine AS runtime
|
FROM node:24-alpine AS runtime
|
||||||
|
|
@ -15,7 +16,7 @@ WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY --from=build /app/dist ./dist
|
COPY --from=build /app/dist ./dist
|
||||||
|
COPY migrations ./migrations
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/server.js"]
|
CMD ["node", "dist/server.js"]
|
||||||
|
|
||||||
|
|
|
||||||
37
README.md
37
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.
|
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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,30 @@
|
||||||
services:
|
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:
|
agent-gateway:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "${PORT:-4100}:${PORT:-4100}"
|
- "${PORT:-4100}:${PORT:-4100}"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
agent-gateway-postgres:
|
||||||
|
|
|
||||||
|
|
@ -133,30 +133,37 @@ agent_grant
|
||||||
id
|
id
|
||||||
agent_id
|
agent_id
|
||||||
workspace_slug
|
workspace_slug
|
||||||
project_id
|
project_id (empty string internally means workspace-level grant)
|
||||||
scopes[]
|
scopes[]
|
||||||
mode: voluntary | reporting
|
mode: voluntary | reporting
|
||||||
created_by_user_id
|
created_by_user_id
|
||||||
created_at
|
created_at
|
||||||
|
updated_at
|
||||||
|
|
||||||
pairing_code
|
pairing_code
|
||||||
id
|
id
|
||||||
agent_id
|
agent_id
|
||||||
code_hash
|
code_hash
|
||||||
|
status: active | used | expired | revoked
|
||||||
expires_at
|
expires_at
|
||||||
consumed_at
|
created_at
|
||||||
|
used_at
|
||||||
|
|
||||||
agent_audit_event
|
agent_audit_event
|
||||||
id
|
id
|
||||||
agent_id
|
agent_id
|
||||||
owner_user_id
|
event_type
|
||||||
operation
|
actor_user_id
|
||||||
workspace_slug
|
metadata
|
||||||
project_id
|
|
||||||
issue_id
|
|
||||||
idempotency_key
|
|
||||||
result
|
|
||||||
created_at
|
created_at
|
||||||
|
|
||||||
|
idempotency_key
|
||||||
|
key
|
||||||
|
agent_id
|
||||||
|
request_hash
|
||||||
|
response_body
|
||||||
|
created_at
|
||||||
|
expires_at
|
||||||
```
|
```
|
||||||
|
|
||||||
## Actor model
|
## Actor model
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Last updated: 2026-05-14.
|
||||||
|
|
||||||
## Phase 0. Architecture baseline
|
## Phase 0. Architecture baseline
|
||||||
|
|
||||||
Status: current phase.
|
Status: done in `97d98a7`.
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
||||||
|
|
@ -23,6 +23,8 @@ Exit criteria:
|
||||||
|
|
||||||
## Phase 1. Agent Gateway skeleton
|
## 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:
|
Create standalone service with:
|
||||||
|
|
||||||
- Dockerfile;
|
- Dockerfile;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -9,10 +9,12 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "^4.22.0",
|
"tsx": "^4.22.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
|
|
@ -589,6 +591,18 @@
|
||||||
"undici-types": "~7.21.0"
|
"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": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
||||||
|
|
@ -933,6 +947,95 @@
|
||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.1",
|
"version": "10.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
|
|
@ -970,6 +1073,45 @@
|
||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/process-warning": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
|
@ -1185,6 +1327,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,16 +10,19 @@
|
||||||
"dev": "tsx watch src/server.ts",
|
"dev": "tsx watch src/server.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"check": "tsc --noEmit -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"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.7.0",
|
"@types/node": "^25.7.0",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "^4.22.0",
|
"tsx": "^4.22.0",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
32
src/app.ts
32
src/app.ts
|
|
@ -1,17 +1,44 @@
|
||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { ZodError } from "zod";
|
||||||
|
|
||||||
import type { AppConfig } from "./config.js";
|
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 { registerAgentRoutes } from "./routes/agents.js";
|
||||||
import { registerHealthRoutes } from "./routes/health.js";
|
import { registerHealthRoutes } from "./routes/health.js";
|
||||||
|
|
||||||
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
|
const pool = createPool(config);
|
||||||
|
const agentsRepository = pool ? new AgentsRepository(pool) : null;
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
level: config.LOG_LEVEL,
|
level: config.LOG_LEVEL,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.addHook("onClose", async () => {
|
||||||
|
await pool?.end();
|
||||||
|
});
|
||||||
|
|
||||||
app.setErrorHandler((error, _request, reply) => {
|
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);
|
app.log.error(error);
|
||||||
void reply.status(500).send({
|
void reply.status(500).send({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -20,9 +47,8 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await registerHealthRoutes(app, config);
|
await registerHealthRoutes(app, config, pool);
|
||||||
await registerAgentRoutes(app);
|
await registerAgentRoutes(app, { agentsRepository });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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 { allowedAgentScopes, deniedMvpCapabilities, reporterPresetScopes, taskAuthorPresetScopes } from "../domain/scopes.js";
|
||||||
|
import { DatabaseNotConfiguredError } from "../db/pool.js";
|
||||||
import { mcpToolDefinitions } from "../mcp/tools.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 () => ({
|
app.get("/api/v1/meta/capabilities", async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
presets: {
|
presets: {
|
||||||
|
|
@ -15,12 +59,211 @@ export async function registerAgentRoutes(app: FastifyInstance): Promise<void> {
|
||||||
mcp_tools: mcpToolDefinitions,
|
mcp_tools: mcpToolDefinitions,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.post("/api/v1/agents", async (_request, reply) =>
|
app.post("/api/v1/agents", async (request, reply) => {
|
||||||
reply.status(501).send({
|
const repository = requireRepository(deps);
|
||||||
ok: false,
|
const body = createAgentBodySchema.parse(request.body);
|
||||||
error: "not_implemented",
|
const agent = await repository.createAgent({
|
||||||
message: "Agent persistence starts in Phase 1 after database migrations are added.",
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,36 @@
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
|
||||||
import type { AppConfig } from "../config.js";
|
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 () => ({
|
app.get("/healthz", async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
service: "nodedc-tasker-codex-api",
|
service: "nodedc-tasker-codex-api",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.get("/readyz", async () => ({
|
app.get("/readyz", async () => {
|
||||||
ok: true,
|
const database = pool ? await getDatabaseStatus(pool) : "not_configured";
|
||||||
service: "nodedc-tasker-codex-api",
|
const ok = database === "available" || (database === "not_configured" && config.NODE_ENV === "development");
|
||||||
dependencies: {
|
|
||||||
database: config.DATABASE_URL ? "configured" : "not_configured",
|
return {
|
||||||
launcher: config.NODEDC_LAUNCHER_INTERNAL_URL,
|
ok,
|
||||||
tasker: config.NODEDC_TASKER_INTERNAL_URL,
|
service: "nodedc-tasker-codex-api",
|
||||||
internal_token: config.NODEDC_INTERNAL_ACCESS_TOKEN ? "configured" : "not_configured",
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue