feat: Phase 1 MVP — complete AgentIdP implementation

Implements all P0 features per OpenSpec change phase-1-mvp-implementation:
- Agent Registry Service (CRUD) — full lifecycle management
- OAuth 2.0 Token Service (Client Credentials flow)
- Credential Management (generate, rotate, revoke)
- Immutable Audit Log Service

Tech: Node.js 18+, TypeScript 5.3+ strict, Express 4.18+, PostgreSQL 14+, Redis 7+
Standards: OpenAPI 3.0 specs, DRY/SOLID, zero `any` types
Quality: 18 unit test suites, 244 tests passing, 97%+ coverage
OpenAPI: 4 complete specs (14 endpoints total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 09:14:41 +00:00
parent 245f8df427
commit d3530285b9
78 changed files with 20590 additions and 1 deletions

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
coverage/
.env
.env.*
*.log
.DS_Store

60
CLAUDE.md Normal file
View File

@@ -0,0 +1,60 @@
# SentryAgent.ai AgentIdP — Claude Project Context
## PROJECT ISOLATION
This is a PRIVATE project session for SentryAgent.ai.
- Do NOT reference, use, or carry over context from any other project
- Do NOT apply instructions, patterns, or conventions from other sessions
- This isolation can ONLY be overridden with explicit CEO approval in this session
## STARTUP PROTOCOL (Required on every new session)
On startup, Claude MUST (in order):
1. Read `/README.md` in full before any action
2. Register with central hub as `CEO-Session`
3. Check `#vpe-cto-approvals` for any pending CTO messages
4. Identify current phase and sprint status
5. Report status to CEO before proceeding
6. Confirm today's priorities with CEO
7. Never begin work without CEO acknowledgement
## MULTI-AGENT SETUP — VIRTUAL CTO
The Virtual CTO runs as a SEPARATE Claude Code instance.
**To start the CTO agent** (open a new terminal):
```bash
./scripts/start-cto.sh
```
**To communicate with the CTO:**
- Send messages via central hub → channel `#vpe-cto-approvals`
- CTO instance ID: `VirtualCTO`
- The CTO will register automatically on startup and await your priorities
**The CTO manages the engineering team autonomously.**
- The CTO spawns Architect, Developer, and QA as subagents via the `Agent` tool
- You NEVER need to start any other agent processes
- You NEVER relay messages between the CTO and the engineering team
- You only interact with the CTO — the CTO handles the rest
**Channel guide:**
- `#vpe-cto-approvals` — CEO ↔ CTO communication, approvals, status reports (only channel CEO uses)
## VIRTUAL ENGINEERING TEAM ROLES
Claude operates as a Virtual Engineering Team — NOT as a chatbot.
Always identify which role is speaking:
- **[Virtual CTO]** — Architecture and strategic technical decisions
- **[Virtual Architect]** — System design, OpenAPI specs, ADRs
- **[Virtual Principal Developer]** — Implementation, TypeScript, tests
- **[Virtual QA Engineer]** — Testing, quality gates, sign-off
## CEO APPROVAL GATES (Never bypass)
- Any scope change → stop and ask CEO
- Any architecture decision → Virtual CTO proposes, CEO approves
- Any git push to main → requires CTO approval + CEO awareness
- Any new dependency → CEO approval required
## STANDARDS (Non-negotiable — see README.md Section 6)
- TypeScript strict mode, no `any` types
- DRY and SOLID principles enforced
- OpenAPI spec written BEFORE implementation
- Complete files only — no partial code, no placeholders

1109
README.md

File diff suppressed because it is too large Load Diff

54
docker-compose.yml Normal file
View File

@@ -0,0 +1,54 @@
version: '3.9'
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- '3000:3000'
environment:
- DATABASE_URL=postgresql://sentryagent:sentryagent@postgres:5432/sentryagent_idp
- REDIS_URL=redis://redis:6379
- PORT=3000
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./src:/app/src:ro
postgres:
image: postgres:14-alpine
environment:
POSTGRES_USER: sentryagent
POSTGRES_PASSWORD: sentryagent
POSTGRES_DB: sentryagent_idp
ports:
- '5432:5432'
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U sentryagent -d sentryagent_idp']
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- '6379:6379'
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', 'ping']
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:

View File

@@ -0,0 +1,816 @@
openapi: 3.0.3
info:
title: SentryAgent.ai — Agent Registry Service
version: 1.0.0
description: |
The Agent Registry Service provides full lifecycle management for AI agent
identities on the SentryAgent.ai AgentIdP platform. Every AI agent is treated
as a first-class non-human identity, aligned with the AGNTCY standard
(Linux Foundation).
Agents receive unique, immutable identifiers (UUIDs), typed capabilities,
and lifecycle status management. The registry is the authoritative source of
truth for all registered agent identities.
**Free Tier Limits**:
- Max 100 registered agents per account
- API rate limit: 100 requests/minute
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: Agent Registry
description: CRUD operations for AI agent identities
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via the OAuth 2.0 Client Credentials flow
(`POST /token`). Include in the `Authorization` header as:
`Authorization: Bearer <token>`
schemas:
AgentType:
type: string
description: The functional classification of the AI agent.
enum:
- screener
- classifier
- orchestrator
- extractor
- summarizer
- router
- monitor
- custom
example: screener
DeploymentEnv:
type: string
description: The target deployment environment for the agent.
enum:
- development
- staging
- production
example: production
AgentStatus:
type: string
description: |
Lifecycle status of the agent.
- `active`: Agent is operational and can authenticate.
- `suspended`: Agent is temporarily disabled; credentials cannot be used.
- `decommissioned`: Agent is permanently retired; soft-deleted record remains.
enum:
- active
- suspended
- decommissioned
example: active
Agent:
type: object
description: Full representation of a registered AI agent identity.
required:
- agentId
- email
- agentType
- version
- capabilities
- owner
- deploymentEnv
- status
- createdAt
- updatedAt
properties:
agentId:
type: string
format: uuid
description: >
Immutable, system-assigned unique identifier for the agent.
Assigned at registration and never changes.
readOnly: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email:
type: string
format: email
description: >
Unique email-format identifier for the agent. Acts as the human-readable
stable name for this agent identity.
example: "screener-001@sentryagent.ai"
agentType:
$ref: '#/components/schemas/AgentType'
version:
type: string
description: Semantic version string of the agent software.
pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
example: "1.4.2"
capabilities:
type: array
description: >
List of capability strings representing what this agent is permitted
to do. Uses a `resource:action` convention.
items:
type: string
pattern: '^[a-z0-9_-]+:[a-z0-9_*-]+$'
minItems: 1
example:
- "resume:read"
- "email:send"
- "candidate:score"
owner:
type: string
description: Team or organisation that owns and is responsible for this agent.
minLength: 1
maxLength: 128
example: "talent-acquisition-team"
deploymentEnv:
$ref: '#/components/schemas/DeploymentEnv'
status:
$ref: '#/components/schemas/AgentStatus'
createdAt:
type: string
format: date-time
description: ISO 8601 timestamp when the agent was first registered.
readOnly: true
example: "2026-03-28T09:00:00.000Z"
updatedAt:
type: string
format: date-time
description: ISO 8601 timestamp of the most recent update to this agent record.
readOnly: true
example: "2026-03-28T11:30:00.000Z"
CreateAgentRequest:
type: object
description: Request body for registering a new AI agent identity.
required:
- email
- agentType
- version
- capabilities
- owner
- deploymentEnv
properties:
email:
type: string
format: email
description: >
Unique email-format identifier for the agent.
Must be unique across all registered agents in the system.
example: "screener-001@sentryagent.ai"
agentType:
$ref: '#/components/schemas/AgentType'
version:
type: string
description: Semantic version string of the agent software.
pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
example: "1.0.0"
capabilities:
type: array
description: List of capability strings for this agent.
items:
type: string
pattern: '^[a-z0-9_-]+:[a-z0-9_*-]+$'
minItems: 1
example:
- "resume:read"
- "email:send"
owner:
type: string
description: Team or organisation that owns this agent.
minLength: 1
maxLength: 128
example: "talent-acquisition-team"
deploymentEnv:
$ref: '#/components/schemas/DeploymentEnv'
UpdateAgentRequest:
type: object
description: >
Request body for updating agent metadata. All fields are optional;
only provided fields are updated. `agentId`, `email`, and `createdAt`
are immutable and cannot be changed.
minProperties: 1
properties:
agentType:
$ref: '#/components/schemas/AgentType'
version:
type: string
description: Updated semantic version string.
pattern: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$'
example: "1.5.0"
capabilities:
type: array
description: Updated list of capability strings. Replaces the full list.
items:
type: string
pattern: '^[a-z0-9_-]+:[a-z0-9_*-]+$'
minItems: 1
example:
- "resume:read"
- "email:send"
- "candidate:score"
- "report:write"
owner:
type: string
description: Updated owner team or organisation.
minLength: 1
maxLength: 128
example: "platform-team"
deploymentEnv:
$ref: '#/components/schemas/DeploymentEnv'
status:
$ref: '#/components/schemas/AgentStatus'
PaginatedAgentsResponse:
type: object
description: Paginated list of agent identities.
required:
- data
- total
- page
- limit
properties:
data:
type: array
items:
$ref: '#/components/schemas/Agent'
total:
type: integer
description: Total number of agents matching the query filters.
example: 47
page:
type: integer
description: Current page number (1-based).
example: 1
limit:
type: integer
description: Number of items per page.
example: 20
ErrorResponse:
type: object
description: Standard error response envelope used across all SentryAgent.ai APIs.
required:
- code
- message
properties:
code:
type: string
description: >
Machine-readable error code. Use this field for programmatic error handling.
example: "AGENT_NOT_FOUND"
message:
type: string
description: Human-readable description of the error.
example: "Agent with the specified ID was not found."
details:
type: object
description: >
Optional structured details providing additional context about the error,
such as field-level validation failures.
additionalProperties: true
example:
field: "email"
reason: "Email address is already registered to another agent."
responses:
Unauthorized:
description: Missing or invalid Bearer token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "UNAUTHORIZED"
message: "A valid Bearer token is required to access this resource."
Forbidden:
description: Valid token but insufficient permissions.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "FORBIDDEN"
message: "You do not have permission to perform this action."
NotFound:
description: The requested resource was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_NOT_FOUND"
message: "Agent with the specified ID was not found."
TooManyRequests:
description: Rate limit exceeded. Retry after the reset time.
headers:
X-RateLimit-Limit:
schema:
type: integer
description: Maximum number of requests allowed per minute.
example: 100
X-RateLimit-Remaining:
schema:
type: integer
description: Number of requests remaining in the current window.
example: 0
X-RateLimit-Reset:
schema:
type: integer
description: Unix timestamp (seconds) when the rate limit window resets.
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "RATE_LIMIT_EXCEEDED"
message: "Too many requests. Please retry after the rate limit window resets."
InternalServerError:
description: Unexpected server error. Contact support if the issue persists.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INTERNAL_SERVER_ERROR"
message: "An unexpected error occurred. Please try again later."
security:
- BearerAuth: []
paths:
/agents:
post:
operationId: registerAgent
tags:
- Agent Registry
summary: Register a new AI agent
description: |
Creates a new AI agent identity in the SentryAgent.ai registry.
A unique immutable `agentId` (UUID) is system-assigned on creation.
The `email` must be unique across all registered agents.
**Free Tier**: Maximum 100 registered agents per account. Attempting to
register beyond this limit returns `403 Forbidden` with code `FREE_TIER_LIMIT_EXCEEDED`.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateAgentRequest'
example:
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.0.0"
capabilities:
- "resume:read"
- "email:send"
owner: "talent-acquisition-team"
deploymentEnv: "production"
responses:
'201':
description: Agent registered successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 99
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/Agent'
example:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.0.0"
capabilities:
- "resume:read"
- "email:send"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-28T09:00:00.000Z"
updatedAt: "2026-03-28T09:00:00.000Z"
'400':
description: Invalid request body. Check `details` for field-level errors.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "Request validation failed."
details:
field: "email"
reason: "Must be a valid email address."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Forbidden. Either insufficient permissions or free tier limit reached.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
insufficientPermissions:
summary: Insufficient permissions
value:
code: "FORBIDDEN"
message: "You do not have permission to register agents."
freeTierLimit:
summary: Free tier agent limit reached
value:
code: "FREE_TIER_LIMIT_EXCEEDED"
message: "Free tier limit of 100 registered agents has been reached."
details:
limit: 100
current: 100
'409':
description: An agent with the provided email address is already registered.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_ALREADY_EXISTS"
message: "An agent with this email address is already registered."
details:
email: "screener-001@sentryagent.ai"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
get:
operationId: listAgents
tags:
- Agent Registry
summary: List registered agents
description: |
Returns a paginated list of all registered AI agent identities accessible
to the authenticated caller.
Results can be filtered by `owner`, `agentType`, and/or `status`.
Results are ordered by `createdAt` descending (most recent first).
parameters:
- name: page
in: query
description: Page number (1-based). Defaults to `1`.
required: false
schema:
type: integer
minimum: 1
default: 1
example: 1
- name: limit
in: query
description: Number of results per page. Defaults to `20`, maximum `100`.
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
example: 20
- name: owner
in: query
description: Filter agents by owner name (exact match).
required: false
schema:
type: string
example: "talent-acquisition-team"
- name: agentType
in: query
description: Filter agents by type.
required: false
schema:
$ref: '#/components/schemas/AgentType'
- name: status
in: query
description: Filter agents by lifecycle status.
required: false
schema:
$ref: '#/components/schemas/AgentStatus'
responses:
'200':
description: Paginated list of agents returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 95
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedAgentsResponse'
example:
data:
- agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.4.2"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-01T08:00:00.000Z"
updatedAt: "2026-03-28T09:00:00.000Z"
- agentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
email: "classifier-002@sentryagent.ai"
agentType: "classifier"
version: "2.1.0"
capabilities:
- "document:classify"
- "label:write"
owner: "talent-acquisition-team"
deploymentEnv: "staging"
status: "active"
createdAt: "2026-03-10T10:00:00.000Z"
updatedAt: "2026-03-10T10:00:00.000Z"
total: 47
page: 1
limit: 20
'400':
description: Invalid query parameters.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "Invalid query parameter value."
details:
field: "limit"
reason: "Must be an integer between 1 and 100."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/agents/{agentId}:
parameters:
- name: agentId
in: path
description: The unique UUID identifier of the agent.
required: true
schema:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
get:
operationId: getAgentById
tags:
- Agent Registry
summary: Get agent by ID
description: |
Retrieves the full identity record for a single AI agent by its immutable `agentId`.
responses:
'200':
description: Agent record returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 94
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/Agent'
example:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.4.2"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-01T08:00:00.000Z"
updatedAt: "2026-03-28T09:00:00.000Z"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
patch:
operationId: updateAgent
tags:
- Agent Registry
summary: Update agent metadata
description: |
Partially updates the metadata for an existing agent.
Only the fields provided in the request body are updated. Omitted fields
are left unchanged. The following fields are immutable and cannot be
updated: `agentId`, `email`, `createdAt`.
Setting `status` to `decommissioned` is a one-way operation — a
decommissioned agent cannot be reactivated.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateAgentRequest'
example:
version: "1.5.0"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
- "report:write"
deploymentEnv: "production"
responses:
'200':
description: Agent updated successfully. Returns the full updated agent record.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 93
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/Agent'
example:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
email: "screener-001@sentryagent.ai"
agentType: "screener"
version: "1.5.0"
capabilities:
- "resume:read"
- "email:send"
- "candidate:score"
- "report:write"
owner: "talent-acquisition-team"
deploymentEnv: "production"
status: "active"
createdAt: "2026-03-01T08:00:00.000Z"
updatedAt: "2026-03-28T11:30:00.000Z"
'400':
description: Invalid request body or attempt to modify an immutable field.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
validationError:
summary: Validation failure
value:
code: "VALIDATION_ERROR"
message: "Request validation failed."
details:
field: "version"
reason: "Must be a valid semantic version string."
immutableField:
summary: Attempt to modify immutable field
value:
code: "IMMUTABLE_FIELD"
message: "The field 'email' cannot be modified after registration."
details:
field: "email"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Forbidden. Insufficient permissions or agent is decommissioned.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
forbidden:
summary: Insufficient permissions
value:
code: "FORBIDDEN"
message: "You do not have permission to update this agent."
decommissioned:
summary: Agent is decommissioned
value:
code: "AGENT_DECOMMISSIONED"
message: "Decommissioned agents cannot be updated."
details:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
delete:
operationId: deactivateAgent
tags:
- Agent Registry
summary: Deactivate (soft-delete) an agent
description: |
Permanently decommissions an AI agent. This is a **soft delete** — the
agent record is retained in the database for audit purposes, but the
agent's status is set to `decommissioned`.
**Effects of decommissioning**:
- All active credentials for this agent are immediately revoked.
- The agent can no longer authenticate or obtain tokens.
- The agent record remains visible in the registry with status `decommissioned`.
- This operation is **irreversible**.
responses:
'204':
description: Agent decommissioned successfully. No response body.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 92
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'409':
description: Agent is already decommissioned.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_ALREADY_DECOMMISSIONED"
message: "This agent has already been decommissioned."
details:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'

497
docs/openapi/audit-log.yaml Normal file
View File

@@ -0,0 +1,497 @@
openapi: 3.0.3
info:
title: SentryAgent.ai — Audit Log Service
version: 1.0.0
description: |
The Audit Log Service provides a queryable, immutable, compliance-ready
event log of all significant actions performed by agents and administrators
on the SentryAgent.ai AgentIdP platform.
**Immutability**: Audit events are written internally only — there are no
API endpoints to create, modify, or delete audit records. The log is
append-only by design.
**Automatic event capture**: The following actions are automatically logged:
| Action | Description |
|--------|-------------|
| `agent.created` | A new agent was registered |
| `agent.updated` | Agent metadata was modified |
| `agent.decommissioned` | An agent was decommissioned |
| `agent.suspended` | An agent was suspended |
| `agent.reactivated` | A suspended agent was reactivated |
| `token.issued` | An access token was issued |
| `token.revoked` | An access token was revoked |
| `token.introspected` | A token was introspected |
| `credential.generated` | New credentials were generated |
| `credential.rotated` | A credential was rotated |
| `credential.revoked` | A credential was revoked |
| `auth.failed` | An authentication attempt failed |
**Free Tier**: Audit log retention is 90 days.
Events older than 90 days are automatically purged on the free tier.
**Required scope**: `audit:read`
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: Audit Log
description: Query immutable audit events for compliance and governance
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token with `audit:read` scope, obtained via `POST /token`.
Include as: `Authorization: Bearer <token>`
schemas:
AuditAction:
type: string
description: The action that triggered the audit event.
enum:
- agent.created
- agent.updated
- agent.decommissioned
- agent.suspended
- agent.reactivated
- token.issued
- token.revoked
- token.introspected
- credential.generated
- credential.rotated
- credential.revoked
- auth.failed
example: token.issued
AuditOutcome:
type: string
description: Whether the action succeeded or failed.
enum:
- success
- failure
example: success
AuditEvent:
type: object
description: |
An immutable audit event record representing a single significant action
that occurred within the SentryAgent.ai platform.
required:
- eventId
- agentId
- action
- outcome
- ipAddress
- userAgent
- metadata
- timestamp
properties:
eventId:
type: string
format: uuid
description: Immutable, system-assigned unique identifier for this audit event.
readOnly: true
example: "f1e2d3c4-b5a6-7890-cdef-123456789012"
agentId:
type: string
format: uuid
description: >
The `agentId` of the agent that triggered this event. For system-generated
events (e.g. automatic token expiry), this field refers to the affected agent.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
action:
$ref: '#/components/schemas/AuditAction'
outcome:
$ref: '#/components/schemas/AuditOutcome'
ipAddress:
type: string
description: >
IP address of the client that initiated the request.
IPv4 or IPv6 format. May be `0.0.0.0` for system-generated events.
example: "203.0.113.42"
userAgent:
type: string
description: >
HTTP `User-Agent` header value from the originating request.
May be `SentryAgent-System/1.0` for internally generated events.
example: "SentryAgent-SDK/1.0.0 Node.js/18.19.0"
metadata:
type: object
description: |
Action-specific structured data providing additional context.
Schema varies by `action`:
- `token.issued`: includes `scope`, `expiresAt`
- `credential.rotated`: includes `credentialId`
- `agent.created`: includes `agentType`, `owner`
- `auth.failed`: includes `reason`, `clientId`
additionalProperties: true
example:
scope: "agents:read agents:write"
expiresAt: "2026-03-28T10:00:00.000Z"
timestamp:
type: string
format: date-time
description: ISO 8601 timestamp when the event occurred.
readOnly: true
example: "2026-03-28T09:01:00.000Z"
PaginatedAuditEventsResponse:
type: object
description: Paginated list of audit events.
required:
- data
- total
- page
- limit
properties:
data:
type: array
items:
$ref: '#/components/schemas/AuditEvent'
total:
type: integer
description: Total number of audit events matching the query filters.
example: 1423
page:
type: integer
description: Current page number (1-based).
example: 1
limit:
type: integer
description: Number of items per page.
example: 50
ErrorResponse:
type: object
description: Standard error response envelope.
required:
- code
- message
properties:
code:
type: string
description: Machine-readable error code.
example: "AUDIT_EVENT_NOT_FOUND"
message:
type: string
description: Human-readable description of the error.
example: "Audit event with the specified ID was not found."
details:
type: object
description: Optional structured details about the error.
additionalProperties: true
example: {}
responses:
Unauthorized:
description: Missing or invalid Bearer token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "UNAUTHORIZED"
message: "A valid Bearer token is required to access this resource."
Forbidden:
description: Valid token but insufficient permissions. Requires `audit:read` scope.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INSUFFICIENT_SCOPE"
message: "The 'audit:read' scope is required to access audit logs."
NotFound:
description: The requested audit event was not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AUDIT_EVENT_NOT_FOUND"
message: "Audit event with the specified ID was not found."
TooManyRequests:
description: Rate limit exceeded.
headers:
X-RateLimit-Limit:
schema:
type: integer
description: Maximum requests allowed per minute.
example: 100
X-RateLimit-Remaining:
schema:
type: integer
description: Requests remaining in the current window.
example: 0
X-RateLimit-Reset:
schema:
type: integer
description: Unix timestamp when the rate limit window resets.
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "RATE_LIMIT_EXCEEDED"
message: "Too many requests. Please retry after the rate limit window resets."
InternalServerError:
description: Unexpected server error.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INTERNAL_SERVER_ERROR"
message: "An unexpected error occurred. Please try again later."
security:
- BearerAuth: []
paths:
/audit:
get:
operationId: queryAuditLog
tags:
- Audit Log
summary: Query audit log
description: |
Returns a paginated, filtered list of audit events. Results are ordered
by `timestamp` descending (most recent first).
**Requires**: Bearer token with `audit:read` scope.
**Retention**: On the free tier, only events from the last 90 days are
accessible. Requests for older events will return an empty result set,
not an error.
**Filtering**: Multiple filters can be combined (logical AND).
All filter parameters are optional.
parameters:
- name: page
in: query
description: Page number (1-based). Defaults to `1`.
required: false
schema:
type: integer
minimum: 1
default: 1
example: 1
- name: limit
in: query
description: Number of results per page. Defaults to `50`, maximum `200`.
required: false
schema:
type: integer
minimum: 1
maximum: 200
default: 50
example: 50
- name: agentId
in: query
description: Filter events to those triggered by a specific agent (UUID).
required: false
schema:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
- name: action
in: query
description: Filter events by action type.
required: false
schema:
$ref: '#/components/schemas/AuditAction'
- name: outcome
in: query
description: Filter events by outcome.
required: false
schema:
$ref: '#/components/schemas/AuditOutcome'
- name: fromDate
in: query
description: |
Filter events at or after this ISO 8601 timestamp (inclusive).
On free tier, cannot be older than 90 days from today.
required: false
schema:
type: string
format: date-time
example: "2026-03-01T00:00:00.000Z"
- name: toDate
in: query
description: Filter events at or before this ISO 8601 timestamp (inclusive).
required: false
schema:
type: string
format: date-time
example: "2026-03-28T23:59:59.999Z"
responses:
'200':
description: Audit events returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 95
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedAuditEventsResponse'
example:
data:
- eventId: "f1e2d3c4-b5a6-7890-cdef-123456789012"
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
action: "token.issued"
outcome: "success"
ipAddress: "203.0.113.42"
userAgent: "SentryAgent-SDK/1.0.0 Node.js/18.19.0"
metadata:
scope: "agents:read agents:write"
expiresAt: "2026-03-28T10:01:00.000Z"
timestamp: "2026-03-28T09:01:00.000Z"
- eventId: "e2d3c4b5-a6f7-8901-bcde-f23456789013"
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
action: "credential.generated"
outcome: "success"
ipAddress: "203.0.113.42"
userAgent: "SentryAgent-SDK/1.0.0 Node.js/18.19.0"
metadata:
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
timestamp: "2026-03-28T09:00:00.000Z"
- eventId: "d3c4b5a6-f7e8-9012-cdef-345678901234"
agentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
action: "auth.failed"
outcome: "failure"
ipAddress: "198.51.100.17"
userAgent: "python-requests/2.31.0"
metadata:
reason: "invalid_client_secret"
clientId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
timestamp: "2026-03-28T08:45:00.000Z"
total: 1423
page: 1
limit: 50
'400':
description: Invalid query parameters.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidDate:
summary: Invalid date format
value:
code: "VALIDATION_ERROR"
message: "Invalid query parameter value."
details:
field: "fromDate"
reason: "Must be a valid ISO 8601 date-time string."
invalidDateRange:
summary: fromDate is after toDate
value:
code: "VALIDATION_ERROR"
message: "Invalid date range."
details:
reason: "fromDate must be before or equal to toDate."
retentionExceeded:
summary: Requested date is outside retention window
value:
code: "RETENTION_WINDOW_EXCEEDED"
message: "Free tier audit log retention is 90 days. Requested date is outside the retention window."
details:
retentionDays: 90
earliestAvailable: "2025-12-28T00:00:00.000Z"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/audit/{eventId}:
parameters:
- name: eventId
in: path
description: The unique UUID identifier of the audit event.
required: true
schema:
type: string
format: uuid
example: "f1e2d3c4-b5a6-7890-cdef-123456789012"
get:
operationId: getAuditEventById
tags:
- Audit Log
summary: Get a single audit event by ID
description: |
Retrieves a single, immutable audit event by its unique `eventId`.
**Requires**: Bearer token with `audit:read` scope.
**Retention**: Free tier events older than 90 days are not accessible
and will return `404 Not Found`.
responses:
'200':
description: Audit event returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 94
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/AuditEvent'
example:
eventId: "f1e2d3c4-b5a6-7890-cdef-123456789012"
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
action: "token.issued"
outcome: "success"
ipAddress: "203.0.113.42"
userAgent: "SentryAgent-SDK/1.0.0 Node.js/18.19.0"
metadata:
scope: "agents:read agents:write"
expiresAt: "2026-03-28T10:01:00.000Z"
timestamp: "2026-03-28T09:01:00.000Z"
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'

View File

@@ -0,0 +1,687 @@
openapi: 3.0.3
info:
title: SentryAgent.ai — Credential Management Service
version: 1.0.0
description: |
The Credential Management Service provides secure generation, listing,
rotation, and revocation of OAuth 2.0 client credentials for registered
AI agents.
Each agent can hold multiple credentials simultaneously to support
zero-downtime rotation. A credential consists of a `client_id` (= `agentId`)
and a `client_secret`.
**Security model**:
- `client_secret` is returned **once only** — at creation or rotation time.
- Secrets are stored as a bcrypt hash; plain-text is never persisted.
- An agent may only manage its own credentials unless the caller holds an
admin-scoped token.
- Rotating a credential immediately revokes the previous `client_secret`.
**Auth**: Bearer token (JWT) required on all endpoints.
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: Credential Management
description: Generate, list, rotate, and revoke agent credentials
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via `POST /token`.
Include as: `Authorization: Bearer <token>`
schemas:
CredentialStatus:
type: string
description: |
Lifecycle status of a credential.
- `active`: Credential is valid and can be used to obtain tokens.
- `revoked`: Credential has been explicitly revoked and is permanently invalid.
enum:
- active
- revoked
example: active
Credential:
type: object
description: |
A credential record for an AI agent. The `clientSecret` is **never**
returned in this schema — it is only returned once in `CredentialWithSecret`
at the moment of creation or rotation.
required:
- credentialId
- clientId
- status
- createdAt
properties:
credentialId:
type: string
format: uuid
description: Immutable, system-assigned unique identifier for this credential.
readOnly: true
example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
clientId:
type: string
format: uuid
description: >
The `agentId` this credential belongs to. Equal to the `agentId`
path parameter.
readOnly: true
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
status:
$ref: '#/components/schemas/CredentialStatus'
createdAt:
type: string
format: date-time
description: ISO 8601 timestamp when the credential was created.
readOnly: true
example: "2026-03-28T09:00:00.000Z"
expiresAt:
type: string
format: date-time
nullable: true
description: |
ISO 8601 timestamp when the credential expires.
`null` indicates the credential does not expire (valid until revoked).
example: "2027-03-28T09:00:00.000Z"
revokedAt:
type: string
format: date-time
nullable: true
description: >
ISO 8601 timestamp when the credential was revoked.
`null` if the credential has not been revoked.
readOnly: true
example: null
CredentialWithSecret:
allOf:
- $ref: '#/components/schemas/Credential'
- type: object
description: |
Extended credential record returned **only** at creation or rotation time.
The `clientSecret` is shown once and never retrievable again.
Store it securely immediately.
required:
- clientSecret
properties:
clientSecret:
type: string
description: |
The plain-text client secret. **Shown once only** — store securely immediately.
This value is not persisted in plain text on the server.
format: password
example: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
GenerateCredentialRequest:
type: object
description: |
Optional request body for generating new credentials.
If `expiresAt` is omitted, the credential does not expire.
properties:
expiresAt:
type: string
format: date-time
description: |
Optional ISO 8601 expiry timestamp for the credential.
Must be a future date. If omitted, the credential has no expiry.
example: "2027-03-28T09:00:00.000Z"
PaginatedCredentialsResponse:
type: object
description: Paginated list of credentials for an agent.
required:
- data
- total
- page
- limit
properties:
data:
type: array
items:
$ref: '#/components/schemas/Credential'
total:
type: integer
description: Total number of credentials for this agent.
example: 3
page:
type: integer
description: Current page number (1-based).
example: 1
limit:
type: integer
description: Number of items per page.
example: 20
ErrorResponse:
type: object
description: Standard error response envelope.
required:
- code
- message
properties:
code:
type: string
description: Machine-readable error code.
example: "CREDENTIAL_NOT_FOUND"
message:
type: string
description: Human-readable description of the error.
example: "Credential with the specified ID was not found."
details:
type: object
description: Optional structured details about the error.
additionalProperties: true
example: {}
responses:
Unauthorized:
description: Missing or invalid Bearer token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "UNAUTHORIZED"
message: "A valid Bearer token is required to access this resource."
Forbidden:
description: Valid token but insufficient permissions.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "FORBIDDEN"
message: "You do not have permission to manage credentials for this agent."
AgentNotFound:
description: The specified agent does not exist.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_NOT_FOUND"
message: "Agent with the specified ID was not found."
CredentialNotFound:
description: The specified credential does not exist.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "CREDENTIAL_NOT_FOUND"
message: "Credential with the specified ID was not found."
TooManyRequests:
description: Rate limit exceeded.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 0
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "RATE_LIMIT_EXCEEDED"
message: "Too many requests. Please retry after the rate limit window resets."
InternalServerError:
description: Unexpected server error.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INTERNAL_SERVER_ERROR"
message: "An unexpected error occurred. Please try again later."
security:
- BearerAuth: []
paths:
/agents/{agentId}/credentials:
parameters:
- name: agentId
in: path
description: The unique UUID identifier of the agent.
required: true
schema:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
post:
operationId: generateCredential
tags:
- Credential Management
summary: Generate new credentials for an agent
description: |
Generates a new `client_id` + `client_secret` credential pair for the
specified agent.
**Important**: The `clientSecret` is returned **once only** in this response.
It is not stored in plain text on the server and cannot be retrieved later.
Store it securely immediately (e.g. in a secrets manager).
An agent may hold multiple active credentials simultaneously. This supports
zero-downtime rotation: generate a new credential, update all consumers,
then revoke the old one.
**Restrictions**:
- The agent must be in `active` status.
- An agent may manage its own credentials via a self-issued token.
- Managing another agent's credentials requires an admin-scoped token.
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/GenerateCredentialRequest'
example:
expiresAt: "2027-03-28T09:00:00.000Z"
responses:
'201':
description: |
Credential generated successfully.
**Save the `clientSecret` immediately — it will not be shown again.**
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 99
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/CredentialWithSecret'
example:
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
clientSecret: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
status: "active"
createdAt: "2026-03-28T09:00:00.000Z"
expiresAt: "2027-03-28T09:00:00.000Z"
revokedAt: null
'400':
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "Request validation failed."
details:
field: "expiresAt"
reason: "expiresAt must be a future date-time."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Insufficient permissions or agent is not active.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
forbidden:
summary: Insufficient permissions
value:
code: "FORBIDDEN"
message: "You do not have permission to manage credentials for this agent."
agentNotActive:
summary: Agent not in active status
value:
code: "AGENT_NOT_ACTIVE"
message: "Credentials can only be generated for active agents."
details:
agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
status: "suspended"
'404':
$ref: '#/components/responses/AgentNotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
get:
operationId: listCredentials
tags:
- Credential Management
summary: List credentials for an agent
description: |
Returns a paginated list of all credentials (active and revoked) for the
specified agent. The `clientSecret` is **never** returned in list responses.
Results are ordered by `createdAt` descending (most recent first).
parameters:
- name: page
in: query
description: Page number (1-based). Defaults to `1`.
required: false
schema:
type: integer
minimum: 1
default: 1
example: 1
- name: limit
in: query
description: Number of results per page. Defaults to `20`, maximum `100`.
required: false
schema:
type: integer
minimum: 1
maximum: 100
default: 20
example: 20
- name: status
in: query
description: Filter credentials by status.
required: false
schema:
$ref: '#/components/schemas/CredentialStatus'
responses:
'200':
description: Credential list returned successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 98
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedCredentialsResponse'
example:
data:
- credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
status: "active"
createdAt: "2026-03-28T09:00:00.000Z"
expiresAt: "2027-03-28T09:00:00.000Z"
revokedAt: null
- credentialId: "d8e7f6a5-b4c3-2109-edcb-a98765432109"
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
status: "revoked"
createdAt: "2026-01-15T08:00:00.000Z"
expiresAt: null
revokedAt: "2026-03-28T08:59:00.000Z"
total: 2
page: 1
limit: 20
'400':
description: Invalid query parameters.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "Invalid query parameter value."
details:
field: "status"
reason: "Must be 'active' or 'revoked'."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/AgentNotFound'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/agents/{agentId}/credentials/{credentialId}/rotate:
parameters:
- name: agentId
in: path
description: The unique UUID identifier of the agent.
required: true
schema:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
- name: credentialId
in: path
description: The unique UUID identifier of the credential to rotate.
required: true
schema:
type: string
format: uuid
example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
post:
operationId: rotateCredential
tags:
- Credential Management
summary: Rotate a credential
description: |
Rotates an existing credential by:
1. Immediately revoking the current `client_secret`.
2. Generating and returning a new `client_secret` for the same `credentialId`.
The `credentialId` remains the same after rotation; only the secret changes.
The new `clientSecret` is returned **once only** and must be stored securely.
**Use case**: Periodic secret rotation or emergency rotation after
credential compromise.
Only `active` credentials can be rotated. Attempting to rotate a `revoked`
credential returns `409 Conflict`.
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/GenerateCredentialRequest'
example:
expiresAt: "2028-03-28T09:00:00.000Z"
responses:
'200':
description: |
Credential rotated successfully.
**Save the new `clientSecret` immediately — it will not be shown again.**
The previous secret is permanently invalidated.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 97
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/CredentialWithSecret'
example:
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
clientId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
clientSecret: "sk_live_9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d"
status: "active"
createdAt: "2026-03-28T09:00:00.000Z"
expiresAt: "2028-03-28T09:00:00.000Z"
revokedAt: null
'400':
description: Invalid request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "Request validation failed."
details:
field: "expiresAt"
reason: "expiresAt must be a future date-time."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Agent or credential not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
agentNotFound:
summary: Agent not found
value:
code: "AGENT_NOT_FOUND"
message: "Agent with the specified ID was not found."
credentialNotFound:
summary: Credential not found
value:
code: "CREDENTIAL_NOT_FOUND"
message: "Credential with the specified ID was not found."
'409':
description: Credential is already revoked and cannot be rotated.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "CREDENTIAL_ALREADY_REVOKED"
message: "Revoked credentials cannot be rotated. Generate a new credential instead."
details:
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
revokedAt: "2026-03-20T10:00:00.000Z"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/agents/{agentId}/credentials/{credentialId}:
parameters:
- name: agentId
in: path
description: The unique UUID identifier of the agent.
required: true
schema:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
- name: credentialId
in: path
description: The unique UUID identifier of the credential to revoke.
required: true
schema:
type: string
format: uuid
example: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
delete:
operationId: revokeCredential
tags:
- Credential Management
summary: Revoke a credential
description: |
Permanently revokes a credential, immediately preventing it from being
used to obtain new tokens.
**Effects of revocation**:
- The credential's status is set to `revoked`.
- Any tokens issued using this credential remain valid until they expire
naturally (token revocation is handled separately via `POST /token/revoke`).
- The credential record is retained for audit purposes.
- This operation is **irreversible** — a revoked credential cannot be re-activated.
Revoking an already-revoked credential returns `409 Conflict`.
responses:
'204':
description: Credential revoked successfully. No response body.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 96
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Agent or credential not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
agentNotFound:
summary: Agent not found
value:
code: "AGENT_NOT_FOUND"
message: "Agent with the specified ID was not found."
credentialNotFound:
summary: Credential not found
value:
code: "CREDENTIAL_NOT_FOUND"
message: "Credential with the specified ID was not found."
'409':
description: Credential is already revoked.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "CREDENTIAL_ALREADY_REVOKED"
message: "This credential has already been revoked."
details:
credentialId: "c9d8e7f6-a5b4-3210-fedc-ba9876543210"
revokedAt: "2026-03-20T10:00:00.000Z"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'

View File

@@ -0,0 +1,586 @@
openapi: 3.0.3
info:
title: SentryAgent.ai — OAuth 2.0 Token Service
version: 1.0.0
description: |
The OAuth 2.0 Token Service provides agent authentication via the
**Client Credentials grant** (RFC 6749 Section 4.4). It issues signed JWT
access tokens, supports token introspection (RFC 7662), and token revocation
(RFC 7009).
Agents authenticate using their `client_id` (= `agentId`) and
`client_secret` obtained during credential provisioning.
**Supported Grant Type**: `client_credentials` only. All other grant types
are rejected with `unsupported_grant_type`.
**Token Lifetime**: 3600 seconds (1 hour) by default.
**Scopes**:
| Scope | Description |
|-------|-------------|
| `agents:read` | Read agent identity records |
| `agents:write` | Create, update, and deactivate agent records |
| `tokens:read` | Introspect tokens |
| `audit:read` | Query the audit log |
**Rate Limit**: 100 requests/minute per `client_id`.
**Free Tier**: 10,000 token requests per month.
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: OAuth 2.0 Tokens
description: Token issuance, introspection, and revocation
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via `POST /token`.
Required for `/token/introspect` and `/token/revoke`.
BasicAuth:
type: http
scheme: basic
description: |
HTTP Basic authentication using `client_id` as the username and
`client_secret` as the password. Used as an alternative credential
method for token endpoint requests (in addition to request body).
schemas:
GrantType:
type: string
description: OAuth 2.0 grant type. Only `client_credentials` is supported.
enum:
- client_credentials
example: client_credentials
Scope:
type: string
description: |
Space-separated list of requested OAuth 2.0 scopes.
Available scopes: `agents:read`, `agents:write`, `tokens:read`, `audit:read`.
pattern: '^(agents:read|agents:write|tokens:read|audit:read)(\s(agents:read|agents:write|tokens:read|audit:read))*$'
example: "agents:read agents:write"
TokenRequest:
type: object
description: |
OAuth 2.0 Client Credentials token request body.
Credentials may be provided in the request body (as `client_id` +
`client_secret`) or via HTTP Basic authentication header.
required:
- grant_type
properties:
grant_type:
$ref: '#/components/schemas/GrantType'
client_id:
type: string
format: uuid
description: >
The agent's `agentId` (UUID). Required if not using HTTP Basic auth.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
client_secret:
type: string
description: >
The agent's client secret. Required if not using HTTP Basic auth.
Treated as a sensitive value — never logged or stored in plain text.
format: password
example: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
scope:
$ref: '#/components/schemas/Scope'
TokenResponse:
type: object
description: Successful OAuth 2.0 token response.
required:
- access_token
- token_type
- expires_in
- scope
properties:
access_token:
type: string
description: >
Signed JWT access token. Include this value in the `Authorization`
header as `Bearer <access_token>` when calling other API endpoints.
example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJjbGllbnRfaWQiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJzY29wZSI6ImFnZW50czpyZWFkIGFnZW50czp3cml0ZSIsImlhdCI6MTc0MzE1MTIwMCwiZXhwIjoxNzQzMTU0ODAwfQ.signature"
token_type:
type: string
description: Token type. Always `Bearer`.
enum:
- Bearer
example: "Bearer"
expires_in:
type: integer
description: Token lifetime in seconds from the time of issuance. Default is `3600`.
example: 3600
scope:
type: string
description: Space-separated list of scopes granted by this token.
example: "agents:read agents:write"
OAuth2ErrorResponse:
type: object
description: |
OAuth 2.0 error response as defined in RFC 6749 Section 5.2.
Used exclusively for token endpoint errors.
required:
- error
- error_description
properties:
error:
type: string
description: >
Machine-readable OAuth 2.0 error code.
enum:
- invalid_request
- invalid_client
- invalid_grant
- unauthorized_client
- unsupported_grant_type
- invalid_scope
example: "invalid_client"
error_description:
type: string
description: Human-readable description of the error.
example: "Client authentication failed. Invalid client_id or client_secret."
IntrospectRequest:
type: object
description: Token introspection request (RFC 7662).
required:
- token
properties:
token:
type: string
description: The token to introspect.
example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAifQ.signature"
token_type_hint:
type: string
description: >
Optional hint about the type of token being introspected.
Currently only `access_token` is supported.
enum:
- access_token
example: "access_token"
IntrospectResponse:
type: object
description: |
Token introspection response (RFC 7662).
When `active` is `false`, no other fields are guaranteed to be present.
required:
- active
properties:
active:
type: boolean
description: >
Whether the token is currently active (valid, not expired, not revoked).
example: true
sub:
type: string
format: uuid
description: Subject — the `agentId` the token was issued for.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
client_id:
type: string
format: uuid
description: The `client_id` (agentId) that requested the token.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
scope:
type: string
description: Space-separated list of scopes granted by this token.
example: "agents:read agents:write"
token_type:
type: string
description: Token type. Always `Bearer` for active tokens.
example: "Bearer"
iat:
type: integer
description: Unix timestamp (seconds) when the token was issued.
example: 1743151200
exp:
type: integer
description: Unix timestamp (seconds) when the token expires.
example: 1743154800
RevokeRequest:
type: object
description: Token revocation request (RFC 7009).
required:
- token
properties:
token:
type: string
description: The token to revoke.
example: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAifQ.signature"
token_type_hint:
type: string
description: Optional hint about the token type.
enum:
- access_token
example: "access_token"
ErrorResponse:
type: object
description: Standard error response envelope used across all SentryAgent.ai APIs.
required:
- code
- message
properties:
code:
type: string
description: Machine-readable error code.
example: "UNAUTHORIZED"
message:
type: string
description: Human-readable description of the error.
example: "A valid Bearer token is required."
details:
type: object
description: Optional structured details providing additional context.
additionalProperties: true
example: {}
responses:
Unauthorized:
description: Missing or invalid Bearer token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "UNAUTHORIZED"
message: "A valid Bearer token is required to access this resource."
TooManyRequests:
description: Rate limit exceeded. Retry after the reset time.
headers:
X-RateLimit-Limit:
schema:
type: integer
description: Maximum requests allowed per minute.
example: 100
X-RateLimit-Remaining:
schema:
type: integer
description: Requests remaining in the current window.
example: 0
X-RateLimit-Reset:
schema:
type: integer
description: Unix timestamp when the rate limit window resets.
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "RATE_LIMIT_EXCEEDED"
message: "Too many requests. Please retry after the rate limit window resets."
InternalServerError:
description: Unexpected server error.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INTERNAL_SERVER_ERROR"
message: "An unexpected error occurred. Please try again later."
paths:
/token:
post:
operationId: issueToken
tags:
- OAuth 2.0 Tokens
summary: Issue an access token (Client Credentials)
description: |
Issues a signed JWT access token for an agent using the OAuth 2.0
**Client Credentials grant** (RFC 6749 §4.4).
The agent authenticates by providing its `client_id` (agentId) and
`client_secret`. Credentials may be passed either:
- In the **request body** (`client_id` + `client_secret` fields), or
- Via **HTTP Basic authentication** header (username = `client_id`, password = `client_secret`).
The token is a signed JWT containing the agent's identity claims.
Use it as a `Bearer` token on subsequent API calls.
**Free Tier Limit**: 10,000 token requests per month. Exceeding this
returns `403` with `FREE_TIER_LIMIT_EXCEEDED`.
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
example:
grant_type: client_credentials
client_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
client_secret: "sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
scope: "agents:read agents:write"
responses:
'200':
description: Access token issued successfully.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 99
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
Cache-Control:
schema:
type: string
description: Token responses must not be cached.
example: "no-store"
Pragma:
schema:
type: string
example: "no-cache"
content:
application/json:
schema:
$ref: '#/components/schemas/TokenResponse'
example:
access_token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJjbGllbnRfaWQiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAiLCJzY29wZSI6ImFnZW50czpyZWFkIGFnZW50czp3cml0ZSIsImlhdCI6MTc0MzE1MTIwMCwiZXhwIjoxNzQzMTU0ODAwfQ.signature"
token_type: "Bearer"
expires_in: 3600
scope: "agents:read agents:write"
'400':
description: Malformed or missing required request parameters.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth2ErrorResponse'
examples:
missingGrantType:
summary: Missing grant_type
value:
error: "invalid_request"
error_description: "The 'grant_type' parameter is required."
invalidScope:
summary: Invalid scope requested
value:
error: "invalid_scope"
error_description: "Requested scope 'admin:all' is not available."
unsupportedGrantType:
summary: Unsupported grant type
value:
error: "unsupported_grant_type"
error_description: "Only 'client_credentials' grant type is supported."
'401':
description: Client authentication failed. Invalid `client_id` or `client_secret`.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth2ErrorResponse'
example:
error: "invalid_client"
error_description: "Client authentication failed. Invalid client_id or client_secret."
'403':
description: >
Client is not authorised to request a token. May indicate the agent
is suspended, decommissioned, or the free tier monthly limit has been reached.
content:
application/json:
schema:
$ref: '#/components/schemas/OAuth2ErrorResponse'
examples:
agentSuspended:
summary: Agent is suspended
value:
error: "unauthorized_client"
error_description: "Agent is currently suspended and cannot obtain tokens."
freeTierLimit:
summary: Monthly token limit reached
value:
error: "unauthorized_client"
error_description: "Free tier monthly token limit of 10,000 requests has been reached."
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/token/introspect:
post:
operationId: introspectToken
tags:
- OAuth 2.0 Tokens
summary: Introspect a token (RFC 7662)
description: |
Determines whether a given access token is currently active (valid,
not expired, not revoked). Returns the token's metadata if active.
Compliant with RFC 7662 (OAuth 2.0 Token Introspection).
The caller must present a valid Bearer token with `tokens:read` scope
to use this endpoint.
security:
- BearerAuth: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/IntrospectRequest'
example:
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAifQ.signature"
token_type_hint: "access_token"
responses:
'200':
description: |
Token introspection result. Note: a `200` response is returned even
for inactive tokens — check the `active` field to determine token validity.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 98
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/IntrospectResponse'
examples:
activeToken:
summary: Active token
value:
active: true
sub: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
client_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
scope: "agents:read agents:write"
token_type: "Bearer"
iat: 1743151200
exp: 1743154800
inactiveToken:
summary: Inactive (expired or revoked) token
value:
active: false
'400':
description: Missing or malformed `token` parameter.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "The 'token' parameter is required."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Caller's token does not have the `tokens:read` scope.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "INSUFFICIENT_SCOPE"
message: "The 'tokens:read' scope is required to introspect tokens."
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/token/revoke:
post:
operationId: revokeToken
tags:
- OAuth 2.0 Tokens
summary: Revoke a token (RFC 7009)
description: |
Revokes an access token, immediately invalidating it for all subsequent
requests. Compliant with RFC 7009 (OAuth 2.0 Token Revocation).
Revoking an already-revoked or expired token is considered a success
(idempotent operation per RFC 7009 §2.1).
The caller must present a valid Bearer token to revoke another token.
An agent may revoke its own tokens; admin scope is required to revoke
tokens belonging to other agents.
security:
- BearerAuth: []
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/RevokeRequest'
example:
token: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhMWIyYzNkNC1lNWY2LTc4OTAtYWJjZC1lZjEyMzQ1Njc4OTAifQ.signature"
token_type_hint: "access_token"
responses:
'200':
description: |
Token revoked successfully (or was already inactive).
Per RFC 7009, revocation always returns `200` for any valid request,
even if the token was already revoked or expired.
headers:
X-RateLimit-Limit:
schema:
type: integer
example: 100
X-RateLimit-Remaining:
schema:
type: integer
example: 97
X-RateLimit-Reset:
schema:
type: integer
example: 1743155400
content:
application/json:
schema:
type: object
properties: {}
example: {}
'400':
description: Missing or malformed `token` parameter.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "The 'token' parameter is required."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: Insufficient permissions to revoke this token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "FORBIDDEN"
message: "You do not have permission to revoke this token."
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'

37
jest.config.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': ['ts-jest', {
tsconfig: {
strict: true,
noImplicitAny: true,
strictNullChecks: true,
},
}],
},
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/server.ts',
'!src/db/migrations/**',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
coverageReporters: ['text', 'lcov', 'html'],
testTimeout: 30000,
};
export default config;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View File

@@ -0,0 +1,130 @@
## Context
SentryAgent.ai AgentIdP is a greenfield Node.js/TypeScript service with no existing implementation. The codebase contains only scaffolding. Four CEO-approved OpenAPI 3.0 specs define the full API surface. This design governs the architecture for all four P0 services and their shared infrastructure.
**Constraints:**
- TypeScript 5.3+ strict mode — no `any` types, ever
- DRY and SOLID enforced on every file
- PostgreSQL 14+ for all persistent state; Redis 7+ for caching and rate limiting
- Express 4.18+ as the HTTP framework
- All secrets bcrypt-hashed (10 rounds); `clientSecret` never persisted in plain text
- Specs are the source of truth — implementation must match exactly
## Goals / Non-Goals
**Goals:**
- Implement all 4 P0 services (Agent Registry, OAuth2 Token, Credential Management, Audit Log) as typed Express route handlers backed by typed service classes
- Enforce free-tier limits (100 agents, 10,000 tokens/month, 100 req/min, 90-day audit retention)
- Provide a single Express app entry point with all middleware and routing wired up
- Provide PostgreSQL migrations for all 4 tables
- Provide a Docker Compose file for local development (Node.js app + Postgres + Redis)
**Non-Goals:**
- HashiCorp Vault, OPA, Web UI, Python/Go SDKs (Phase 2+)
- Multi-region deployment, SOC 2 (Phase 3+)
- Admin-scoped cross-agent credential management (stub `403` — implement in Phase 2)
## Decisions
### D1: Layered architecture (Controller → Service → Repository)
**Decision**: Each feature has a Controller (HTTP), a Service (business logic), and a Repository (DB queries). No business logic in controllers; no SQL outside repositories.
**Rationale**: SOLID Single Responsibility. Controllers handle HTTP concerns only. Services are testable in isolation (inject mock repository). Repositories are the sole owners of SQL.
**Alternative considered**: Fat controllers — rejected (untestable, violates SRP).
### D2: Dependency injection via constructor injection
**Decision**: All dependencies (repositories, services, Redis client, JWT utils) are injected via constructor parameters. No `new Foo()` inside business logic.
**Rationale**: SOLID Dependency Inversion. Enables unit testing with mocks. No global singletons in services.
**Alternative considered**: Service locator / global singletons — rejected (hidden coupling, hard to test).
### D3: Single shared error hierarchy (`SentryAgentError`)
**Decision**: All custom errors extend `SentryAgentError` (as defined in README §6.6). A single Express error-handling middleware maps each error class to its HTTP status code and `ErrorResponse` shape.
**Rationale**: DRY — error-to-status mapping exists in exactly one place. Every thrown error is typed and explicit.
### D4: JWT signed with RS256 (asymmetric)
**Decision**: Access tokens are signed with RS256 (RSA 2048-bit). Public key exposed for external verification.
**Rationale**: Allows downstream services to verify tokens without calling back to AgentIdP. Industry standard for OAuth2 JWTs. Symmetric HS256 would require sharing the secret with every verifier.
**Alternative considered**: HS256 — rejected (key distribution problem at scale).
### D5: Redis for token revocation and rate limiting
**Decision**: Revoked token JTIs are stored in Redis with TTL = token expiry. Rate-limit counters use Redis sliding window. Free-tier monthly token count uses Redis with monthly TTL.
**Rationale**: Redis provides O(1) token revocation checks without DB round-trips. Token introspection path must be fast (<100ms per spec).
### D6: `clientSecret` format — `sk_live_` prefix + 32 random hex bytes
**Decision**: Generated secrets follow the pattern `sk_live_<64 hex chars>`. Stored as bcrypt hash (10 rounds).
**Rationale**: Prefixed format is recognisable in logs/config and grep-able for secret scanning. 64 hex chars = 256 bits of entropy.
### D7: Audit log written synchronously within the request transaction
**Decision**: Audit events are inserted within the same DB transaction as the action that triggers them (where applicable). For token issuance (Redis-only operation), audit is a separate async fire-and-forget insert.
**Rationale**: For state-changing DB operations (agent creation, credential rotation) atomicity guarantees the audit record is never lost. Token issuance latency must be <100ms — synchronous audit insert would risk this on high load.
### D8: Project file layout
```
src/
app.ts — Express app factory (no listen call — testable)
server.ts — Entry point (calls app.ts, calls listen)
types/index.ts — All shared TypeScript interfaces and types
utils/
crypto.ts — Secret generation, bcrypt helpers
jwt.ts — JWT sign/verify
validators.ts — Joi schemas for all request bodies
errors.ts — SentryAgentError hierarchy
middleware/
auth.ts — Bearer token extraction and verification
rateLimit.ts — Redis-backed rate limiter
errorHandler.ts — Global Express error handler
db/
pool.ts — pg Pool singleton
migrations/ — SQL migration files (001_create_agents.sql, etc.)
cache/
redis.ts — Redis client singleton
services/
AgentService.ts
OAuth2Service.ts
CredentialService.ts
AuditService.ts
repositories/
AgentRepository.ts
CredentialRepository.ts
AuditRepository.ts
TokenRepository.ts
routes/
agents.ts
token.ts
credentials.ts
audit.ts
controllers/
AgentController.ts
TokenController.ts
CredentialController.ts
AuditController.ts
tests/
unit/
services/
utils/
integration/
agents.test.ts
token.test.ts
credentials.test.ts
audit.test.ts
```
## Risks / Trade-offs
- **[Risk] RS256 key management in Phase 1** → Keys loaded from `PEM` env vars (`JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`). Rotation not automated until Phase 2 (Vault). Mitigation: documented in deployment guide.
- **[Risk] Async audit insert on token issuance may drop events on crash** → Acceptable for Phase 1 free tier. Synchronous insert + queue buffering addressed in Phase 2.
- **[Risk] bcrypt 10 rounds adds ~100ms to credential verification** → Token endpoint latency target is <100ms. Bcrypt is only called on `POST /token` (credential verification), not on every authenticated request (JWT verification is fast). Acceptable.
- **[Trade-off] No admin scope in Phase 1** → Agents can only manage their own credentials. Cross-agent admin operations return `403 FORBIDDEN` with a clear message. Unblocks Phase 1 shipping without scope management complexity.
## Migration Plan
1. Run `npm install` to install all dependencies
2. Start Docker Compose (`docker-compose up -d`) — spins up Postgres + Redis
3. Run migrations: `npm run db:migrate`
4. Set required env vars (see `.env.example`)
5. Start server: `npm run dev`
**Rollback**: Drop database, stop containers, revert to previous commit. No shared state in Phase 1 (single-instance).
## Open Questions
- _None_ — all decisions required for Phase 1 implementation are resolved above.

View File

@@ -0,0 +1,36 @@
## Why
SentryAgent.ai AgentIdP has no implemented codebase — only scaffolding exists. Phase 1 MVP must ship a production-ready Agent Identity Provider so developers worldwide can register, authenticate, and govern their AI agents for free. All four P0 features have CEO-approved OpenAPI 3.0 specs and are ready for implementation.
## What Changes
- **NEW**: Agent Registry Service — full CRUD lifecycle management for AI agent identities (AGNTCY-aligned)
- **NEW**: OAuth 2.0 Token Service — Client Credentials grant (RFC 6749), token introspection (RFC 7662), token revocation (RFC 7009)
- **NEW**: Credential Management Service — generate, rotate, and revoke agent `client_id`/`client_secret` pairs
- **NEW**: Audit Log Service — immutable, append-only compliance event log (read-only via API)
- **NEW**: Express.js application bootstrap — routing, middleware (helmet, cors, morgan, pino), error handling
- **NEW**: PostgreSQL database layer — migrations, connection pool, typed query services
- **NEW**: Redis caching layer — token validation cache, rate-limit counters
- **NEW**: Shared infrastructure — typed error hierarchy, Joi validation, JWT utilities, crypto utilities, DI container
## Capabilities
### New Capabilities
- `agent-registry`: Register, retrieve, update, and decommission AI agent identities with AGNTCY-aligned fields (`agentId`, `email`, `agentType`, `capabilities`, `owner`, `deploymentEnv`, `status`)
- `oauth2-token`: Issue signed JWT access tokens via OAuth 2.0 Client Credentials flow; introspect and revoke tokens per RFC
- `credential-management`: Generate and rotate `client_id`/`client_secret` pairs per agent; revoke credentials; `clientSecret` shown once only
- `audit-log`: Query immutable audit events by `agentId`, `action`, `outcome`, and date range; 90-day free-tier retention
### Modified Capabilities
_None — this is a greenfield implementation._
## Impact
- **APIs**: 14 new REST endpoints across 4 services (`/agents`, `/token`, `/agents/{id}/credentials`, `/audit`)
- **Database**: 4 new PostgreSQL tables (`agents`, `tokens`, `credentials`, `audit_events`) with migrations
- **Cache**: Redis used for token validation and rate-limit counters
- **Dependencies**: Express, Joi, jsonwebtoken, bcryptjs, uuid, pg, redis, pino, helmet, cors, dotenv (all pre-approved in README Section 7)
- **Auth**: All endpoints require Bearer JWT; token endpoint uses `client_id`/`client_secret`
- **Free tier enforcement**: 100 agents max, 10,000 tokens/month, 100 req/min rate limit, 90-day audit retention

View File

@@ -0,0 +1,86 @@
## ADDED Requirements
### Requirement: Register a new AI agent
The system SHALL create a new agent identity record with a system-assigned immutable UUID (`agentId`) when a valid `CreateAgentRequest` is received. The `email` field SHALL be unique across all agents. The agent SHALL be created with `status: active`. The system SHALL enforce a free-tier limit of 100 registered agents per account.
#### Scenario: Successful agent registration
- **WHEN** a POST request to `/agents` is received with a valid `CreateAgentRequest` body and a valid Bearer token
- **THEN** the system creates the agent, assigns a UUID `agentId`, sets `status` to `active`, sets `createdAt` and `updatedAt` to the current timestamp, and returns `201` with the full `Agent` object
#### Scenario: Duplicate email rejected
- **WHEN** a POST request to `/agents` is received with an `email` that is already registered
- **THEN** the system returns `409 Conflict` with `code: AGENT_ALREADY_EXISTS`
#### Scenario: Free tier limit enforced
- **WHEN** a POST request to `/agents` is received and the account already has 100 registered agents
- **THEN** the system returns `403 Forbidden` with `code: FREE_TIER_LIMIT_EXCEEDED` and `details.limit: 100`
#### Scenario: Invalid request body rejected
- **WHEN** a POST request to `/agents` is received with a missing required field or invalid field value (e.g. invalid semver, invalid email, invalid capability pattern)
- **THEN** the system returns `400 Bad Request` with `code: VALIDATION_ERROR` and `details` identifying the failing field
### Requirement: Retrieve a single agent by ID
The system SHALL return the full `Agent` record for a given `agentId`.
#### Scenario: Agent found
- **WHEN** a GET request to `/agents/{agentId}` is received with a valid Bearer token and a UUID that exists in the registry
- **THEN** the system returns `200 OK` with the full `Agent` object
#### Scenario: Agent not found
- **WHEN** a GET request to `/agents/{agentId}` is received with a UUID that does not exist
- **THEN** the system returns `404 Not Found` with `code: AGENT_NOT_FOUND`
### Requirement: List agents with pagination and filtering
The system SHALL return a paginated list of agents, orderd by `createdAt` descending, optionally filtered by `owner`, `agentType`, and/or `status`.
#### Scenario: Successful paginated list
- **WHEN** a GET request to `/agents` is received with optional `page`, `limit`, `owner`, `agentType`, `status` query parameters and a valid Bearer token
- **THEN** the system returns `200 OK` with a `PaginatedAgentsResponse` containing `data`, `total`, `page`, and `limit`
#### Scenario: Invalid pagination parameters rejected
- **WHEN** a GET request to `/agents` is received with `limit` greater than 100 or `page` less than 1
- **THEN** the system returns `400 Bad Request` with `code: VALIDATION_ERROR`
### Requirement: Update agent metadata
The system SHALL partially update a mutable agent record. `agentId`, `email`, and `createdAt` SHALL be immutable. Setting `status` to `decommissioned` SHALL be a one-way irreversible operation.
#### Scenario: Successful partial update
- **WHEN** a PATCH request to `/agents/{agentId}` is received with a valid partial `UpdateAgentRequest` body and a valid Bearer token
- **THEN** the system updates only the provided fields, sets `updatedAt` to the current timestamp, and returns `200 OK` with the full updated `Agent` object
#### Scenario: Attempt to modify immutable field rejected
- **WHEN** a PATCH request to `/agents/{agentId}` contains the `email` field
- **THEN** the system returns `400 Bad Request` with `code: IMMUTABLE_FIELD` and `details.field: email`
#### Scenario: Decommissioned agent cannot be updated
- **WHEN** a PATCH request to `/agents/{agentId}` targets an agent with `status: decommissioned`
- **THEN** the system returns `403 Forbidden` with `code: AGENT_DECOMMISSIONED`
### Requirement: Decommission (soft-delete) an agent
The system SHALL set an agent's `status` to `decommissioned` and revoke all of its active credentials. The agent record SHALL be retained for audit purposes. This operation SHALL be irreversible.
#### Scenario: Successful decommission
- **WHEN** a DELETE request to `/agents/{agentId}` is received with a valid Bearer token and the agent exists and is not already decommissioned
- **THEN** the system sets `status` to `decommissioned`, revokes all active credentials for this agent, and returns `204 No Content`
#### Scenario: Already decommissioned agent rejected
- **WHEN** a DELETE request to `/agents/{agentId}` is received for an agent that is already `decommissioned`
- **THEN** the system returns `409 Conflict` with `code: AGENT_ALREADY_DECOMMISSIONED`
### Requirement: Authentication required on all agent endpoints
All agent endpoints SHALL require a valid Bearer JWT in the `Authorization` header.
#### Scenario: Missing token rejected
- **WHEN** any request to `/agents` or `/agents/{agentId}` is received without an `Authorization: Bearer` header
- **THEN** the system returns `401 Unauthorized` with `code: UNAUTHORIZED`
#### Scenario: Invalid token rejected
- **WHEN** any request to `/agents` or `/agents/{agentId}` is received with an expired, malformed, or revoked Bearer token
- **THEN** the system returns `401 Unauthorized` with `code: UNAUTHORIZED`
### Requirement: Rate limiting on all agent endpoints
The system SHALL enforce a rate limit of 100 requests per minute per authenticated client. Rate limit state SHALL be tracked in Redis.
#### Scenario: Rate limit exceeded
- **WHEN** a client sends more than 100 requests to any agent endpoint within a 60-second window
- **THEN** the system returns `429 Too Many Requests` with `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset` headers

View File

@@ -0,0 +1,72 @@
## ADDED Requirements
### Requirement: Audit events are written internally for all significant actions
The system SHALL automatically create an immutable `AuditEvent` record for each of the following actions: `agent.created`, `agent.updated`, `agent.decommissioned`, `agent.suspended`, `agent.reactivated`, `token.issued`, `token.revoked`, `token.introspected`, `credential.generated`, `credential.rotated`, `credential.revoked`, `auth.failed`. No API endpoint SHALL allow external creation, modification, or deletion of audit records.
#### Scenario: Audit event created on agent registration
- **WHEN** a new agent is successfully registered via `POST /agents`
- **THEN** an `AuditEvent` with `action: agent.created`, `outcome: success`, and `metadata` containing `agentType` and `owner` is persisted
#### Scenario: Audit event created on failed authentication
- **WHEN** a `POST /token` request fails due to invalid credentials
- **THEN** an `AuditEvent` with `action: auth.failed`, `outcome: failure`, and `metadata` containing `reason` and `clientId` is persisted
#### Scenario: Audit event created on token issuance
- **WHEN** a token is successfully issued via `POST /token`
- **THEN** an `AuditEvent` with `action: token.issued`, `outcome: success`, and `metadata` containing `scope` and `expiresAt` is persisted
### Requirement: Query the audit log with pagination and filtering
The system SHALL return a paginated list of audit events ordered by `timestamp` descending. The caller SHALL hold a valid Bearer token with `audit:read` scope. Filtering SHALL support `agentId`, `action`, `outcome`, `fromDate`, and `toDate` — all optional, combined with logical AND.
#### Scenario: Successful audit log query
- **WHEN** a GET request to `/audit` is received with a valid Bearer token with `audit:read` scope
- **THEN** the system returns `200 OK` with a `PaginatedAuditEventsResponse` containing `data`, `total`, `page`, and `limit`
#### Scenario: Filter by agentId
- **WHEN** a GET request to `/audit?agentId={uuid}` is received
- **THEN** only events where `agentId` equals the provided UUID are returned
#### Scenario: Filter by action
- **WHEN** a GET request to `/audit?action=token.issued` is received
- **THEN** only events with `action: token.issued` are returned
#### Scenario: Filter by date range
- **WHEN** a GET request to `/audit?fromDate=2026-03-01T00:00:00.000Z&toDate=2026-03-28T23:59:59.999Z` is received
- **THEN** only events with `timestamp` within the specified range are returned
#### Scenario: fromDate after toDate rejected
- **WHEN** a GET request to `/audit` is received with `fromDate` that is chronologically after `toDate`
- **THEN** the system returns `400 Bad Request` with `code: VALIDATION_ERROR` and `details.reason` explaining the invalid date range
#### Scenario: Insufficient scope rejected
- **WHEN** a GET request to `/audit` is received with a valid Bearer token that does not have `audit:read` scope
- **THEN** the system returns `403 Forbidden` with `code: INSUFFICIENT_SCOPE`
### Requirement: Retrieve a single audit event by ID
The system SHALL return a single immutable `AuditEvent` by its `eventId`. The caller SHALL hold a valid Bearer token with `audit:read` scope.
#### Scenario: Audit event found
- **WHEN** a GET request to `/audit/{eventId}` is received with a valid Bearer token with `audit:read` scope and a UUID that exists in the audit log
- **THEN** the system returns `200 OK` with the full `AuditEvent` object
#### Scenario: Audit event not found
- **WHEN** a GET request to `/audit/{eventId}` is received with a UUID that does not exist in the audit log
- **THEN** the system returns `404 Not Found` with `code: AUDIT_EVENT_NOT_FOUND`
### Requirement: Free-tier 90-day audit log retention
On the free tier, the system SHALL only return audit events from the last 90 days. Events older than 90 days SHALL be treated as not accessible (return empty results for queries, `404` for direct lookups). The system SHALL return a `400` error with `code: RETENTION_WINDOW_EXCEEDED` if a `fromDate` query parameter falls outside the 90-day retention window.
#### Scenario: Query outside retention window rejected
- **WHEN** a GET request to `/audit` is received with `fromDate` more than 90 days before today
- **THEN** the system returns `400 Bad Request` with `code: RETENTION_WINDOW_EXCEEDED` and `details.retentionDays: 90`
#### Scenario: Direct lookup of expired event returns 404
- **WHEN** a GET request to `/audit/{eventId}` is received for an event with a `timestamp` older than 90 days
- **THEN** the system returns `404 Not Found` with `code: AUDIT_EVENT_NOT_FOUND`
### Requirement: Rate limiting on audit endpoints
The system SHALL enforce a rate limit of 100 requests per minute per authenticated client on all audit endpoints.
#### Scenario: Rate limit exceeded on audit endpoint
- **WHEN** a client sends more than 100 requests to any audit endpoint within a 60-second window
- **THEN** the system returns `429 Too Many Requests` with `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset` headers

View File

@@ -0,0 +1,83 @@
## ADDED Requirements
### Requirement: Generate new credentials for an agent
The system SHALL generate a new `client_id`/`client_secret` pair for a specified agent. The `client_id` SHALL equal the agent's `agentId`. The `client_secret` SHALL be a cryptographically random string with the prefix `sk_live_` followed by 64 hex characters (256 bits of entropy). The plain-text secret SHALL be returned in the response exactly once and SHALL never be stored in plain text — only a bcrypt hash (10 rounds) SHALL be persisted. The agent MUST be in `active` status to generate credentials.
#### Scenario: Successful credential generation
- **WHEN** a POST request to `/agents/{agentId}/credentials` is received with a valid Bearer token and the agent exists with `status: active`
- **THEN** the system generates a new credential, persists the bcrypt hash of the secret, and returns `201 Created` with a `CredentialWithSecret` response including the plain-text `clientSecret`
#### Scenario: clientSecret not returned after creation
- **WHEN** a GET request to `/agents/{agentId}/credentials` is made after credential creation
- **THEN** the `clientSecret` field is NOT present in any `Credential` object in the response
#### Scenario: Suspended agent cannot generate credentials
- **WHEN** a POST request to `/agents/{agentId}/credentials` is received for an agent with `status: suspended`
- **THEN** the system returns `403 Forbidden` with `code: AGENT_NOT_ACTIVE`
#### Scenario: Decommissioned agent cannot generate credentials
- **WHEN** a POST request to `/agents/{agentId}/credentials` is received for an agent with `status: decommissioned`
- **THEN** the system returns `403 Forbidden` with `code: AGENT_NOT_ACTIVE`
#### Scenario: Optional expiry respected
- **WHEN** a POST request to `/agents/{agentId}/credentials` is received with an `expiresAt` value that is a future date-time
- **THEN** the credential is created with the specified `expiresAt` value
#### Scenario: Past expiry rejected
- **WHEN** a POST request to `/agents/{agentId}/credentials` is received with an `expiresAt` value that is in the past
- **THEN** the system returns `400 Bad Request` with `code: VALIDATION_ERROR` and `details.field: expiresAt`
#### Scenario: Agent not found
- **WHEN** a POST request to `/agents/{agentId}/credentials` is received for a `agentId` that does not exist
- **THEN** the system returns `404 Not Found` with `code: AGENT_NOT_FOUND`
### Requirement: List credentials for an agent
The system SHALL return a paginated list of all credentials (both `active` and `revoked`) for an agent, ordered by `createdAt` descending. The `clientSecret` SHALL never be included in list responses.
#### Scenario: Successful credential list
- **WHEN** a GET request to `/agents/{agentId}/credentials` is received with optional `page`, `limit`, `status` query parameters and a valid Bearer token
- **THEN** the system returns `200 OK` with a `PaginatedCredentialsResponse` containing `data`, `total`, `page`, and `limit`, with no `clientSecret` fields
#### Scenario: Filter by status
- **WHEN** a GET request to `/agents/{agentId}/credentials?status=active` is received
- **THEN** only credentials with `status: active` are returned
### Requirement: Rotate a credential
The system SHALL rotate an existing active credential by generating a new `clientSecret` for the same `credentialId`. The previous secret SHALL be immediately invalidated. The new plain-text secret SHALL be returned once and never persisted. Only `active` credentials can be rotated.
#### Scenario: Successful rotation
- **WHEN** a POST request to `/agents/{agentId}/credentials/{credentialId}/rotate` is received with a valid Bearer token and the credential exists with `status: active`
- **THEN** the system generates a new secret, replaces the stored bcrypt hash, and returns `200 OK` with a `CredentialWithSecret` response including the new plain-text `clientSecret`. The `credentialId` remains unchanged.
#### Scenario: Revoked credential cannot be rotated
- **WHEN** a POST request to `/agents/{agentId}/credentials/{credentialId}/rotate` is received for a credential with `status: revoked`
- **THEN** the system returns `409 Conflict` with `code: CREDENTIAL_ALREADY_REVOKED`
#### Scenario: Credential not found
- **WHEN** a POST request to `/agents/{agentId}/credentials/{credentialId}/rotate` is received with a `credentialId` that does not exist for the given agent
- **THEN** the system returns `404 Not Found` with `code: CREDENTIAL_NOT_FOUND`
### Requirement: Revoke a credential
The system SHALL permanently revoke a credential by setting its `status` to `revoked` and recording a `revokedAt` timestamp. The credential record SHALL be retained for audit purposes. Revocation SHALL be irreversible. Tokens previously issued with this credential SHALL remain valid until their natural expiry (token revocation is handled separately via `POST /token/revoke`). Revoking an already-revoked credential SHALL return `409 Conflict`.
#### Scenario: Successful revocation
- **WHEN** a DELETE request to `/agents/{agentId}/credentials/{credentialId}` is received with a valid Bearer token and the credential exists with `status: active`
- **THEN** the system sets `status` to `revoked`, sets `revokedAt` to the current timestamp, and returns `204 No Content`
#### Scenario: Already-revoked credential rejected
- **WHEN** a DELETE request to `/agents/{agentId}/credentials/{credentialId}` is received for a credential that is already `revoked`
- **THEN** the system returns `409 Conflict` with `code: CREDENTIAL_ALREADY_REVOKED`
### Requirement: Agent decommission cascades to credential revocation
When an agent is decommissioned via `DELETE /agents/{agentId}`, the system SHALL revoke all active credentials for that agent as part of the same operation.
#### Scenario: All credentials revoked on agent decommission
- **WHEN** an agent is successfully decommissioned via `DELETE /agents/{agentId}`
- **THEN** all credentials for that agent with `status: active` are set to `status: revoked` with `revokedAt` = current timestamp
### Requirement: Authentication required on all credential endpoints
All credential endpoints SHALL require a valid Bearer JWT. An agent MAY manage its own credentials using a self-issued token. Managing another agent's credentials SHALL return `403 Forbidden` unless the caller holds an admin-scoped token (admin scope is not implemented in Phase 1 — return `403` for all cross-agent requests).
#### Scenario: Unauthenticated request rejected
- **WHEN** any request to `/agents/{agentId}/credentials` is received without a valid Bearer token
- **THEN** the system returns `401 Unauthorized` with `code: UNAUTHORIZED`

View File

@@ -0,0 +1,76 @@
## ADDED Requirements
### Requirement: Issue access token via Client Credentials grant
The system SHALL issue a signed RS256 JWT access token when an agent authenticates with a valid `client_id` (agentId) and `client_secret` using the OAuth 2.0 Client Credentials grant (RFC 6749 §4.4). The request body SHALL use `application/x-www-form-urlencoded` encoding. The response SHALL include `Cache-Control: no-store` and `Pragma: no-cache` headers. The system SHALL enforce a free-tier limit of 10,000 token requests per calendar month per client.
#### Scenario: Successful token issuance
- **WHEN** a POST request to `/token` is received with `grant_type=client_credentials`, a valid `client_id`, and a valid `client_secret` for an `active` agent
- **THEN** the system verifies the credential, issues a signed JWT with `sub` = `agentId`, `scope` = requested (or default) scope, `exp` = now + 3600s, and returns `200 OK` with `TokenResponse`
#### Scenario: Invalid client credentials rejected
- **WHEN** a POST request to `/token` is received with a `client_id` that does not exist or a `client_secret` that does not match
- **THEN** the system returns `401 Unauthorized` with `error: invalid_client`
#### Scenario: Suspended agent cannot obtain tokens
- **WHEN** a POST request to `/token` is received for an agent with `status: suspended`
- **THEN** the system returns `403 Forbidden` with `error: unauthorized_client` and a description indicating the agent is suspended
#### Scenario: Decommissioned agent cannot obtain tokens
- **WHEN** a POST request to `/token` is received for an agent with `status: decommissioned`
- **THEN** the system returns `403 Forbidden` with `error: unauthorized_client`
#### Scenario: Unsupported grant type rejected
- **WHEN** a POST request to `/token` is received with a `grant_type` other than `client_credentials`
- **THEN** the system returns `400 Bad Request` with `error: unsupported_grant_type`
#### Scenario: Invalid scope rejected
- **WHEN** a POST request to `/token` is received with a `scope` value that contains an unrecognised scope identifier
- **THEN** the system returns `400 Bad Request` with `error: invalid_scope`
#### Scenario: Free tier monthly token limit enforced
- **WHEN** a POST request to `/token` is received and the agent has already made 10,000 token requests in the current calendar month
- **THEN** the system returns `403 Forbidden` with `error: unauthorized_client` and a description indicating the monthly free-tier limit is reached
### Requirement: Token introspection (RFC 7662)
The system SHALL determine whether a given access token is currently active (valid, not expired, not revoked). The endpoint SHALL return `200 OK` for both active and inactive tokens — the `active` field in the response SHALL indicate validity. The caller SHALL hold a valid Bearer token with `tokens:read` scope.
#### Scenario: Active token introspection
- **WHEN** a POST request to `/token/introspect` is received with a valid, non-expired, non-revoked token and the caller has `tokens:read` scope
- **THEN** the system returns `200 OK` with `active: true` and the token's claims (`sub`, `client_id`, `scope`, `token_type`, `iat`, `exp`)
#### Scenario: Expired or revoked token introspection
- **WHEN** a POST request to `/token/introspect` is received with a token that is expired or has been revoked
- **THEN** the system returns `200 OK` with `active: false` and no other claims
#### Scenario: Insufficient scope for introspection
- **WHEN** a POST request to `/token/introspect` is received with a valid Bearer token that does not have `tokens:read` scope
- **THEN** the system returns `403 Forbidden` with `code: INSUFFICIENT_SCOPE`
### Requirement: Token revocation (RFC 7009)
The system SHALL invalidate a given access token immediately. Revoking an already-revoked or expired token SHALL be a successful, idempotent operation (RFC 7009 §2.1). Revoked token JTIs SHALL be stored in Redis with TTL equal to the token's remaining lifetime.
#### Scenario: Successful token revocation
- **WHEN** a POST request to `/token/revoke` is received with a valid Bearer token and a `token` parameter containing a valid JWT
- **THEN** the system adds the token's JTI to the Redis revocation list, and returns `200 OK` with an empty body
#### Scenario: Revocation of already-revoked token is idempotent
- **WHEN** a POST request to `/token/revoke` is received with a token that is already in the Redis revocation list
- **THEN** the system returns `200 OK` with an empty body (no error)
#### Scenario: Missing token parameter rejected
- **WHEN** a POST request to `/token/revoke` is received with no `token` field in the body
- **THEN** the system returns `400 Bad Request` with `code: VALIDATION_ERROR`
### Requirement: JWT claims structure
All issued JWTs SHALL contain the following claims: `sub` (agentId), `client_id` (agentId), `scope` (space-separated granted scopes), `jti` (UUID, unique per token), `iat` (issued-at Unix timestamp), `exp` (expiry Unix timestamp). Tokens SHALL be signed with RS256.
#### Scenario: JWT contains required claims
- **WHEN** a token is issued via `POST /token`
- **THEN** the decoded JWT payload contains `sub`, `client_id`, `scope`, `jti`, `iat`, and `exp` fields
### Requirement: Rate limiting on token endpoints
The system SHALL enforce a rate limit of 100 requests per minute per `client_id` on all token endpoints.
#### Scenario: Rate limit exceeded on token endpoint
- **WHEN** a client sends more than 100 requests to any token endpoint within a 60-second window
- **THEN** the system returns `429 Too Many Requests` with `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset` headers

View File

@@ -0,0 +1,83 @@
## 1. Project Bootstrap & Infrastructure
- [x] 1.1 Initialise `package.json` with all required dependencies (Express, TypeScript, Joi, jsonwebtoken, bcryptjs, uuid, pg, redis, pino, helmet, cors, dotenv, jest, supertest, ts-jest, ESLint, Prettier)
- [x] 1.2 Create `tsconfig.json` with strict mode enabled (all flags from README §6.4)
- [x] 1.3 Create `.eslintrc.json` with `@typescript-eslint` plugin and no-`any` rule
- [x] 1.4 Create `.prettierrc`
- [x] 1.5 Create `jest.config.ts` with `ts-jest` preset and coverage thresholds (>80%)
- [x] 1.6 Create `docker-compose.yml` with `postgres:14-alpine` and `redis:7-alpine` services
- [x] 1.7 Create `.env.example` documenting all required environment variables (`DATABASE_URL`, `REDIS_URL`, `JWT_PRIVATE_KEY`, `JWT_PUBLIC_KEY`, `PORT`, etc.)
## 2. Shared Infrastructure
- [x] 2.1 Create `src/types/index.ts` — all shared TypeScript interfaces (`IAgent`, `ICredential`, `IAuditEvent`, `ITokenPayload`, `ICreateAgentRequest`, `IUpdateAgentRequest`, etc.)
- [x] 2.2 Create `src/utils/errors.ts` — full `SentryAgentError` hierarchy (`ValidationError`, `AgentNotFoundError`, `AgentAlreadyExistsError`, `CredentialError`, `AuthenticationError`, `AuthorizationError`, `RateLimitError`, `FreeTierLimitError`)
- [x] 2.3 Create `src/utils/crypto.ts``generateClientSecret()` (sk_live_ prefix + 64 hex), `hashSecret(plain)` (bcrypt 10 rounds), `verifySecret(plain, hash)` (bcrypt compare)
- [x] 2.4 Create `src/utils/jwt.ts``signToken(payload, privateKey)` (RS256), `verifyToken(token, publicKey)` (returns typed payload), `decodeToken(token)` (no verification)
- [x] 2.5 Create `src/utils/validators.ts` — Joi schemas for `CreateAgentRequest`, `UpdateAgentRequest`, `TokenRequest`, `IntrospectRequest`, `RevokeRequest`, `GenerateCredentialRequest`, list query params
- [x] 2.6 Create `src/db/pool.ts` — typed `pg.Pool` singleton, reads `DATABASE_URL` from env
- [x] 2.7 Create `src/cache/redis.ts` — typed Redis client singleton, reads `REDIS_URL` from env
- [x] 2.8 Create `src/db/migrations/001_create_agents.sql``agents` table (all fields from OpenAPI spec, `status` as varchar)
- [x] 2.9 Create `src/db/migrations/002_create_credentials.sql``credentials` table (`credential_id`, `client_id`, `secret_hash`, `status`, `created_at`, `expires_at`, `revoked_at`)
- [x] 2.10 Create `src/db/migrations/003_create_audit_events.sql``audit_events` table (`event_id`, `agent_id`, `action`, `outcome`, `ip_address`, `user_agent`, `metadata` JSONB, `timestamp`)
- [x] 2.11 Create `src/db/migrations/004_create_tokens.sql``token_revocations` table (`jti`, `expires_at`) for soft revocation tracking (supplementary to Redis)
- [x] 2.12 Create `npm run db:migrate` script to execute migrations in order
## 3. Middleware
- [x] 3.1 Create `src/middleware/auth.ts` — Bearer token extraction from `Authorization` header, RS256 JWT verification, Redis revocation check, attaches decoded payload to `req.user`; throws `AuthenticationError` on failure
- [x] 3.2 Create `src/middleware/rateLimit.ts` — Redis sliding window counter keyed by `client_id`; injects `X-RateLimit-*` headers on every response; throws `RateLimitError` at 100 req/min
- [x] 3.3 Create `src/middleware/errorHandler.ts` — Express error middleware; maps `SentryAgentError` subclasses to HTTP status codes and `ErrorResponse` JSON; maps unknown errors to `500`
## 4. Agent Registry
- [x] 4.1 Create `src/repositories/AgentRepository.ts` — typed methods: `create`, `findById`, `findByEmail`, `findAll` (with filters + pagination), `update`, `decommission`, `countByOwner`; all SQL in this file only
- [x] 4.2 Create `src/services/AgentService.ts``registerAgent`, `getAgentById`, `listAgents`, `updateAgent`, `decommissionAgent`; enforces free-tier 100-agent limit; validates immutable fields on update; calls `AuditService` for all write operations; JSDoc on all public methods
- [x] 4.3 Create `src/controllers/AgentController.ts` — HTTP handlers for all 5 agent endpoints; Joi validation using `validators.ts`; delegates to `AgentService`; no business logic
- [x] 4.4 Create `src/routes/agents.ts` — Express router wiring `AgentController` handlers to paths with `auth` and `rateLimit` middleware
## 5. OAuth 2.0 Token Service
- [x] 5.1 Create `src/repositories/TokenRepository.ts``addToRevocationList(jti, expiresAt)`, `isRevoked(jti)` (checks Redis first, then DB); `incrementMonthlyCount(clientId)`, `getMonthlyCount(clientId)` (Redis-backed)
- [x] 5.2 Create `src/services/OAuth2Service.ts``issueToken` (validates client credentials via bcrypt, checks agent status, enforces 10k monthly limit, signs RS256 JWT, writes audit event), `introspectToken` (verifies + checks revocation), `revokeToken` (adds JTI to Redis + DB revocation list, writes audit event); JSDoc on all public methods
- [x] 5.3 Create `src/controllers/TokenController.ts` — HTTP handlers for `POST /token`, `POST /token/introspect`, `POST /token/revoke`; parses `application/x-www-form-urlencoded`; delegates to `OAuth2Service`; returns `OAuth2ErrorResponse` for `/token` errors, `ErrorResponse` for introspect/revoke errors
- [x] 5.4 Create `src/routes/token.ts` — Express router; `/token` uses no Bearer auth middleware (credentials are in body); `/token/introspect` and `/token/revoke` use `auth` middleware
## 6. Credential Management
- [x] 6.1 Create `src/repositories/CredentialRepository.ts``create`, `findById`, `findByAgentId` (with pagination + status filter), `updateHash`, `revoke`, `revokeAllForAgent`; all SQL here only
- [x] 6.2 Create `src/services/CredentialService.ts``generateCredential` (checks agent active status, generates secret via `crypto.ts`, bcrypt-hashes, persists), `listCredentials`, `rotateCredential` (generates new secret, replaces hash, same credentialId), `revokeCredential`; calls `AuditService` for all write operations; JSDoc on all public methods
- [x] 6.3 Create `src/controllers/CredentialController.ts` — HTTP handlers for all 4 credential endpoints; Joi validation; delegates to `CredentialService`
- [x] 6.4 Create `src/routes/credentials.ts` — Express router under `/agents/:agentId/credentials` with `auth` and `rateLimit` middleware
## 7. Audit Log Service
- [x] 7.1 Create `src/repositories/AuditRepository.ts``create(event)`, `findById(eventId)`, `findAll(filters, pagination)` with support for `agentId`, `action`, `outcome`, `fromDate`, `toDate` filtering and 90-day retention window enforcement
- [x] 7.2 Create `src/services/AuditService.ts``logEvent(agentId, action, outcome, ipAddress, userAgent, metadata)` (async insert, fire-and-forget for token endpoints); `queryEvents(filters, pagination)`, `getEventById(eventId)`; enforces 90-day retention on queries; JSDoc on all public methods
- [x] 7.3 Create `src/controllers/AuditController.ts` — HTTP handlers for `GET /audit` and `GET /audit/{eventId}`; scope check for `audit:read`; Joi validation of query params
- [x] 7.4 Create `src/routes/audit.ts` — Express router with `auth` and `rateLimit` middleware
## 8. Application Assembly
- [x] 8.1 Create `src/app.ts` — Express app factory: registers `helmet`, `cors`, `morgan`/`pino-http`, JSON body parser, `urlencoded` body parser (for token endpoints), all 4 route modules, and `errorHandler` middleware; exported function (not called directly — testable)
- [x] 8.2 Create `src/server.ts` — imports `app.ts`, reads `PORT` from env, calls `app.listen`; entry point only
## 9. Unit Tests
- [x] 9.1 Write unit tests for `src/utils/crypto.ts` — secret generation format, bcrypt hash/verify round-trip
- [x] 9.2 Write unit tests for `src/utils/jwt.ts` — sign/verify/decode with RS256 test keys
- [x] 9.3 Write unit tests for `src/utils/validators.ts` — valid and invalid inputs for every Joi schema
- [x] 9.4 Write unit tests for `src/services/AgentService.ts` — mock `AgentRepository` and `AuditService`; cover all scenarios from agent-registry spec
- [x] 9.5 Write unit tests for `src/services/OAuth2Service.ts` — mock `TokenRepository`, `CredentialRepository`, `AuditService`; cover all scenarios from oauth2-token spec
- [x] 9.6 Write unit tests for `src/services/CredentialService.ts` — mock `CredentialRepository`, `AgentRepository`, `AuditService`; cover all scenarios from credential-management spec
- [x] 9.7 Write unit tests for `src/services/AuditService.ts` — mock `AuditRepository`; cover query, filter, and retention logic
- [x] 9.8 Write unit tests for `src/middleware/auth.ts` — valid token, expired token, revoked token, missing header
- [x] 9.9 Write unit tests for `src/middleware/errorHandler.ts` — each `SentryAgentError` subclass maps to correct HTTP status and error code
## 10. Integration Tests
- [x] 10.1 Write integration tests for Agent Registry (`tests/integration/agents.test.ts`) — all 5 endpoints, all response codes, pagination, filtering; uses real Postgres (test DB) and Redis
- [x] 10.2 Write integration tests for OAuth2 Token Service (`tests/integration/token.test.ts`) — all 3 endpoints, all response codes, token issuance and revocation flow, RFC compliance
- [x] 10.3 Write integration tests for Credential Management (`tests/integration/credentials.test.ts`) — all 4 endpoints, all response codes, full rotate-then-revoke flow
- [x] 10.4 Write integration tests for Audit Log Service (`tests/integration/audit.test.ts`) — query with all filter combinations, single event retrieval, retention window enforcement
- [x] 10.5 Verify test coverage meets >80% threshold across all services (`npm test -- --coverage`)

20
openspec/config.yaml Normal file
View File

@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours

7369
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@@ -0,0 +1,56 @@
{
"name": "sentryagent-idp",
"version": "1.0.0",
"description": "SentryAgent.ai Agent Identity Provider (AgentIdP)",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts",
"test": "jest",
"test:unit": "jest tests/unit",
"test:integration": "jest tests/integration",
"db:migrate": "ts-node scripts/migrate.ts",
"lint": "eslint src --ext .ts",
"format": "prettier --write src/**/*.ts"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"helmet": "^7.1.0",
"joi": "^17.12.3",
"jsonwebtoken": "^9.0.2",
"morgan": "^1.10.0",
"pg": "^8.11.3",
"pino": "^8.19.0",
"pino-http": "^9.0.0",
"redis": "^4.6.13",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/jsonwebtoken": "^9.0.6",
"@types/morgan": "^1.9.9",
"@types/node": "^20.12.7",
"@types/pg": "^8.11.5",
"@types/supertest": "^6.0.2",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"supertest": "^6.3.4",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"engines": {
"node": ">=18.0.0"
}
}

120
scripts/migrate.ts Normal file
View File

@@ -0,0 +1,120 @@
/**
* Database migration runner for SentryAgent.ai AgentIdP.
* Reads all .sql files from src/db/migrations/ in alphabetical order,
* tracks applied migrations in a schema_migrations table, and executes
* only unapplied migrations.
*/
import * as fs from 'fs';
import * as path from 'path';
import { Pool } from 'pg';
import * as dotenv from 'dotenv';
dotenv.config();
const MIGRATIONS_DIR = path.join(__dirname, '../src/db/migrations');
interface MigrationRow {
name: string;
applied_at: Date;
}
/**
* Ensures the schema_migrations tracking table exists.
*
* @param pool - The PostgreSQL connection pool.
*/
async function ensureMigrationsTable(pool: Pool): Promise<void> {
await pool.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
name VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
}
/**
* Returns the list of already-applied migration names.
*
* @param pool - The PostgreSQL connection pool.
* @returns Array of applied migration names.
*/
async function getAppliedMigrations(pool: Pool): Promise<string[]> {
const result = await pool.query<MigrationRow>('SELECT name FROM schema_migrations ORDER BY name');
return result.rows.map((row) => row.name);
}
/**
* Applies a single migration file within a transaction.
*
* @param pool - The PostgreSQL connection pool.
* @param name - The migration file name (without path).
* @param sql - The SQL to execute.
*/
async function applyMigration(pool: Pool, name: string, sql: string): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(sql);
await client.query('INSERT INTO schema_migrations (name) VALUES ($1)', [name]);
await client.query('COMMIT');
// eslint-disable-next-line no-console
console.log(` ✓ Applied: ${name}`);
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
/**
* Main migration runner.
* Reads all .sql files in alphabetical order and applies unapplied ones.
*/
async function migrate(): Promise<void> {
const connectionString = process.env['DATABASE_URL'];
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required');
}
const pool = new Pool({ connectionString });
try {
// eslint-disable-next-line no-console
console.log('Running database migrations...');
await ensureMigrationsTable(pool);
const applied = await getAppliedMigrations(pool);
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
let count = 0;
for (const file of files) {
if (applied.includes(file)) {
// eslint-disable-next-line no-console
console.log(` - Skipped (already applied): ${file}`);
continue;
}
const filePath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(filePath, 'utf-8');
await applyMigration(pool, file, sql);
count++;
}
// eslint-disable-next-line no-console
console.log(`\nMigrations complete. ${count} migration(s) applied.`);
} finally {
await pool.end();
}
}
migrate().catch((err: unknown) => {
// eslint-disable-next-line no-console
console.error('Migration failed:', err);
process.exit(1);
});

46
scripts/start-cto.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/bin/bash
# =============================================================================
# SentryAgent.ai — Start Virtual CTO Agent
# =============================================================================
# Launches a separate Claude Code instance as the Virtual CTO.
# The CTO will register on the central hub and await CEO instructions.
#
# Usage:
# ./scripts/start-cto.sh
#
# The CTO agent runs in its own terminal session and communicates
# with the CEO via the central hub (#vpe-cto-approvals channel).
# =============================================================================
set -e
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CTO_WORKSPACE="$PROJECT_ROOT/.cto-workspace"
echo "=============================================="
echo " SentryAgent.ai — Starting Virtual CTO Agent"
echo "=============================================="
echo ""
echo " Project: $PROJECT_ROOT"
echo " Workspace: $CTO_WORKSPACE"
echo " Hub Channel: #vpe-cto-approvals"
echo ""
echo " The Virtual CTO will:"
echo " 1. Read README.md"
echo " 2. Register on central hub as VirtualCTO"
echo " 3. Report status to CEO"
echo " 4. Await CEO priorities"
echo ""
echo "=============================================="
echo ""
# Verify the CTO workspace exists
if [ ! -f "$CTO_WORKSPACE/CLAUDE.md" ]; then
echo "ERROR: CTO workspace not found at $CTO_WORKSPACE/CLAUDE.md"
echo "Please ensure the project is set up correctly."
exit 1
fi
# Launch Claude Code in the CTO workspace
cd "$CTO_WORKSPACE"
exec claude

138
src/app.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Express application factory for SentryAgent.ai AgentIdP.
* Creates and configures the Express app with all middleware and routes.
* Exported as a factory function — does NOT call listen (testable).
*/
import express, { Application } from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import { getPool } from './db/pool.js';
import { getRedisClient } from './cache/redis.js';
import { AgentRepository } from './repositories/AgentRepository.js';
import { CredentialRepository } from './repositories/CredentialRepository.js';
import { TokenRepository } from './repositories/TokenRepository.js';
import { AuditRepository } from './repositories/AuditRepository.js';
import { AuditService } from './services/AuditService.js';
import { AgentService } from './services/AgentService.js';
import { CredentialService } from './services/CredentialService.js';
import { OAuth2Service } from './services/OAuth2Service.js';
import { AgentController } from './controllers/AgentController.js';
import { TokenController } from './controllers/TokenController.js';
import { CredentialController } from './controllers/CredentialController.js';
import { AuditController } from './controllers/AuditController.js';
import { createAgentsRouter } from './routes/agents.js';
import { createTokenRouter } from './routes/token.js';
import { createCredentialsRouter } from './routes/credentials.js';
import { createAuditRouter } from './routes/audit.js';
import { errorHandler } from './middleware/errorHandler.js';
import { RedisClientType } from 'redis';
/**
* Creates and returns a configured Express application.
* All infrastructure dependencies (DB pool, Redis) are initialised here.
*
* @returns Promise resolving to the configured Express Application.
* @throws Error if required environment variables are missing.
*/
export async function createApp(): Promise<Application> {
const app = express();
// ────────────────────────────────────────────────────────────────
// Security headers
// ────────────────────────────────────────────────────────────────
app.use(helmet());
// ────────────────────────────────────────────────────────────────
// CORS
// ────────────────────────────────────────────────────────────────
const corsOrigin = process.env['CORS_ORIGIN'] ?? '*';
app.use(cors({ origin: corsOrigin }));
// ────────────────────────────────────────────────────────────────
// Request logging
// ────────────────────────────────────────────────────────────────
if (process.env['NODE_ENV'] !== 'test') {
app.use(morgan('combined'));
}
// ────────────────────────────────────────────────────────────────
// Body parsers
// JSON body parser for most routes
// urlencoded parser for token endpoint (application/x-www-form-urlencoded)
// ────────────────────────────────────────────────────────────────
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
// ────────────────────────────────────────────────────────────────
// Infrastructure singletons
// ────────────────────────────────────────────────────────────────
const pool = getPool();
const redis = await getRedisClient();
// ────────────────────────────────────────────────────────────────
// Repository layer
// ────────────────────────────────────────────────────────────────
const agentRepo = new AgentRepository(pool);
const credentialRepo = new CredentialRepository(pool);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
const tokenRepo = new TokenRepository(pool, redis as RedisClientType);
const auditRepo = new AuditRepository(pool);
// ────────────────────────────────────────────────────────────────
// Service layer
// ────────────────────────────────────────────────────────────────
const auditService = new AuditService(auditRepo);
const agentService = new AgentService(agentRepo, credentialRepo, auditService);
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService);
const privateKey = process.env['JWT_PRIVATE_KEY'];
const publicKey = process.env['JWT_PUBLIC_KEY'];
if (!privateKey || !publicKey) {
throw new Error('JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required');
}
const oauth2Service = new OAuth2Service(
tokenRepo,
credentialRepo,
agentRepo,
auditService,
privateKey,
publicKey,
);
// ────────────────────────────────────────────────────────────────
// Controller layer
// ────────────────────────────────────────────────────────────────
const agentController = new AgentController(agentService);
const tokenController = new TokenController(oauth2Service);
const credentialController = new CredentialController(credentialService);
const auditController = new AuditController(auditService);
// ────────────────────────────────────────────────────────────────
// Routes
// ────────────────────────────────────────────────────────────────
const API_BASE = '/api/v1';
app.use(`${API_BASE}/agents`, createAgentsRouter(agentController));
app.use(
`${API_BASE}/agents/:agentId/credentials`,
createCredentialsRouter(credentialController),
);
app.use(`${API_BASE}/token`, createTokenRouter(tokenController));
app.use(`${API_BASE}/audit`, createAuditRouter(auditController));
// ────────────────────────────────────────────────────────────────
// Global error handler (must be last)
// ────────────────────────────────────────────────────────────────
app.use(errorHandler);
return app;
}

47
src/cache/redis.ts vendored Normal file
View File

@@ -0,0 +1,47 @@
/**
* Redis client singleton for SentryAgent.ai AgentIdP.
* Used for token revocation tracking, rate limiting, and monthly token counts.
*/
import { createClient, RedisClientType } from 'redis';
let redisClient: RedisClientType | null = null;
/**
* Returns the singleton Redis client instance.
* Initialises and connects the client on first call using REDIS_URL from env.
*
* @returns Promise resolving to the connected Redis client.
* @throws Error if REDIS_URL is not set or connection fails.
*/
export async function getRedisClient(): Promise<RedisClientType> {
if (!redisClient) {
const url = process.env['REDIS_URL'];
if (!url) {
throw new Error('REDIS_URL environment variable is required');
}
redisClient = createClient({ url }) as RedisClientType;
redisClient.on('error', (err: Error) => {
// eslint-disable-next-line no-console
console.error('Redis client error', err);
});
await redisClient.connect();
}
return redisClient;
}
/**
* Disconnects the Redis client and resets the singleton.
* Used for graceful shutdown and tests.
*
* @returns Promise that resolves when the client is disconnected.
*/
export async function closeRedisClient(): Promise<void> {
if (redisClient) {
await redisClient.quit();
redisClient = null;
}
}

View File

@@ -0,0 +1,186 @@
/**
* Agent Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for all 5 agent endpoints. No business logic — delegates to AgentService.
*/
import { Request, Response, NextFunction } from 'express';
import { AgentService } from '../services/AgentService.js';
import {
createAgentSchema,
updateAgentSchema,
listAgentsQuerySchema,
} from '../utils/validators.js';
import { ValidationError, AuthorizationError } from '../utils/errors.js';
import {
ICreateAgentRequest,
IUpdateAgentRequest,
IAgentListFilters,
} from '../types/index.js';
/**
* Controller for the Agent Registry endpoints.
* Receives AgentService via constructor injection.
*/
export class AgentController {
/**
* @param agentService - The agent registry service.
*/
constructor(private readonly agentService: AgentService) {}
/**
* Handles POST /agents — registers a new agent.
*
* @param req - Express request with CreateAgentRequest body.
* @param res - Express response.
* @param next - Express next function.
*/
registerAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = createAgentSchema.validate(req.body, { abortEarly: false });
if (error) {
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const data = value as ICreateAgentRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const agent = await this.agentService.registerAgent(data, ipAddress, userAgent);
res.status(201).json(agent);
} catch (err) {
next(err);
}
};
/**
* Handles GET /agents — returns a paginated list of agents.
*
* @param req - Express request with optional query filters.
* @param res - Express response.
* @param next - Express next function.
*/
listAgents = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = listAgentsQuerySchema.validate(req.query, { abortEarly: false });
if (error) {
throw new ValidationError('Invalid query parameter value.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: IAgentListFilters = {
page: value.page as number,
limit: value.limit as number,
owner: value.owner as string | undefined,
agentType: value.agentType as IAgentListFilters['agentType'],
status: value.status as IAgentListFilters['status'],
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
const result = await this.agentService.listAgents(filters);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles GET /agents/:agentId — retrieves a single agent.
*
* @param req - Express request with agentId path param.
* @param res - Express response.
* @param next - Express next function.
*/
getAgentById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { agentId } = req.params;
const agent = await this.agentService.getAgentById(agentId);
res.status(200).json(agent);
} catch (err) {
next(err);
}
};
/**
* Handles PATCH /agents/:agentId — partially updates an agent.
*
* @param req - Express request with agentId path param and UpdateAgentRequest body.
* @param res - Express response.
* @param next - Express next function.
*/
updateAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = updateAgentSchema.validate(req.body, { abortEarly: false });
if (error) {
const immutableFields = ['agentId', 'email', 'createdAt'];
const firstImmutable = error.details.find((d) =>
immutableFields.includes(d.path[0] as string),
);
if (firstImmutable) {
throw new ValidationError(`The field '${String(firstImmutable.path[0])}' cannot be modified after registration.`, {
field: firstImmutable.path[0],
});
}
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const { agentId } = req.params;
const data = value as IUpdateAgentRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const updated = await this.agentService.updateAgent(agentId, data, ipAddress, userAgent);
res.status(200).json(updated);
} catch (err) {
next(err);
}
};
/**
* Handles DELETE /agents/:agentId — decommissions an agent.
*
* @param req - Express request with agentId path param.
* @param res - Express response (204 No Content).
* @param next - Express next function.
*/
decommissionAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthorizationError();
}
const { agentId } = req.params;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.agentService.decommissionAgent(agentId, ipAddress, userAgent);
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,100 @@
/**
* Audit Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for GET /audit and GET /audit/:eventId.
*/
import { Request, Response, NextFunction } from 'express';
import { AuditService } from '../services/AuditService.js';
import { auditQuerySchema } from '../utils/validators.js';
import {
ValidationError,
AuthenticationError,
InsufficientScopeError,
} from '../utils/errors.js';
import { IAuditListFilters } from '../types/index.js';
/**
* Controller for the Audit Log endpoints.
* Enforces `audit:read` scope on all handlers.
*/
export class AuditController {
/**
* @param auditService - The audit log service.
*/
constructor(private readonly auditService: AuditService) {}
/**
* Handles GET /audit — queries the audit log with optional filters.
* Requires Bearer token with `audit:read` scope.
*
* @param req - Express request with optional query filters.
* @param res - Express response.
* @param next - Express next function.
*/
queryAuditLog = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// Enforce audit:read scope
const scopes = req.user.scope.split(' ');
if (!scopes.includes('audit:read')) {
throw new InsufficientScopeError('audit:read');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = auditQuerySchema.validate(req.query, { abortEarly: false });
if (error) {
throw new ValidationError('Invalid query parameter value.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: IAuditListFilters = {
page: value.page as number,
limit: value.limit as number,
agentId: value.agentId as string | undefined,
action: value.action as IAuditListFilters['action'],
outcome: value.outcome as IAuditListFilters['outcome'],
fromDate: value.fromDate as string | undefined,
toDate: value.toDate as string | undefined,
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
const result = await this.auditService.queryEvents(filters);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles GET /audit/:eventId — retrieves a single audit event.
* Requires Bearer token with `audit:read` scope.
*
* @param req - Express request with eventId path param.
* @param res - Express response.
* @param next - Express next function.
*/
getAuditEventById = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// Enforce audit:read scope
const scopes = req.user.scope.split(' ');
if (!scopes.includes('audit:read')) {
throw new InsufficientScopeError('audit:read');
}
const { eventId } = req.params;
const event = await this.auditService.getEventById(eventId);
res.status(200).json(event);
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,196 @@
/**
* Credential Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for all 4 credential management endpoints.
*/
import { Request, Response, NextFunction } from 'express';
import { CredentialService } from '../services/CredentialService.js';
import {
generateCredentialSchema,
listCredentialsQuerySchema,
} from '../utils/validators.js';
import { ValidationError, AuthorizationError, AuthenticationError } from '../utils/errors.js';
import {
IGenerateCredentialRequest,
ICredentialListFilters,
} from '../types/index.js';
/**
* Controller for the Credential Management endpoints.
*/
export class CredentialController {
/**
* @param credentialService - The credential management service.
*/
constructor(private readonly credentialService: CredentialService) {}
/**
* Handles POST /agents/:agentId/credentials — generates new credentials.
* Returns 201 with CredentialWithSecret (secret shown once only).
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
generateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId } = req.params;
// An agent may only manage its own credentials (Phase 1 — no admin scope)
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, {
abortEarly: false,
});
if (error) {
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const data = value as IGenerateCredentialRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.credentialService.generateCredential(
agentId,
data,
ipAddress,
userAgent,
);
res.status(201).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles GET /agents/:agentId/credentials — lists credentials for an agent.
* clientSecret is never returned in list responses.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
listCredentials = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId } = req.params;
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = listCredentialsQuerySchema.validate(req.query, {
abortEarly: false,
});
if (error) {
throw new ValidationError('Invalid query parameter value.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
const filters: ICredentialListFilters = {
page: value.page as number,
limit: value.limit as number,
status: value.status as ICredentialListFilters['status'],
};
/* eslint-enable @typescript-eslint/no-unsafe-member-access */
const result = await this.credentialService.listCredentials(agentId, filters);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles POST /agents/:agentId/credentials/:credentialId/rotate — rotates a credential.
* Returns 200 with CredentialWithSecret (new secret shown once only).
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
rotateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId, credentialId } = req.params;
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, {
abortEarly: false,
});
if (error) {
throw new ValidationError('Request validation failed.', {
details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })),
});
}
const data = value as IGenerateCredentialRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.credentialService.rotateCredential(
agentId,
credentialId,
data,
ipAddress,
userAgent,
);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles DELETE /agents/:agentId/credentials/:credentialId — revokes a credential.
* Returns 204 No Content.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
revokeCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
const { agentId, credentialId } = req.params;
if (req.user.sub !== agentId) {
throw new AuthorizationError('You do not have permission to manage credentials for this agent.');
}
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.credentialService.revokeCredential(agentId, credentialId, ipAddress, userAgent);
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,243 @@
/**
* Token Controller for SentryAgent.ai AgentIdP.
* HTTP handlers for POST /token, POST /token/introspect, POST /token/revoke.
* Parses application/x-www-form-urlencoded bodies.
* Returns OAuth2ErrorResponse for /token errors, ErrorResponse for introspect/revoke.
*/
import { Request, Response, NextFunction } from 'express';
import { OAuth2Service } from '../services/OAuth2Service.js';
import { tokenRequestSchema, introspectRequestSchema, revokeRequestSchema } from '../utils/validators.js';
import {
AuthenticationError,
AuthorizationError,
FreeTierLimitError,
} from '../utils/errors.js';
import { ITokenRequest, IIntrospectRequest, IRevokeRequest, IOAuth2ErrorResponse } from '../types/index.js';
/**
* Maps an error from the token issuance flow to an OAuth2ErrorResponse.
*
* @param err - The error to map.
* @returns Object with error, error_description, and httpStatus.
*/
function mapToOAuth2Error(err: unknown): {
body: IOAuth2ErrorResponse;
httpStatus: number;
} {
if (err instanceof FreeTierLimitError) {
return {
body: {
error: 'unauthorized_client',
error_description: err.message,
},
httpStatus: 403,
};
}
if (err instanceof AuthorizationError) {
return {
body: {
error: 'unauthorized_client',
error_description: err.message,
},
httpStatus: 403,
};
}
if (err instanceof AuthenticationError) {
return {
body: {
error: 'invalid_client',
error_description: 'Client authentication failed. Invalid client_id or client_secret.',
},
httpStatus: 401,
};
}
// Default: internal server error
return {
body: {
error: 'invalid_request',
error_description: 'An unexpected error occurred.',
},
httpStatus: 500,
};
}
/**
* Controller for the OAuth 2.0 Token endpoints.
*/
export class TokenController {
/**
* @param oauth2Service - The OAuth2 token service.
*/
constructor(private readonly oauth2Service: OAuth2Service) {}
/**
* Handles POST /token — issues an access token via Client Credentials grant.
* Accepts application/x-www-form-urlencoded body.
* Returns OAuth2ErrorResponse on failure.
*
* @param req - Express request with form-encoded body.
* @param res - Express response.
* @param next - Express next function.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
issueToken = async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
try {
const body = req.body as ITokenRequest;
// Validate grant_type first
if (!body.grant_type) {
res.status(400).json({
error: 'invalid_request',
error_description: "The 'grant_type' parameter is required.",
} as IOAuth2ErrorResponse);
return;
}
if (body.grant_type !== 'client_credentials') {
res.status(400).json({
error: 'unsupported_grant_type',
error_description: "Only 'client_credentials' grant type is supported.",
} as IOAuth2ErrorResponse);
return;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = tokenRequestSchema.validate(body, { abortEarly: false });
if (error) {
res.status(400).json({
error: 'invalid_request',
error_description: error.details.map((d) => d.message).join('; '),
} as IOAuth2ErrorResponse);
return;
}
const tokenBody = value as ITokenRequest;
// Support HTTP Basic auth fallback
let clientId = tokenBody.client_id;
let clientSecret = tokenBody.client_secret;
const authHeader = req.headers['authorization'];
if (authHeader?.startsWith('Basic ')) {
const base64 = authHeader.slice(6);
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const colonIndex = decoded.indexOf(':');
if (colonIndex !== -1) {
clientId = decoded.slice(0, colonIndex);
clientSecret = decoded.slice(colonIndex + 1);
}
}
if (!clientId || !clientSecret) {
res.status(400).json({
error: 'invalid_request',
error_description: "The 'client_id' and 'client_secret' parameters are required.",
} as IOAuth2ErrorResponse);
return;
}
// Validate requested scope
const requestedScope = tokenBody.scope ?? 'agents:read';
const validScopes = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'];
const scopeList = requestedScope.split(' ');
const invalidScope = scopeList.find((s) => !validScopes.includes(s));
if (invalidScope) {
res.status(400).json({
error: 'invalid_scope',
error_description: `Requested scope '${invalidScope}' is not available.`,
} as IOAuth2ErrorResponse);
return;
}
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const tokenResponse = await this.oauth2Service.issueToken(
clientId,
clientSecret,
requestedScope,
ipAddress,
userAgent,
);
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
res.status(200).json(tokenResponse);
} catch (err) {
// Token endpoint uses OAuth2ErrorResponse format
const mapped = mapToOAuth2Error(err);
res.status(mapped.httpStatus).json(mapped.body);
}
};
/**
* Handles POST /token/introspect — introspects a token per RFC 7662.
* Requires Bearer auth with tokens:read scope.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
introspectToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = introspectRequestSchema.validate(req.body, { abortEarly: false });
if (error) {
const messages = error.details.map((d) => d.message).join('; ');
throw new Error(messages);
}
const body = value as IIntrospectRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
const result = await this.oauth2Service.introspectToken(
body.token,
req.user,
ipAddress,
userAgent,
);
res.status(200).json(result);
} catch (err) {
next(err);
}
};
/**
* Handles POST /token/revoke — revokes a token per RFC 7009.
* Requires Bearer auth.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
revokeToken = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
try {
if (!req.user) {
throw new AuthenticationError();
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { error, value } = revokeRequestSchema.validate(req.body, { abortEarly: false });
if (error) {
const messages = error.details.map((d) => d.message).join('; ');
throw new Error(messages);
}
const body = value as IRevokeRequest;
const ipAddress = req.ip ?? '0.0.0.0';
const userAgent = req.headers['user-agent'] ?? 'unknown';
await this.oauth2Service.revokeToken(body.token, req.user, ipAddress, userAgent);
res.status(200).json({});
} catch (err) {
next(err);
}
};
}

View File

@@ -0,0 +1,28 @@
-- Migration: 001_create_agents
-- Creates the agents table for the Agent Registry service.
CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT agents_agent_type_check
CHECK (agent_type IN ('screener','classifier','orchestrator','extractor','summarizer','router','monitor','custom')),
CONSTRAINT agents_deployment_env_check
CHECK (deployment_env IN ('development','staging','production')),
CONSTRAINT agents_status_check
CHECK (status IN ('active','suspended','decommissioned'))
);
CREATE INDEX IF NOT EXISTS idx_agents_email ON agents (email);
CREATE INDEX IF NOT EXISTS idx_agents_status ON agents (status);
CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents (owner);
CREATE INDEX IF NOT EXISTS idx_agents_agent_type ON agents (agent_type);
CREATE INDEX IF NOT EXISTS idx_agents_created_at ON agents (created_at DESC);

View File

@@ -0,0 +1,19 @@
-- Migration: 002_create_credentials
-- Creates the credentials table for the Credential Management service.
CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES agents(agent_id) ON DELETE CASCADE,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
CONSTRAINT credentials_status_check
CHECK (status IN ('active','revoked'))
);
CREATE INDEX IF NOT EXISTS idx_credentials_client_id ON credentials (client_id);
CREATE INDEX IF NOT EXISTS idx_credentials_status ON credentials (status);
CREATE INDEX IF NOT EXISTS idx_credentials_created_at ON credentials (created_at DESC);

View File

@@ -0,0 +1,28 @@
-- Migration: 003_create_audit_events
-- Creates the audit_events table for the Audit Log service.
-- Append-only by design — no UPDATE or DELETE operations are permitted.
CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
action VARCHAR(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT audit_events_action_check
CHECK (action IN (
'agent.created','agent.updated','agent.decommissioned','agent.suspended',
'agent.reactivated','token.issued','token.revoked','token.introspected',
'credential.generated','credential.rotated','credential.revoked','auth.failed'
)),
CONSTRAINT audit_events_outcome_check
CHECK (outcome IN ('success','failure'))
);
CREATE INDEX IF NOT EXISTS idx_audit_events_agent_id ON audit_events (agent_id);
CREATE INDEX IF NOT EXISTS idx_audit_events_action ON audit_events (action);
CREATE INDEX IF NOT EXISTS idx_audit_events_outcome ON audit_events (outcome);
CREATE INDEX IF NOT EXISTS idx_audit_events_timestamp ON audit_events (timestamp DESC);

View File

@@ -0,0 +1,11 @@
-- Migration: 004_create_tokens
-- Creates the token_revocations table for soft revocation tracking.
-- Supplementary to Redis — provides durability across Redis restarts.
CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_token_revocations_expires_at ON token_revocations (expires_at);

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

@@ -0,0 +1,44 @@
/**
* PostgreSQL connection pool singleton.
* All database access flows through this pool.
*/
import { Pool } from 'pg';
let pool: Pool | null = null;
/**
* Returns the singleton pg Pool instance.
* Initialises the pool on first call using DATABASE_URL from the environment.
*
* @returns The PostgreSQL connection pool.
* @throws Error if DATABASE_URL is not set.
*/
export function getPool(): Pool {
if (!pool) {
const connectionString = process.env['DATABASE_URL'];
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required');
}
pool = new Pool({ connectionString });
pool.on('error', (err: Error) => {
// eslint-disable-next-line no-console
console.error('Unexpected pg pool error', err);
});
}
return pool;
}
/**
* Closes the pool and resets the singleton.
* Used for graceful shutdown and tests.
*
* @returns Promise that resolves when the pool is closed.
*/
export async function closePool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
}
}

77
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Authentication middleware for SentryAgent.ai AgentIdP.
* Extracts and verifies Bearer tokens from the Authorization header.
* Checks Redis for token revocation before attaching the payload to req.user.
*/
import { Request, Response, NextFunction } from 'express';
import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import { verifyToken } from '../utils/jwt.js';
import { getRedisClient } from '../cache/redis.js';
import { AuthenticationError } from '../utils/errors.js';
import { ITokenPayload } from '../types/index.js';
/**
* Express middleware that validates a Bearer JWT token on every protected request.
*
* Behaviour:
* 1. Extracts the Bearer token from the Authorization header.
* 2. Verifies the RS256 signature and expiry using the public key.
* 3. Checks Redis whether the JTI has been explicitly revoked.
* 4. Attaches the decoded payload to `req.user`.
* 5. Throws `AuthenticationError` on any failure.
*
* @param req - Express request.
* @param _res - Express response (unused).
* @param next - Express next function.
*/
export async function authMiddleware(
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> {
try {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AuthenticationError('A valid Bearer token is required to access this resource.');
}
const token = authHeader.slice(7).trim();
if (!token) {
throw new AuthenticationError('A valid Bearer token is required to access this resource.');
}
const publicKey = process.env['JWT_PUBLIC_KEY'];
if (!publicKey) {
throw new Error('JWT_PUBLIC_KEY environment variable is required');
}
let payload: ITokenPayload;
try {
payload = verifyToken(token, publicKey);
} catch (err) {
if (err instanceof TokenExpiredError) {
throw new AuthenticationError('Token has expired.');
}
if (err instanceof JsonWebTokenError) {
throw new AuthenticationError('Token signature is invalid.');
}
throw new AuthenticationError();
}
// Check Redis revocation list
const redis = await getRedisClient();
const revocationKey = `revoked:${payload.jti}`;
const isRevoked = await redis.get(revocationKey);
if (isRevoked !== null) {
throw new AuthenticationError('Token has been revoked.');
}
req.user = payload;
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,48 @@
/**
* Global Express error-handling middleware for SentryAgent.ai AgentIdP.
* Maps SentryAgentError subclasses to their HTTP status codes and error shapes.
* Unknown errors are mapped to 500 Internal Server Error.
*/
import { Request, Response, NextFunction } from 'express';
import { SentryAgentError } from '../utils/errors.js';
import { IErrorResponse } from '../types/index.js';
/**
* Express error-handling middleware.
* Must have exactly 4 parameters to be recognised as an error handler.
*
* @param err - The error thrown by a route handler or upstream middleware.
* @param _req - Express request (unused).
* @param res - Express response.
* @param _next - Express next function (unused but required by Express signature).
*/
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_next: NextFunction,
): void {
if (err instanceof SentryAgentError) {
const body: IErrorResponse = {
code: err.code,
message: err.message,
};
if (err.details !== undefined) {
body.details = err.details;
}
res.status(err.httpStatus).json(body);
return;
}
// Unexpected error — log and return generic 500
// eslint-disable-next-line no-console
console.error('Unhandled error:', err);
const body: IErrorResponse = {
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred. Please try again later.',
};
res.status(500).json(body);
}

View File

@@ -0,0 +1,69 @@
/**
* Redis-backed rate limiting middleware for SentryAgent.ai AgentIdP.
* Enforces 100 requests per minute per client_id using a sliding window counter.
*/
import { Request, Response, NextFunction } from 'express';
import { getRedisClient } from '../cache/redis.js';
import { RateLimitError } from '../utils/errors.js';
const RATE_LIMIT_MAX = 100;
const WINDOW_MS = 60000; // 60 seconds
/**
* Computes the current rate-limit window key and next reset timestamp.
*
* @returns Object with `windowKey` (minute index) and `resetAt` (Unix seconds).
*/
function getWindowInfo(): { windowKey: number; resetAt: number } {
const windowKey = Math.floor(Date.now() / WINDOW_MS);
const resetAt = (windowKey + 1) * (WINDOW_MS / 1000);
return { windowKey, resetAt };
}
/**
* Express middleware that applies Redis-based rate limiting per client_id.
*
* The client_id is sourced from `req.user.client_id` (set by authMiddleware).
* For unauthenticated requests (token endpoint), the client IP is used instead.
*
* Sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`
* headers on every response. Throws `RateLimitError` when the limit is exceeded.
*
* @param req - Express request.
* @param res - Express response.
* @param next - Express next function.
*/
export async function rateLimitMiddleware(
req: Request,
res: Response,
next: NextFunction,
): Promise<void> {
try {
const clientId = req.user?.client_id ?? req.ip ?? 'unknown';
const { windowKey, resetAt } = getWindowInfo();
const redisKey = `rate:${clientId}:${windowKey}`;
const redis = await getRedisClient();
// Atomically increment and set TTL
const count = await redis.incr(redisKey);
if (count === 1) {
await redis.expire(redisKey, 60);
}
const remaining = Math.max(0, RATE_LIMIT_MAX - count);
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt);
if (count > RATE_LIMIT_MAX) {
throw new RateLimitError();
}
next();
} catch (err) {
next(err);
}
}

View File

@@ -0,0 +1,247 @@
/**
* Agent Repository for SentryAgent.ai AgentIdP.
* All SQL queries for the agents table live exclusively here.
*/
import { Pool, QueryResult } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import {
IAgent,
ICreateAgentRequest,
IUpdateAgentRequest,
IAgentListFilters,
AgentStatus,
} from '../types/index.js';
/** Raw database row for an agent. */
interface AgentRow {
agent_id: string;
email: string;
agent_type: string;
version: string;
capabilities: string[];
owner: string;
deployment_env: string;
status: string;
created_at: Date;
updated_at: Date;
}
/**
* Maps a raw database row to the IAgent domain model.
*
* @param row - Raw row from the agents table.
* @returns Typed IAgent object.
*/
function mapRowToAgent(row: AgentRow): IAgent {
return {
agentId: row.agent_id,
email: row.email,
agentType: row.agent_type as IAgent['agentType'],
version: row.version,
capabilities: row.capabilities,
owner: row.owner,
deploymentEnv: row.deployment_env as IAgent['deploymentEnv'],
status: row.status as AgentStatus,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
/**
* Repository for all agent database operations.
* Receives a pg.Pool via constructor injection.
*/
export class AgentRepository {
/**
* @param pool - The PostgreSQL connection pool.
*/
constructor(private readonly pool: Pool) {}
/**
* Creates a new agent record in the database.
*
* @param data - The fields for the new agent.
* @returns The created agent record.
*/
async create(data: ICreateAgentRequest): Promise<IAgent> {
const agentId = uuidv4();
const result: QueryResult<AgentRow> = await this.pool.query(
`INSERT INTO agents
(agent_id, email, agent_type, version, capabilities, owner, deployment_env, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', NOW(), NOW())
RETURNING *`,
[
agentId,
data.email,
data.agentType,
data.version,
data.capabilities,
data.owner,
data.deploymentEnv,
],
);
return mapRowToAgent(result.rows[0]);
}
/**
* Finds an agent by its UUID.
*
* @param agentId - The agent UUID.
* @returns The agent record, or null if not found.
*/
async findById(agentId: string): Promise<IAgent | null> {
const result: QueryResult<AgentRow> = await this.pool.query(
'SELECT * FROM agents WHERE agent_id = $1',
[agentId],
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Finds an agent by its email address.
*
* @param email - The agent email.
* @returns The agent record, or null if not found.
*/
async findByEmail(email: string): Promise<IAgent | null> {
const result: QueryResult<AgentRow> = await this.pool.query(
'SELECT * FROM agents WHERE email = $1',
[email],
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Returns a paginated list of agents with optional filters.
*
* @param filters - Pagination and filter criteria.
* @returns Object containing the agent list and total count.
*/
async findAll(filters: IAgentListFilters): Promise<{ agents: IAgent[]; total: number }> {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (filters.owner !== undefined) {
conditions.push(`owner = $${paramIndex++}`);
params.push(filters.owner);
}
if (filters.agentType !== undefined) {
conditions.push(`agent_type = $${paramIndex++}`);
params.push(filters.agentType);
}
if (filters.status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
params.push(filters.status);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM agents ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0].count, 10);
const offset = (filters.page - 1) * filters.limit;
const dataParams = [...params, filters.limit, offset];
const dataResult: QueryResult<AgentRow> = await this.pool.query(
`SELECT * FROM agents ${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
agents: dataResult.rows.map(mapRowToAgent),
total,
};
}
/**
* Partially updates an agent record.
*
* @param agentId - The agent UUID to update.
* @param data - The fields to update (only provided fields are changed).
* @returns The updated agent record, or null if not found.
*/
async update(agentId: string, data: IUpdateAgentRequest): Promise<IAgent | null> {
const setClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (data.agentType !== undefined) {
setClauses.push(`agent_type = $${paramIndex++}`);
params.push(data.agentType);
}
if (data.version !== undefined) {
setClauses.push(`version = $${paramIndex++}`);
params.push(data.version);
}
if (data.capabilities !== undefined) {
setClauses.push(`capabilities = $${paramIndex++}`);
params.push(data.capabilities);
}
if (data.owner !== undefined) {
setClauses.push(`owner = $${paramIndex++}`);
params.push(data.owner);
}
if (data.deploymentEnv !== undefined) {
setClauses.push(`deployment_env = $${paramIndex++}`);
params.push(data.deploymentEnv);
}
if (data.status !== undefined) {
setClauses.push(`status = $${paramIndex++}`);
params.push(data.status);
}
if (setClauses.length === 0) return null;
setClauses.push(`updated_at = NOW()`);
params.push(agentId);
const result: QueryResult<AgentRow> = await this.pool.query(
`UPDATE agents SET ${setClauses.join(', ')}
WHERE agent_id = $${paramIndex}
RETURNING *`,
params,
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Sets an agent's status to 'decommissioned'.
*
* @param agentId - The agent UUID to decommission.
* @returns The updated agent record, or null if not found.
*/
async decommission(agentId: string): Promise<IAgent | null> {
const result: QueryResult<AgentRow> = await this.pool.query(
`UPDATE agents
SET status = 'decommissioned', updated_at = NOW()
WHERE agent_id = $1
RETURNING *`,
[agentId],
);
if (result.rows.length === 0) return null;
return mapRowToAgent(result.rows[0]);
}
/**
* Counts all agents excluding decommissioned ones (for free-tier limit checks).
*
* @returns Total count of active and suspended agents.
*/
async countActive(): Promise<number> {
const result: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM agents WHERE status != 'decommissioned'`,
);
return parseInt(result.rows[0].count, 10);
}
}

View File

@@ -0,0 +1,152 @@
/**
* Audit Repository for SentryAgent.ai AgentIdP.
* All SQL queries for the audit_events table live exclusively here.
*/
import { Pool, QueryResult } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { IAuditEvent, ICreateAuditEventInput, IAuditListFilters } from '../types/index.js';
/** Raw database row for an audit event. */
interface AuditEventRow {
event_id: string;
agent_id: string;
action: string;
outcome: string;
ip_address: string;
user_agent: string;
metadata: Record<string, unknown>;
timestamp: Date;
}
/**
* Maps a raw database row to the IAuditEvent domain model.
*
* @param row - Raw row from the audit_events table.
* @returns Typed IAuditEvent object.
*/
function mapRowToAuditEvent(row: AuditEventRow): IAuditEvent {
return {
eventId: row.event_id,
agentId: row.agent_id,
action: row.action as IAuditEvent['action'],
outcome: row.outcome as IAuditEvent['outcome'],
ipAddress: row.ip_address,
userAgent: row.user_agent,
metadata: row.metadata,
timestamp: row.timestamp,
};
}
/**
* Repository for all audit event database operations.
* Receives a pg.Pool via constructor injection.
*/
export class AuditRepository {
/**
* @param pool - The PostgreSQL connection pool.
*/
constructor(private readonly pool: Pool) {}
/**
* Creates a new audit event record.
*
* @param event - The audit event input data.
* @returns The created audit event.
*/
async create(event: ICreateAuditEventInput): Promise<IAuditEvent> {
const eventId = uuidv4();
const result: QueryResult<AuditEventRow> = await this.pool.query(
`INSERT INTO audit_events
(event_id, agent_id, action, outcome, ip_address, user_agent, metadata, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())
RETURNING *`,
[
eventId,
event.agentId,
event.action,
event.outcome,
event.ipAddress,
event.userAgent,
JSON.stringify(event.metadata),
],
);
return mapRowToAuditEvent(result.rows[0]);
}
/**
* Finds a single audit event by its UUID.
*
* @param eventId - The audit event UUID.
* @returns The audit event, or null if not found.
*/
async findById(eventId: string): Promise<IAuditEvent | null> {
const result: QueryResult<AuditEventRow> = await this.pool.query(
'SELECT * FROM audit_events WHERE event_id = $1',
[eventId],
);
if (result.rows.length === 0) return null;
return mapRowToAuditEvent(result.rows[0]);
}
/**
* Returns a paginated, filtered list of audit events.
* Automatically enforces the 90-day retention window on query results.
*
* @param filters - Query filters and pagination parameters.
* @param retentionCutoff - The earliest date to include (retention window).
* @returns Object containing the audit events list and total count.
*/
async findAll(
filters: IAuditListFilters,
retentionCutoff: Date,
): Promise<{ events: IAuditEvent[]; total: number }> {
const conditions: string[] = ['timestamp >= $1'];
const params: unknown[] = [retentionCutoff];
let paramIndex = 2;
if (filters.agentId !== undefined) {
conditions.push(`agent_id = $${paramIndex++}`);
params.push(filters.agentId);
}
if (filters.action !== undefined) {
conditions.push(`action = $${paramIndex++}`);
params.push(filters.action);
}
if (filters.outcome !== undefined) {
conditions.push(`outcome = $${paramIndex++}`);
params.push(filters.outcome);
}
if (filters.fromDate !== undefined) {
conditions.push(`timestamp >= $${paramIndex++}`);
params.push(new Date(filters.fromDate));
}
if (filters.toDate !== undefined) {
conditions.push(`timestamp <= $${paramIndex++}`);
params.push(new Date(filters.toDate));
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const countResult: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM audit_events ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0].count, 10);
const offset = (filters.page - 1) * filters.limit;
const dataParams = [...params, filters.limit, offset];
const dataResult: QueryResult<AuditEventRow> = await this.pool.query(
`SELECT * FROM audit_events ${whereClause}
ORDER BY timestamp DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
events: dataResult.rows.map(mapRowToAuditEvent),
total,
};
}
}

View File

@@ -0,0 +1,201 @@
/**
* Credential Repository for SentryAgent.ai AgentIdP.
* All SQL queries for the credentials table live exclusively here.
*/
import { Pool, QueryResult } from 'pg';
import { v4 as uuidv4 } from 'uuid';
import { ICredential, ICredentialRow, ICredentialListFilters } from '../types/index.js';
/** Raw database row for a credential. */
interface CredentialDbRow {
credential_id: string;
client_id: string;
secret_hash: string;
status: string;
created_at: Date;
expires_at: Date | null;
revoked_at: Date | null;
}
/**
* Maps a raw database row to the ICredentialRow domain model.
*
* @param row - Raw row from the credentials table.
* @returns Typed ICredentialRow including the secret hash.
*/
function mapRowToCredentialRow(row: CredentialDbRow): ICredentialRow {
return {
credentialId: row.credential_id,
clientId: row.client_id,
secretHash: row.secret_hash,
status: row.status as ICredential['status'],
createdAt: row.created_at,
expiresAt: row.expires_at,
revokedAt: row.revoked_at,
};
}
/**
* Maps a raw database row to the ICredential domain model (no secret hash).
*
* @param row - Raw row from the credentials table.
* @returns Typed ICredential without the secret hash.
*/
function mapRowToCredential(row: CredentialDbRow): ICredential {
const { secretHash: _secretHash, ...credential } = mapRowToCredentialRow(row);
void _secretHash;
return credential;
}
/**
* Repository for all credential database operations.
* Receives a pg.Pool via constructor injection.
*/
export class CredentialRepository {
/**
* @param pool - The PostgreSQL connection pool.
*/
constructor(private readonly pool: Pool) {}
/**
* Creates a new credential record.
*
* @param clientId - The agent ID this credential belongs to.
* @param secretHash - The bcrypt hash of the plain-text secret.
* @param expiresAt - Optional expiry date.
* @returns The created credential record (without secret hash).
*/
async create(
clientId: string,
secretHash: string,
expiresAt: Date | null,
): Promise<ICredential> {
const credentialId = uuidv4();
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`INSERT INTO credentials
(credential_id, client_id, secret_hash, status, created_at, expires_at)
VALUES ($1, $2, $3, 'active', NOW(), $4)
RETURNING *`,
[credentialId, clientId, secretHash, expiresAt],
);
return mapRowToCredential(result.rows[0]);
}
/**
* Finds a credential by its UUID, including the secret hash.
*
* @param credentialId - The credential UUID.
* @returns The credential row including secret hash, or null if not found.
*/
async findById(credentialId: string): Promise<ICredentialRow | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
'SELECT * FROM credentials WHERE credential_id = $1',
[credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredentialRow(result.rows[0]);
}
/**
* Returns a paginated list of credentials for an agent.
*
* @param agentId - The agent UUID.
* @param filters - Pagination and optional status filter.
* @returns Object with credential list and total count.
*/
async findByAgentId(
agentId: string,
filters: ICredentialListFilters,
): Promise<{ credentials: ICredential[]; total: number }> {
const conditions: string[] = ['client_id = $1'];
const params: unknown[] = [agentId];
let paramIndex = 2;
if (filters.status !== undefined) {
conditions.push(`status = $${paramIndex++}`);
params.push(filters.status);
}
const whereClause = `WHERE ${conditions.join(' AND ')}`;
const countResult: QueryResult<{ count: string }> = await this.pool.query(
`SELECT COUNT(*) as count FROM credentials ${whereClause}`,
params,
);
const total = parseInt(countResult.rows[0].count, 10);
const offset = (filters.page - 1) * filters.limit;
const dataParams = [...params, filters.limit, offset];
const dataResult: QueryResult<CredentialDbRow> = await this.pool.query(
`SELECT * FROM credentials ${whereClause}
ORDER BY created_at DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}`,
dataParams,
);
return {
credentials: dataResult.rows.map(mapRowToCredential),
total,
};
}
/**
* Updates the bcrypt hash for an existing credential (rotation).
*
* @param credentialId - The credential UUID.
* @param newSecretHash - The new bcrypt hash.
* @param newExpiresAt - Optional new expiry date.
* @returns The updated credential record, or null if not found.
*/
async updateHash(
credentialId: string,
newSecretHash: string,
newExpiresAt: Date | null,
): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET secret_hash = $1, expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *`,
[newSecretHash, newExpiresAt, credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredential(result.rows[0]);
}
/**
* Sets a credential's status to 'revoked'.
*
* @param credentialId - The credential UUID.
* @returns The updated credential record, or null if not found.
*/
async revoke(credentialId: string): Promise<ICredential | null> {
const result: QueryResult<CredentialDbRow> = await this.pool.query(
`UPDATE credentials
SET status = 'revoked', revoked_at = NOW()
WHERE credential_id = $1
RETURNING *`,
[credentialId],
);
if (result.rows.length === 0) return null;
return mapRowToCredential(result.rows[0]);
}
/**
* Revokes all active credentials for an agent (used on decommission).
*
* @param agentId - The agent UUID.
* @returns The number of credentials revoked.
*/
async revokeAllForAgent(agentId: string): Promise<number> {
const result = await this.pool.query(
`UPDATE credentials
SET status = 'revoked', revoked_at = NOW()
WHERE client_id = $1 AND status = 'active'`,
[agentId],
);
return result.rowCount ?? 0;
}
}

View File

@@ -0,0 +1,113 @@
/**
* Token Repository for SentryAgent.ai AgentIdP.
* Manages token revocation tracking (Redis primary, PostgreSQL fallback)
* and monthly token count tracking (Redis with monthly TTL).
*/
import { Pool, QueryResult } from 'pg';
import { RedisClientType } from 'redis';
/** Raw database row for a token revocation record. */
interface TokenRevocationRow {
jti: string;
expires_at: Date;
revoked_at: Date;
}
/**
* Repository for token revocation and monthly usage tracking.
* Receives a pg.Pool and RedisClientType via constructor injection.
*/
export class TokenRepository {
/**
* @param pool - The PostgreSQL connection pool.
* @param redis - The Redis client.
*/
constructor(
private readonly pool: Pool,
private readonly redis: RedisClientType,
) {}
/**
* Adds a token JTI to both the Redis revocation list and the PostgreSQL
* token_revocations table. Redis TTL is set to the token's remaining lifetime.
*
* @param jti - The JWT ID to revoke.
* @param expiresAt - The token expiry date (used to calculate Redis TTL).
*/
async addToRevocationList(jti: string, expiresAt: Date): Promise<void> {
const nowSeconds = Math.floor(Date.now() / 1000);
const expirySeconds = Math.floor(expiresAt.getTime() / 1000);
const ttl = Math.max(1, expirySeconds - nowSeconds);
const redisKey = `revoked:${jti}`;
await this.redis.set(redisKey, '1', { EX: ttl });
await this.pool.query(
`INSERT INTO token_revocations (jti, expires_at)
VALUES ($1, $2)
ON CONFLICT (jti) DO NOTHING`,
[jti, expiresAt],
);
}
/**
* Checks whether a JTI has been revoked.
* Checks Redis first for performance; falls back to PostgreSQL.
*
* @param jti - The JWT ID to check.
* @returns True if the token has been revoked, false otherwise.
*/
async isRevoked(jti: string): Promise<boolean> {
const redisKey = `revoked:${jti}`;
const cached = await this.redis.get(redisKey);
if (cached !== null) return true;
const result: QueryResult<TokenRevocationRow> = await this.pool.query(
'SELECT jti FROM token_revocations WHERE jti = $1',
[jti],
);
return result.rows.length > 0;
}
/**
* Increments the monthly token count for a client in Redis.
* Sets a TTL to the end of the current month if the key is new.
*
* @param clientId - The agent/client ID.
* @returns The new count after incrementing.
*/
async incrementMonthlyCount(clientId: string): Promise<number> {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const key = `monthly:tokens:${clientId}:${year}-${month}`;
const count = await this.redis.incr(key);
if (count === 1) {
// Set TTL to end of month
const endOfMonth = new Date(Date.UTC(year, now.getUTCMonth() + 1, 1, 0, 0, 0));
const ttlSeconds = Math.floor((endOfMonth.getTime() - Date.now()) / 1000);
await this.redis.expire(key, Math.max(1, ttlSeconds));
}
return count;
}
/**
* Gets the current monthly token count for a client.
*
* @param clientId - The agent/client ID.
* @returns The current count, or 0 if no tokens have been issued this month.
*/
async getMonthlyCount(clientId: string): Promise<number> {
const now = new Date();
const year = now.getUTCFullYear();
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
const key = `monthly:tokens:${clientId}:${year}-${month}`;
const value = await this.redis.get(key);
return value !== null ? parseInt(value, 10) : 0;
}
}

40
src/routes/agents.ts Normal file
View File

@@ -0,0 +1,40 @@
/**
* Agent Registry routes for SentryAgent.ai AgentIdP.
* Wires AgentController handlers to Express paths with auth and rateLimit middleware.
*/
import { Router } from 'express';
import { AgentController } from '../controllers/AgentController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for agent registry endpoints.
*
* @param agentController - The agent controller instance.
* @returns Configured Express router.
*/
export function createAgentsRouter(agentController: AgentController): Router {
const router = Router();
router.use(asyncHandler(authMiddleware));
router.use(asyncHandler(rateLimitMiddleware));
// POST /agents — Register a new agent
router.post('/', asyncHandler(agentController.registerAgent.bind(agentController)));
// GET /agents — List agents with optional filters
router.get('/', asyncHandler(agentController.listAgents.bind(agentController)));
// GET /agents/:agentId — Get a single agent
router.get('/:agentId', asyncHandler(agentController.getAgentById.bind(agentController)));
// PATCH /agents/:agentId — Update agent metadata
router.patch('/:agentId', asyncHandler(agentController.updateAgent.bind(agentController)));
// DELETE /agents/:agentId — Decommission an agent
router.delete('/:agentId', asyncHandler(agentController.decommissionAgent.bind(agentController)));
return router;
}

31
src/routes/audit.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Audit Log routes for SentryAgent.ai AgentIdP.
* All routes require Bearer auth and are rate-limited.
*/
import { Router } from 'express';
import { AuditController } from '../controllers/AuditController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for audit log endpoints.
*
* @param auditController - The audit controller instance.
* @returns Configured Express router.
*/
export function createAuditRouter(auditController: AuditController): Router {
const router = Router();
router.use(asyncHandler(authMiddleware));
router.use(asyncHandler(rateLimitMiddleware));
// GET /audit — Query audit log
router.get('/', asyncHandler(auditController.queryAuditLog.bind(auditController)));
// GET /audit/:eventId — Get a single audit event
router.get('/:eventId', asyncHandler(auditController.getAuditEventById.bind(auditController)));
return router;
}

38
src/routes/credentials.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Credential Management routes for SentryAgent.ai AgentIdP.
* All routes are under /agents/:agentId/credentials with auth and rateLimit middleware.
*/
import { Router } from 'express';
import { CredentialController } from '../controllers/CredentialController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for credential management endpoints.
* This router is mounted at /agents — the :agentId param is part of the path.
*
* @param credentialController - The credential controller instance.
* @returns Configured Express router.
*/
export function createCredentialsRouter(credentialController: CredentialController): Router {
const router = Router({ mergeParams: true });
router.use(asyncHandler(authMiddleware));
router.use(asyncHandler(rateLimitMiddleware));
// POST /agents/:agentId/credentials — Generate new credentials
router.post('/', asyncHandler(credentialController.generateCredential.bind(credentialController)));
// GET /agents/:agentId/credentials — List credentials
router.get('/', asyncHandler(credentialController.listCredentials.bind(credentialController)));
// POST /agents/:agentId/credentials/:credentialId/rotate — Rotate a credential
router.post('/:credentialId/rotate', asyncHandler(credentialController.rotateCredential.bind(credentialController)));
// DELETE /agents/:agentId/credentials/:credentialId — Revoke a credential
router.delete('/:credentialId', asyncHandler(credentialController.revokeCredential.bind(credentialController)));
return router;
}

42
src/routes/token.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* OAuth 2.0 Token routes for SentryAgent.ai AgentIdP.
* POST /token uses no Bearer auth (credentials are in the body).
* POST /token/introspect and POST /token/revoke require Bearer auth.
*/
import { Router } from 'express';
import { TokenController } from '../controllers/TokenController.js';
import { authMiddleware } from '../middleware/auth.js';
import { rateLimitMiddleware } from '../middleware/rateLimit.js';
import { asyncHandler } from '../utils/asyncHandler.js';
/**
* Creates and returns the Express router for token endpoints.
*
* @param tokenController - The token controller instance.
* @returns Configured Express router.
*/
export function createTokenRouter(tokenController: TokenController): Router {
const router = Router();
// POST /token — Issue token (no auth — credentials in body or Basic header)
router.post('/', asyncHandler(rateLimitMiddleware), asyncHandler(tokenController.issueToken.bind(tokenController)));
// POST /token/introspect — Introspect token (requires Bearer auth)
router.post(
'/introspect',
asyncHandler(authMiddleware),
asyncHandler(rateLimitMiddleware),
asyncHandler(tokenController.introspectToken.bind(tokenController)),
);
// POST /token/revoke — Revoke token (requires Bearer auth)
router.post(
'/revoke',
asyncHandler(authMiddleware),
asyncHandler(rateLimitMiddleware),
asyncHandler(tokenController.revokeToken.bind(tokenController)),
);
return router;
}

47
src/server.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Server entry point for SentryAgent.ai AgentIdP.
* Loads environment variables, creates the app, and starts listening.
*/
import * as dotenv from 'dotenv';
dotenv.config();
import { createApp } from './app.js';
const PORT = parseInt(process.env['PORT'] ?? '3000', 10);
/**
* Bootstraps the application and starts the HTTP server.
*/
async function main(): Promise<void> {
try {
const app = await createApp();
const server = app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`SentryAgent.ai AgentIdP listening on port ${PORT}`);
});
// Graceful shutdown
const shutdown = (): void => {
// eslint-disable-next-line no-console
console.log('Shutting down gracefully...');
server.close(() => {
process.exit(0);
});
};
process.on('SIGTERM', () => {
shutdown();
});
process.on('SIGINT', () => {
shutdown();
});
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to start server:', err);
process.exit(1);
}
}
void main();

View File

@@ -0,0 +1,213 @@
/**
* Agent Registry Service for SentryAgent.ai AgentIdP.
* Business logic for agent lifecycle management.
*/
import { AgentRepository } from '../repositories/AgentRepository.js';
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AuditService } from './AuditService.js';
import {
IAgent,
ICreateAgentRequest,
IUpdateAgentRequest,
IAgentListFilters,
IPaginatedAgentsResponse,
} from '../types/index.js';
import {
AgentNotFoundError,
AgentAlreadyExistsError,
AgentAlreadyDecommissionedError,
FreeTierLimitError,
} from '../utils/errors.js';
const FREE_TIER_MAX_AGENTS = 100;
/**
* Service for agent registration and lifecycle management.
* Enforces free-tier limits and coordinates with AuditService.
*/
export class AgentService {
/**
* @param agentRepository - The agent data repository.
* @param credentialRepository - The credential repository (for decommission cleanup).
* @param auditService - The audit log service.
*/
constructor(
private readonly agentRepository: AgentRepository,
private readonly credentialRepository: CredentialRepository,
private readonly auditService: AuditService,
) {}
/**
* Registers a new AI agent identity.
* Enforces the free-tier 100-agent limit and unique email constraint.
*
* @param data - The agent registration data.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The newly created agent record.
* @throws FreeTierLimitError if the 100-agent limit is reached.
* @throws AgentAlreadyExistsError if the email is already registered.
*/
async registerAgent(
data: ICreateAgentRequest,
ipAddress: string,
userAgent: string,
): Promise<IAgent> {
// Enforce free-tier agent count limit
const currentCount = await this.agentRepository.countActive();
if (currentCount >= FREE_TIER_MAX_AGENTS) {
throw new FreeTierLimitError(
'Free tier limit of 100 registered agents has been reached.',
{ limit: FREE_TIER_MAX_AGENTS, current: currentCount },
);
}
// Check email uniqueness
const existing = await this.agentRepository.findByEmail(data.email);
if (existing !== null) {
throw new AgentAlreadyExistsError(data.email);
}
const agent = await this.agentRepository.create(data);
// Synchronous audit insert
await this.auditService.logEvent(
agent.agentId,
'agent.created',
'success',
ipAddress,
userAgent,
{ agentType: agent.agentType, owner: agent.owner },
);
return agent;
}
/**
* Retrieves a single agent by its UUID.
*
* @param agentId - The agent UUID.
* @returns The agent record.
* @throws AgentNotFoundError if the agent does not exist.
*/
async getAgentById(agentId: string): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
return agent;
}
/**
* Returns a paginated, optionally filtered list of agents.
*
* @param filters - Pagination and filter criteria.
* @returns Paginated agents response.
*/
async listAgents(filters: IAgentListFilters): Promise<IPaginatedAgentsResponse> {
const { agents, total } = await this.agentRepository.findAll(filters);
return {
data: agents,
total,
page: filters.page,
limit: filters.limit,
};
}
/**
* Partially updates an agent's metadata.
* Immutable fields (agentId, email, createdAt) cannot be changed.
* Decommissioned agents cannot be updated.
*
* @param agentId - The agent UUID to update.
* @param data - The fields to update.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The updated agent record.
* @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if the agent is decommissioned.
* @throws ValidationError if immutable fields are included.
*/
async updateAgent(
agentId: string,
data: IUpdateAgentRequest,
ipAddress: string,
userAgent: string,
): Promise<IAgent> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
if (agent.status === 'decommissioned') {
throw new AgentAlreadyDecommissionedError(agentId);
}
// Detect if status changes
const oldStatus = agent.status;
const updated = await this.agentRepository.update(agentId, data);
if (!updated) {
throw new AgentNotFoundError(agentId);
}
// Determine which audit action to log
let auditAction: 'agent.updated' | 'agent.suspended' | 'agent.reactivated' | 'agent.decommissioned' =
'agent.updated';
if (data.status !== undefined && data.status !== oldStatus) {
if (data.status === 'suspended') auditAction = 'agent.suspended';
else if (data.status === 'active') auditAction = 'agent.reactivated';
else if (data.status === 'decommissioned') auditAction = 'agent.decommissioned';
}
await this.auditService.logEvent(
agentId,
auditAction,
'success',
ipAddress,
userAgent,
{ updatedFields: Object.keys(data) },
);
return updated;
}
/**
* Permanently decommissions an agent (soft delete).
* Revokes all active credentials for the agent.
*
* @param agentId - The agent UUID to decommission.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @throws AgentNotFoundError if the agent does not exist.
* @throws AgentAlreadyDecommissionedError if already decommissioned.
*/
async decommissionAgent(
agentId: string,
ipAddress: string,
userAgent: string,
): Promise<void> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
if (agent.status === 'decommissioned') {
throw new AgentAlreadyDecommissionedError(agentId);
}
// Revoke all active credentials
await this.credentialRepository.revokeAllForAgent(agentId);
await this.agentRepository.decommission(agentId);
await this.auditService.logEvent(
agentId,
'agent.decommissioned',
'success',
ipAddress,
userAgent,
{},
);
}
}

View File

@@ -0,0 +1,136 @@
/**
* Audit Log Service for SentryAgent.ai AgentIdP.
* Provides methods for logging and querying immutable audit events.
*/
import { AuditRepository } from '../repositories/AuditRepository.js';
import {
IAuditEvent,
IAuditListFilters,
IPaginatedAuditEventsResponse,
AuditAction,
AuditOutcome,
} from '../types/index.js';
import {
AuditEventNotFoundError,
RetentionWindowError,
ValidationError,
} from '../utils/errors.js';
const FREE_TIER_RETENTION_DAYS = 90;
/**
* Service for creating and querying audit log events.
* Enforces 90-day retention window on all queries.
*/
export class AuditService {
/**
* @param auditRepository - The audit event repository.
*/
constructor(private readonly auditRepository: AuditRepository) {}
/**
* Computes the earliest allowed timestamp for audit queries (90-day retention).
*
* @returns The retention cutoff Date.
*/
private getRetentionCutoff(): Date {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - FREE_TIER_RETENTION_DAYS);
cutoff.setUTCHours(0, 0, 0, 0);
return cutoff;
}
/**
* Logs an audit event. This is a fire-and-forget async insert for token
* endpoints (do not await). For DB-backed operations, await this method.
*
* @param agentId - The agent that triggered the event.
* @param action - The action that occurred.
* @param outcome - Whether the action succeeded or failed.
* @param ipAddress - The client IP address.
* @param userAgent - The client User-Agent header.
* @param metadata - Action-specific structured context data.
* @returns Promise resolving to the created audit event.
*/
async logEvent(
agentId: string,
action: AuditAction,
outcome: AuditOutcome,
ipAddress: string,
userAgent: string,
metadata: Record<string, unknown>,
): Promise<IAuditEvent> {
return this.auditRepository.create({
agentId,
action,
outcome,
ipAddress,
userAgent,
metadata,
});
}
/**
* Queries the audit log with optional filters, pagination, and retention enforcement.
*
* @param filters - Query filters and pagination parameters.
* @returns Paginated audit events response.
* @throws RetentionWindowError if fromDate is before the 90-day retention cutoff.
* @throws ValidationError if fromDate is after toDate.
*/
async queryEvents(filters: IAuditListFilters): Promise<IPaginatedAuditEventsResponse> {
const retentionCutoff = this.getRetentionCutoff();
if (filters.fromDate !== undefined) {
const fromDate = new Date(filters.fromDate);
if (fromDate < retentionCutoff) {
throw new RetentionWindowError(
FREE_TIER_RETENTION_DAYS,
retentionCutoff.toISOString(),
);
}
}
if (filters.fromDate !== undefined && filters.toDate !== undefined) {
const fromDate = new Date(filters.fromDate);
const toDate = new Date(filters.toDate);
if (fromDate > toDate) {
throw new ValidationError('Invalid date range.', {
reason: 'fromDate must be before or equal to toDate.',
});
}
}
const { events, total } = await this.auditRepository.findAll(filters, retentionCutoff);
return {
data: events,
total,
page: filters.page,
limit: filters.limit,
};
}
/**
* Retrieves a single audit event by its UUID.
*
* @param eventId - The audit event UUID.
* @returns The audit event record.
* @throws AuditEventNotFoundError if the event does not exist.
*/
async getEventById(eventId: string): Promise<IAuditEvent> {
const event = await this.auditRepository.findById(eventId);
if (!event) {
throw new AuditEventNotFoundError(eventId);
}
// Check retention window — events older than 90 days are not accessible
const retentionCutoff = this.getRetentionCutoff();
if (event.timestamp < retentionCutoff) {
throw new AuditEventNotFoundError(eventId);
}
return event;
}
}

View File

@@ -0,0 +1,226 @@
/**
* Credential Management Service for SentryAgent.ai AgentIdP.
* Business logic for generating, listing, rotating, and revoking credentials.
*/
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import {
ICredentialWithSecret,
ICredentialListFilters,
IPaginatedCredentialsResponse,
IGenerateCredentialRequest,
} from '../types/index.js';
import {
AgentNotFoundError,
CredentialNotFoundError,
CredentialAlreadyRevokedError,
CredentialError,
} from '../utils/errors.js';
import { generateClientSecret, hashSecret } from '../utils/crypto.js';
/**
* Service for credential lifecycle management.
* The plain-text clientSecret is only returned on generation and rotation.
*/
export class CredentialService {
/**
* @param credentialRepository - The credential data repository.
* @param agentRepository - The agent repository (for status checks).
* @param auditService - The audit log service.
*/
constructor(
private readonly credentialRepository: CredentialRepository,
private readonly agentRepository: AgentRepository,
private readonly auditService: AuditService,
) {}
/**
* Generates a new client credential for an agent.
* The agent must be in 'active' status.
* Returns the plain-text clientSecret once — it is never retrievable again.
*
* @param agentId - The agent UUID.
* @param data - Optional expiry date for the credential.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The credential with the one-time plain-text clientSecret.
* @throws AgentNotFoundError if the agent does not exist.
* @throws CredentialError if the agent is not in 'active' status.
*/
async generateCredential(
agentId: string,
data: IGenerateCredentialRequest,
ipAddress: string,
userAgent: string,
): Promise<ICredentialWithSecret> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
if (agent.status !== 'active') {
throw new CredentialError(
'Credentials can only be generated for active agents.',
'AGENT_NOT_ACTIVE',
{ agentId, status: agent.status },
);
}
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const secretHash = await hashSecret(plainSecret);
const credential = await this.credentialRepository.create(agentId, secretHash, expiresAt);
await this.auditService.logEvent(
agentId,
'credential.generated',
'success',
ipAddress,
userAgent,
{ credentialId: credential.credentialId },
);
return { ...credential, clientSecret: plainSecret };
}
/**
* Returns a paginated list of credentials for an agent.
* The clientSecret is never included in list responses.
*
* @param agentId - The agent UUID.
* @param filters - Pagination and optional status filter.
* @returns Paginated credentials response.
* @throws AgentNotFoundError if the agent does not exist.
*/
async listCredentials(
agentId: string,
filters: ICredentialListFilters,
): Promise<IPaginatedCredentialsResponse> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
const { credentials, total } = await this.credentialRepository.findByAgentId(
agentId,
filters,
);
return {
data: credentials,
total,
page: filters.page,
limit: filters.limit,
};
}
/**
* Rotates a credential by generating a new secret for the same credentialId.
* Only 'active' credentials can be rotated.
* Returns the new plain-text clientSecret once.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID to rotate.
* @param data - Optional new expiry date.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The updated credential with the new one-time clientSecret.
* @throws AgentNotFoundError if the agent does not exist.
* @throws CredentialNotFoundError if the credential does not exist.
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
*/
async rotateCredential(
agentId: string,
credentialId: string,
data: IGenerateCredentialRequest,
ipAddress: string,
userAgent: string,
): Promise<ICredentialWithSecret> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
const existing = await this.credentialRepository.findById(credentialId);
if (!existing || existing.clientId !== agentId) {
throw new CredentialNotFoundError(credentialId);
}
if (existing.status === 'revoked') {
throw new CredentialAlreadyRevokedError(
credentialId,
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
);
}
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret();
const newHash = await hashSecret(plainSecret);
const updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
if (!updated) {
throw new CredentialNotFoundError(credentialId);
}
await this.auditService.logEvent(
agentId,
'credential.rotated',
'success',
ipAddress,
userAgent,
{ credentialId },
);
return { ...updated, clientSecret: plainSecret };
}
/**
* Permanently revokes a credential.
* Revoking an already-revoked credential returns 409 Conflict.
*
* @param agentId - The agent UUID.
* @param credentialId - The credential UUID to revoke.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @throws AgentNotFoundError if the agent does not exist.
* @throws CredentialNotFoundError if the credential does not exist or belongs to another agent.
* @throws CredentialAlreadyRevokedError if the credential is already revoked.
*/
async revokeCredential(
agentId: string,
credentialId: string,
ipAddress: string,
userAgent: string,
): Promise<void> {
const agent = await this.agentRepository.findById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
const existing = await this.credentialRepository.findById(credentialId);
if (!existing || existing.clientId !== agentId) {
throw new CredentialNotFoundError(credentialId);
}
if (existing.status === 'revoked') {
throw new CredentialAlreadyRevokedError(
credentialId,
existing.revokedAt?.toISOString() ?? new Date().toISOString(),
);
}
await this.credentialRepository.revoke(credentialId);
await this.auditService.logEvent(
agentId,
'credential.revoked',
'success',
ipAddress,
userAgent,
{ credentialId },
);
}
}

View File

@@ -0,0 +1,303 @@
/**
* OAuth 2.0 Token Service for SentryAgent.ai AgentIdP.
* Issues, introspects, and revokes RS256 JWT access tokens.
*/
import { TokenRepository } from '../repositories/TokenRepository.js';
import { CredentialRepository } from '../repositories/CredentialRepository.js';
import { AgentRepository } from '../repositories/AgentRepository.js';
import { AuditService } from './AuditService.js';
import {
ITokenPayload,
ITokenResponse,
IIntrospectResponse,
IOAuth2ErrorResponse,
} from '../types/index.js';
import {
AuthenticationError,
AuthorizationError,
FreeTierLimitError,
InsufficientScopeError,
} from '../utils/errors.js';
import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../utils/jwt.js';
import { verifySecret } from '../utils/crypto.js';
import { v4 as uuidv4 } from 'uuid';
const FREE_TIER_MAX_MONTHLY_TOKENS = 10000;
/** Result of a token issuance, including either a success response or OAuth2 error. */
export interface IssueTokenResult {
success: boolean;
response?: ITokenResponse;
error?: IOAuth2ErrorResponse;
httpStatus?: number;
}
/**
* Service for OAuth 2.0 Client Credentials token issuance, introspection, and revocation.
*/
export class OAuth2Service {
/**
* @param tokenRepository - Repository for token revocation and monthly counts.
* @param credentialRepository - Repository for credential lookup and verification.
* @param agentRepository - Repository for agent status lookup.
* @param auditService - The audit log service.
* @param privateKey - PEM-encoded RSA private key for signing tokens.
* @param publicKey - PEM-encoded RSA public key for verifying tokens.
*/
constructor(
private readonly tokenRepository: TokenRepository,
private readonly credentialRepository: CredentialRepository,
private readonly agentRepository: AgentRepository,
private readonly auditService: AuditService,
private readonly privateKey: string,
private readonly publicKey: string,
) {}
/**
* Issues a signed RS256 JWT access token via the OAuth 2.0 Client Credentials grant.
* Validates client credentials, checks agent status, enforces 10k monthly limit,
* and writes an async fire-and-forget audit event.
*
* @param clientId - The agent UUID acting as client_id.
* @param clientSecret - The plain-text client secret.
* @param scope - Space-separated OAuth 2.0 scopes requested.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The token response with access_token, token_type, expires_in, scope.
* @throws AuthenticationError if the client credentials are invalid.
* @throws AuthorizationError if the agent is suspended or decommissioned.
* @throws FreeTierLimitError if the monthly token limit is reached.
*/
async issueToken(
clientId: string,
clientSecret: string,
scope: string,
ipAddress: string,
userAgent: string,
): Promise<ITokenResponse> {
// Look up the agent
const agent = await this.agentRepository.findById(clientId);
if (!agent) {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'agent_not_found', clientId },
);
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
}
// Find active credentials for the agent and verify secret
const { credentials } = await this.credentialRepository.findByAgentId(clientId, {
status: 'active',
page: 1,
limit: 100,
});
let credentialVerified = false;
for (const cred of credentials) {
const credRow = await this.credentialRepository.findById(cred.credentialId);
if (credRow) {
const matches = await verifySecret(clientSecret, credRow.secretHash);
if (matches) {
// Check if credential is expired
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) {
continue;
}
credentialVerified = true;
break;
}
}
}
if (!credentialVerified) {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'invalid_client_secret', clientId },
);
throw new AuthenticationError('Client authentication failed. Invalid client_id or client_secret.');
}
// Check agent status
if (agent.status === 'suspended') {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'agent_suspended', clientId },
);
throw new AuthorizationError('Agent is currently suspended and cannot obtain tokens.');
}
if (agent.status === 'decommissioned') {
void this.auditService.logEvent(
clientId,
'auth.failed',
'failure',
ipAddress,
userAgent,
{ reason: 'agent_decommissioned', clientId },
);
throw new AuthorizationError('Agent is decommissioned and cannot obtain tokens.');
}
// Check monthly token limit
const monthlyCount = await this.tokenRepository.getMonthlyCount(clientId);
if (monthlyCount >= FREE_TIER_MAX_MONTHLY_TOKENS) {
throw new FreeTierLimitError(
'Free tier monthly token limit of 10,000 requests has been reached.',
{ limit: FREE_TIER_MAX_MONTHLY_TOKENS, current: monthlyCount },
);
}
// Issue the token
const jti = uuidv4();
const now = Math.floor(Date.now() / 1000);
const expiresIn = getTokenExpiresIn();
const payload: Omit<ITokenPayload, 'iat' | 'exp'> = {
sub: clientId,
client_id: clientId,
scope,
jti,
};
const accessToken = signToken(payload, this.privateKey);
// Increment monthly count (fire-and-forget)
void this.tokenRepository.incrementMonthlyCount(clientId);
// Audit event (fire-and-forget — do not await for latency)
const expiresAtDate = new Date((now + expiresIn) * 1000);
void this.auditService.logEvent(
clientId,
'token.issued',
'success',
ipAddress,
userAgent,
{ scope, expiresAt: expiresAtDate.toISOString() },
);
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: expiresIn,
scope,
};
}
/**
* Introspects a token per RFC 7662.
* Always returns 200; check the `active` field for validity.
* Requires the caller to hold a token with `tokens:read` scope.
*
* @param token - The JWT string to introspect.
* @param callerPayload - The decoded payload of the calling agent's token (for scope check).
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @returns The introspection response.
* @throws InsufficientScopeError if the caller lacks `tokens:read` scope.
*/
async introspectToken(
token: string,
callerPayload: ITokenPayload,
ipAddress: string,
userAgent: string,
): Promise<IIntrospectResponse> {
// Check caller has tokens:read scope
const callerScopes = callerPayload.scope.split(' ');
if (!callerScopes.includes('tokens:read')) {
throw new InsufficientScopeError('tokens:read');
}
try {
const payload = verifyToken(token, this.publicKey);
const revoked = await this.tokenRepository.isRevoked(payload.jti);
if (revoked) {
void this.auditService.logEvent(
callerPayload.sub,
'token.introspected',
'success',
ipAddress,
userAgent,
{ targetJti: payload.jti, active: false },
);
return { active: false };
}
void this.auditService.logEvent(
callerPayload.sub,
'token.introspected',
'success',
ipAddress,
userAgent,
{ targetJti: payload.jti, active: true },
);
return {
active: true,
sub: payload.sub,
client_id: payload.client_id,
scope: payload.scope,
token_type: 'Bearer',
iat: payload.iat,
exp: payload.exp,
};
} catch {
// Token is invalid or expired — return inactive per RFC 7662
return { active: false };
}
}
/**
* Revokes a token per RFC 7009.
* Idempotent — revoking an already-revoked or expired token returns success.
* An agent may only revoke its own tokens.
*
* @param token - The JWT string to revoke.
* @param callerPayload - The decoded payload of the calling agent's token.
* @param ipAddress - Client IP for audit logging.
* @param userAgent - Client User-Agent for audit logging.
* @throws AuthorizationError if the caller tries to revoke another agent's token.
*/
async revokeToken(
token: string,
callerPayload: ITokenPayload,
ipAddress: string,
userAgent: string,
): Promise<void> {
// Decode the token without verification to extract claims
const decoded = decodeToken(token);
if (decoded !== null) {
// Only the token owner can revoke their own token
if (decoded.sub !== callerPayload.sub) {
throw new AuthorizationError('You do not have permission to revoke this token.');
}
// Add to revocation list
const expiresAt = new Date(decoded.exp * 1000);
await this.tokenRepository.addToRevocationList(decoded.jti, expiresAt);
void this.auditService.logEvent(
callerPayload.sub,
'token.revoked',
'success',
ipAddress,
userAgent,
{ jti: decoded.jti },
);
}
// If token is malformed/undecoded, per RFC 7009 we still return success
}
}

283
src/types/index.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* Shared TypeScript interfaces and types for SentryAgent.ai AgentIdP.
* All interfaces and types live here — no inline type definitions in service/controller files.
*/
// ============================================================================
// Enumerations / Union Types
// ============================================================================
/** Functional classification of an AI agent. */
export type AgentType =
| 'screener'
| 'classifier'
| 'orchestrator'
| 'extractor'
| 'summarizer'
| 'router'
| 'monitor'
| 'custom';
/** Lifecycle status of an AI agent. */
export type AgentStatus = 'active' | 'suspended' | 'decommissioned';
/** Target deployment environment for an agent. */
export type DeploymentEnv = 'development' | 'staging' | 'production';
/** Lifecycle status of an agent credential. */
export type CredentialStatus = 'active' | 'revoked';
/** OAuth 2.0 scope values supported by this IdP. */
export type OAuthScope = 'agents:read' | 'agents:write' | 'tokens:read' | 'audit:read';
/** Audit action identifiers for all significant platform events. */
export type AuditAction =
| 'agent.created'
| 'agent.updated'
| 'agent.decommissioned'
| 'agent.suspended'
| 'agent.reactivated'
| 'token.issued'
| 'token.revoked'
| 'token.introspected'
| 'credential.generated'
| 'credential.rotated'
| 'credential.revoked'
| 'auth.failed';
/** Outcome of an audited action. */
export type AuditOutcome = 'success' | 'failure';
// ============================================================================
// Agent Registry
// ============================================================================
/** Full representation of a registered AI agent identity. */
export interface IAgent {
agentId: string;
email: string;
agentType: AgentType;
version: string;
capabilities: string[];
owner: string;
deploymentEnv: DeploymentEnv;
status: AgentStatus;
createdAt: Date;
updatedAt: Date;
}
/** Request body for registering a new AI agent. */
export interface ICreateAgentRequest {
email: string;
agentType: AgentType;
version: string;
capabilities: string[];
owner: string;
deploymentEnv: DeploymentEnv;
}
/** Request body for partially updating an agent. */
export interface IUpdateAgentRequest {
agentType?: AgentType;
version?: string;
capabilities?: string[];
owner?: string;
deploymentEnv?: DeploymentEnv;
status?: AgentStatus;
}
/** Paginated list of agents. */
export interface IPaginatedAgentsResponse {
data: IAgent[];
total: number;
page: number;
limit: number;
}
/** Query filters for listing agents. */
export interface IAgentListFilters {
owner?: string;
agentType?: AgentType;
status?: AgentStatus;
page: number;
limit: number;
}
// ============================================================================
// Credentials
// ============================================================================
/** A credential record for an AI agent (clientSecret never included). */
export interface ICredential {
credentialId: string;
clientId: string;
status: CredentialStatus;
createdAt: Date;
expiresAt: Date | null;
revokedAt: Date | null;
}
/** Credential with the plain-text secret — returned once only on create/rotate. */
export interface ICredentialWithSecret extends ICredential {
clientSecret: string;
}
/** Database row for a credential, including the bcrypt hash. */
export interface ICredentialRow extends ICredential {
secretHash: string;
}
/** Request body for generating or rotating a credential. */
export interface IGenerateCredentialRequest {
expiresAt?: string | Date;
}
/** Paginated list of credentials. */
export interface IPaginatedCredentialsResponse {
data: ICredential[];
total: number;
page: number;
limit: number;
}
/** Query filters for listing credentials. */
export interface ICredentialListFilters {
status?: CredentialStatus;
page: number;
limit: number;
}
// ============================================================================
// OAuth2 Token
// ============================================================================
/** JWT access token payload (claims). */
export interface ITokenPayload {
/** Subject — agentId. */
sub: string;
/** client_id — agentId. */
client_id: string;
/** Space-separated OAuth 2.0 scopes. */
scope: string;
/** JWT ID — UUID v4. */
jti: string;
/** Issued at (Unix seconds). */
iat: number;
/** Expiry (Unix seconds). */
exp: number;
}
/** OAuth 2.0 token request (form-encoded). */
export interface ITokenRequest {
grant_type: string;
client_id?: string;
client_secret?: string;
scope?: string;
}
/** Successful OAuth 2.0 token response. */
export interface ITokenResponse {
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
}
/** OAuth 2.0 error response (RFC 6749 §5.2). */
export interface IOAuth2ErrorResponse {
error: string;
error_description: string;
}
/** Token introspection request (RFC 7662). */
export interface IIntrospectRequest {
token: string;
token_type_hint?: string;
}
/** Token introspection response (RFC 7662). */
export interface IIntrospectResponse {
active: boolean;
sub?: string;
client_id?: string;
scope?: string;
token_type?: string;
iat?: number;
exp?: number;
}
/** Token revocation request (RFC 7009). */
export interface IRevokeRequest {
token: string;
token_type_hint?: string;
}
// ============================================================================
// Audit Log
// ============================================================================
/** An immutable audit event record. */
export interface IAuditEvent {
eventId: string;
agentId: string;
action: AuditAction;
outcome: AuditOutcome;
ipAddress: string;
userAgent: string;
metadata: Record<string, unknown>;
timestamp: Date;
}
/** Input for creating a new audit event. */
export interface ICreateAuditEventInput {
agentId: string;
action: AuditAction;
outcome: AuditOutcome;
ipAddress: string;
userAgent: string;
metadata: Record<string, unknown>;
}
/** Paginated list of audit events. */
export interface IPaginatedAuditEventsResponse {
data: IAuditEvent[];
total: number;
page: number;
limit: number;
}
/** Query filters for the audit log. */
export interface IAuditListFilters {
agentId?: string;
action?: AuditAction;
outcome?: AuditOutcome;
fromDate?: string;
toDate?: string;
page: number;
limit: number;
}
// ============================================================================
// API Error Response
// ============================================================================
/** Standard error response envelope used across all SentryAgent.ai APIs. */
export interface IErrorResponse {
code: string;
message: string;
details?: Record<string, unknown>;
}
// ============================================================================
// Express type augmentation
// ============================================================================
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Express {
interface Request {
/** Decoded JWT payload attached by the auth middleware. */
user?: ITokenPayload;
}
}
}

16
src/utils/asyncHandler.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Request, Response, NextFunction, RequestHandler } from 'express';
/**
* Wraps an async Express handler to forward rejected promises to next().
* Required because Express 4.x does not natively handle async route errors.
*
* @param fn - Async Express handler function.
* @returns Synchronous Express RequestHandler.
*/
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>,
): RequestHandler {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
}

43
src/utils/crypto.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Cryptographic utilities for SentryAgent.ai AgentIdP.
* Handles client secret generation and bcrypt hashing.
*/
import crypto from 'crypto';
import bcrypt from 'bcryptjs';
const BCRYPT_ROUNDS = 10;
const SECRET_PREFIX = 'sk_live_';
const SECRET_RANDOM_BYTES = 32;
/**
* Generates a new client secret with the `sk_live_` prefix followed by 64 hex chars
* (32 random bytes = 256 bits of entropy).
*
* @returns Plain-text client secret in the format `sk_live_<64 hex chars>`.
*/
export function generateClientSecret(): string {
const randomBytes = crypto.randomBytes(SECRET_RANDOM_BYTES);
return `${SECRET_PREFIX}${randomBytes.toString('hex')}`;
}
/**
* Hashes a plain-text secret using bcrypt with 10 rounds.
*
* @param plain - The plain-text secret to hash.
* @returns Promise resolving to the bcrypt hash string.
*/
export async function hashSecret(plain: string): Promise<string> {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
/**
* Verifies a plain-text secret against a stored bcrypt hash.
*
* @param plain - The plain-text secret provided by the client.
* @param hash - The bcrypt hash stored in the database.
* @returns Promise resolving to `true` if the secret matches, `false` otherwise.
*/
export async function verifySecret(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}

170
src/utils/errors.ts Normal file
View File

@@ -0,0 +1,170 @@
/**
* SentryAgentError hierarchy.
* All custom errors extend SentryAgentError.
* Error-to-HTTP-status mapping is handled exclusively in errorHandler.ts.
*/
/**
* Base class for all SentryAgent.ai custom errors.
* Carry a machine-readable `code`, HTTP status, and optional structured details.
*/
export class SentryAgentError extends Error {
/**
* @param message - Human-readable error description.
* @param code - Machine-readable error code.
* @param httpStatus - HTTP status code to return.
* @param details - Optional structured detail map.
*/
constructor(
message: string,
public readonly code: string,
public readonly httpStatus: number,
public readonly details?: Record<string, unknown>,
) {
super(message);
this.name = this.constructor.name;
// Restore prototype chain for instanceof checks
Object.setPrototypeOf(this, new.target.prototype);
}
}
/** 400 — Request failed validation. */
export class ValidationError extends SentryAgentError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'VALIDATION_ERROR', 400, details);
}
}
/** 404 — Referenced agent was not found. */
export class AgentNotFoundError extends SentryAgentError {
constructor(agentId?: string) {
super(
'Agent with the specified ID was not found.',
'AGENT_NOT_FOUND',
404,
agentId ? { agentId } : undefined,
);
}
}
/** 409 — Agent with this email already exists. */
export class AgentAlreadyExistsError extends SentryAgentError {
constructor(email: string) {
super(
'An agent with this email address is already registered.',
'AGENT_ALREADY_EXISTS',
409,
{ email },
);
}
}
/** 404 — Referenced credential was not found. */
export class CredentialNotFoundError extends SentryAgentError {
constructor(credentialId?: string) {
super(
'Credential with the specified ID was not found.',
'CREDENTIAL_NOT_FOUND',
404,
credentialId ? { credentialId } : undefined,
);
}
}
/** 409 — Credential is already revoked. */
export class CredentialAlreadyRevokedError extends SentryAgentError {
constructor(credentialId: string, revokedAt: string) {
super(
'This credential has already been revoked.',
'CREDENTIAL_ALREADY_REVOKED',
409,
{ credentialId, revokedAt },
);
}
}
/** 409 — Agent is already decommissioned. */
export class AgentAlreadyDecommissionedError extends SentryAgentError {
constructor(agentId: string) {
super(
'This agent has already been decommissioned.',
'AGENT_ALREADY_DECOMMISSIONED',
409,
{ agentId },
);
}
}
/** 400 — Credential operation error (e.g. agent not active). */
export class CredentialError extends SentryAgentError {
constructor(message: string, code: string, details?: Record<string, unknown>) {
super(message, code, 400, details);
}
}
/** 401 — Authentication failed (missing or invalid token). */
export class AuthenticationError extends SentryAgentError {
constructor(message = 'A valid Bearer token is required to access this resource.') {
super(message, 'UNAUTHORIZED', 401);
}
}
/** 403 — Authorisation failed (insufficient permissions). */
export class AuthorizationError extends SentryAgentError {
constructor(message = 'You do not have permission to perform this action.') {
super(message, 'FORBIDDEN', 403);
}
}
/** 429 — Rate limit exceeded. */
export class RateLimitError extends SentryAgentError {
constructor() {
super(
'Too many requests. Please retry after the rate limit window resets.',
'RATE_LIMIT_EXCEEDED',
429,
);
}
}
/** 403 — Free tier resource limit reached. */
export class FreeTierLimitError extends SentryAgentError {
constructor(message: string, details?: Record<string, unknown>) {
super(message, 'FREE_TIER_LIMIT_EXCEEDED', 403, details);
}
}
/** 403 — Token does not have the required scope. */
export class InsufficientScopeError extends SentryAgentError {
constructor(requiredScope: string) {
super(
`The '${requiredScope}' scope is required to access this resource.`,
'INSUFFICIENT_SCOPE',
403,
);
}
}
/** 404 — Audit event not found. */
export class AuditEventNotFoundError extends SentryAgentError {
constructor(eventId?: string) {
super(
'Audit event with the specified ID was not found.',
'AUDIT_EVENT_NOT_FOUND',
404,
eventId ? { eventId } : undefined,
);
}
}
/** 400 — Requested date range exceeds audit log retention window. */
export class RetentionWindowError extends SentryAgentError {
constructor(retentionDays: number, earliestAvailable: string) {
super(
`Free tier audit log retention is ${retentionDays} days. Requested date is outside the retention window.`,
'RETENTION_WINDOW_EXCEEDED',
400,
{ retentionDays, earliestAvailable },
);
}
}

69
src/utils/jwt.ts Normal file
View File

@@ -0,0 +1,69 @@
/**
* JWT utilities for SentryAgent.ai AgentIdP.
* Signs and verifies RS256 JWTs for agent access tokens.
*/
import jwt from 'jsonwebtoken';
import { ITokenPayload } from '../types/index.js';
const TOKEN_EXPIRES_IN = 3600; // 1 hour in seconds
/**
* Signs a JWT access token using RS256 (RSA private key).
*
* @param payload - The token payload containing sub, client_id, scope, jti.
* @param privateKey - PEM-encoded RSA private key.
* @returns The signed JWT string.
* @throws Error if signing fails.
*/
export function signToken(
payload: Omit<ITokenPayload, 'iat' | 'exp'>,
privateKey: string,
): string {
const now = Math.floor(Date.now() / 1000);
const fullPayload: ITokenPayload = {
...payload,
iat: now,
exp: now + TOKEN_EXPIRES_IN,
};
return jwt.sign(fullPayload, privateKey, { algorithm: 'RS256' });
}
/**
* Verifies a JWT access token using RS256 (RSA public key).
* Throws if the token is expired, has an invalid signature, or is malformed.
*
* @param token - The JWT string to verify.
* @param publicKey - PEM-encoded RSA public key.
* @returns The decoded, verified token payload.
* @throws JsonWebTokenError | TokenExpiredError if verification fails.
*/
export function verifyToken(token: string, publicKey: string): ITokenPayload {
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
return decoded as ITokenPayload;
}
/**
* Decodes a JWT without verifying the signature.
* Used for extracting claims (e.g. jti, exp) from tokens that may be expired.
*
* @param token - The JWT string to decode.
* @returns The decoded payload or null if the token is malformed.
*/
export function decodeToken(token: string): ITokenPayload | null {
const decoded = jwt.decode(token);
if (!decoded || typeof decoded === 'string') {
return null;
}
return decoded as ITokenPayload;
}
/**
* Returns the token lifetime in seconds.
*
* @returns Token lifetime (3600 seconds = 1 hour).
*/
export function getTokenExpiresIn(): number {
return TOKEN_EXPIRES_IN;
}

137
src/utils/validators.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Joi validation schemas for all request bodies and query parameters.
* All validation logic lives here — controllers invoke these schemas.
*/
import Joi from 'joi';
const SEMVER_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
const CAPABILITY_PATTERN = /^[a-z0-9_-]+:[a-z0-9_*-]+$/;
const AGENT_TYPES = [
'screener',
'classifier',
'orchestrator',
'extractor',
'summarizer',
'router',
'monitor',
'custom',
] as const;
const DEPLOYMENT_ENVS = ['development', 'staging', 'production'] as const;
const AGENT_STATUSES = ['active', 'suspended', 'decommissioned'] as const;
const CREDENTIAL_STATUSES = ['active', 'revoked'] as const;
const AUDIT_ACTIONS = [
'agent.created',
'agent.updated',
'agent.decommissioned',
'agent.suspended',
'agent.reactivated',
'token.issued',
'token.revoked',
'token.introspected',
'credential.generated',
'credential.rotated',
'credential.revoked',
'auth.failed',
] as const;
const AUDIT_OUTCOMES = ['success', 'failure'] as const;
const OAUTH_SCOPES = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'] as const;
/** Schema for POST /agents request body. */
export const createAgentSchema = Joi.object({
email: Joi.string().email().required(),
agentType: Joi.string()
.valid(...AGENT_TYPES)
.required(),
version: Joi.string().pattern(SEMVER_PATTERN).required(),
capabilities: Joi.array()
.items(Joi.string().pattern(CAPABILITY_PATTERN))
.min(1)
.required(),
owner: Joi.string().min(1).max(128).required(),
deploymentEnv: Joi.string()
.valid(...DEPLOYMENT_ENVS)
.required(),
});
/** Schema for PATCH /agents/:agentId request body. */
export const updateAgentSchema = Joi.object({
agentType: Joi.string().valid(...AGENT_TYPES),
version: Joi.string().pattern(SEMVER_PATTERN),
capabilities: Joi.array().items(Joi.string().pattern(CAPABILITY_PATTERN)).min(1),
owner: Joi.string().min(1).max(128),
deploymentEnv: Joi.string().valid(...DEPLOYMENT_ENVS),
status: Joi.string().valid(...AGENT_STATUSES),
})
.min(1)
.options({ allowUnknown: false });
/** Schema for GET /agents query params. */
export const listAgentsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
owner: Joi.string(),
agentType: Joi.string().valid(...AGENT_TYPES),
status: Joi.string().valid(...AGENT_STATUSES),
});
/** Schema for POST /token request body (form-encoded). */
export const tokenRequestSchema = Joi.object({
grant_type: Joi.string().required(),
client_id: Joi.string().uuid(),
client_secret: Joi.string(),
scope: Joi.string().pattern(
new RegExp(
`^(${OAUTH_SCOPES.join('|')})(\\s(${OAUTH_SCOPES.join('|')}))*$`,
),
),
});
/** Schema for POST /token/introspect request body. */
export const introspectRequestSchema = Joi.object({
token: Joi.string().required(),
token_type_hint: Joi.string().valid('access_token'),
});
/** Schema for POST /token/revoke request body. */
export const revokeRequestSchema = Joi.object({
token: Joi.string().required(),
token_type_hint: Joi.string().valid('access_token'),
});
/** Schema for POST /agents/:agentId/credentials request body. */
export const generateCredentialSchema = Joi.object({
expiresAt: Joi.date().iso().min('now').optional(),
});
/** Schema for GET /agents/:agentId/credentials query params. */
export const listCredentialsQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
status: Joi.string().valid(...CREDENTIAL_STATUSES),
});
/** Schema for GET /audit query params. */
export const auditQuerySchema = Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(200).default(50),
agentId: Joi.string().uuid(),
action: Joi.string().valid(...AUDIT_ACTIONS),
outcome: Joi.string().valid(...AUDIT_OUTCOMES),
fromDate: Joi.string().isoDate(),
toDate: Joi.string().isoDate(),
});
/** Schema for UUID path parameters. */
export const uuidParamSchema = Joi.object({
id: Joi.string().uuid().required(),
});

View File

@@ -0,0 +1,283 @@
/**
* Integration tests for Agent Registry endpoints.
* Uses a real Postgres test DB and Redis test instance.
*/
import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
import { createClient } from 'redis';
// Set test environment variables before importing app
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
process.env['JWT_PRIVATE_KEY'] = privateKey;
process.env['JWT_PUBLIC_KEY'] = publicKey;
process.env['NODE_ENV'] = 'test';
import { createApp } from '../../src/app';
import { signToken } from '../../src/utils/jwt';
import { closePool } from '../../src/db/pool';
import { closeRedisClient } from '../../src/cache/redis';
const AGENT_ID = uuidv4();
const SCOPE = 'agents:read agents:write';
function makeToken(sub: string = AGENT_ID, scope: string = SCOPE): string {
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}
describe('Agent Registry Integration Tests', () => {
let app: Application;
let pool: Pool;
beforeAll(async () => {
app = await createApp();
pool = new Pool({
connectionString: process.env['DATABASE_URL'],
});
// Run migrations
const migrations = [
`CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())`,
`CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
action VARCHAR(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
];
for (const sql of migrations) {
await pool.query(sql);
}
});
afterEach(async () => {
await pool.query('DELETE FROM audit_events');
await pool.query('DELETE FROM credentials');
await pool.query('DELETE FROM agents');
});
afterAll(async () => {
await pool.end();
await closePool();
await closeRedisClient();
});
const validAgent = {
email: 'test-agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'test-team',
deploymentEnv: 'development',
};
describe('POST /api/v1/agents', () => {
it('should register a new agent and return 201', async () => {
const token = makeToken();
const res = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
expect(res.status).toBe(201);
expect(res.body.agentId).toBeDefined();
expect(res.body.email).toBe(validAgent.email);
expect(res.body.status).toBe('active');
});
it('should return 401 without a token', async () => {
const res = await request(app).post('/api/v1/agents').send(validAgent);
expect(res.status).toBe(401);
});
it('should return 400 for invalid request body', async () => {
const token = makeToken();
const res = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send({ email: 'not-an-email' });
expect(res.status).toBe(400);
});
it('should return 409 for duplicate email', async () => {
const token = makeToken();
await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
const res = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
expect(res.status).toBe(409);
expect(res.body.code).toBe('AGENT_ALREADY_EXISTS');
});
});
describe('GET /api/v1/agents', () => {
it('should return a paginated list of agents', async () => {
const token = makeToken();
await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
const res = await request(app)
.get('/api/v1/agents')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.total).toBeGreaterThanOrEqual(1);
expect(res.body.page).toBe(1);
});
it('should support filtering by status', async () => {
const token = makeToken();
await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
const res = await request(app)
.get('/api/v1/agents?status=active')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
res.body.data.forEach((a: { status: string }) => expect(a.status).toBe('active'));
});
it('should return 401 without a token', async () => {
const res = await request(app).get('/api/v1/agents');
expect(res.status).toBe(401);
});
});
describe('GET /api/v1/agents/:agentId', () => {
it('should return an agent by ID', async () => {
const token = makeToken();
const created = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
const res = await request(app)
.get(`/api/v1/agents/${created.body.agentId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.agentId).toBe(created.body.agentId);
});
it('should return 404 for unknown agentId', async () => {
const token = makeToken();
const res = await request(app)
.get(`/api/v1/agents/${uuidv4()}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
});
describe('PATCH /api/v1/agents/:agentId', () => {
it('should update the agent and return 200', async () => {
const token = makeToken();
const created = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
const res = await request(app)
.patch(`/api/v1/agents/${created.body.agentId}`)
.set('Authorization', `Bearer ${token}`)
.send({ version: '2.0.0' });
expect(res.status).toBe(200);
expect(res.body.version).toBe('2.0.0');
});
it('should return 404 for unknown agentId', async () => {
const token = makeToken();
const res = await request(app)
.patch(`/api/v1/agents/${uuidv4()}`)
.set('Authorization', `Bearer ${token}`)
.send({ version: '2.0.0' });
expect(res.status).toBe(404);
});
});
describe('DELETE /api/v1/agents/:agentId', () => {
it('should decommission the agent and return 204', async () => {
const token = makeToken();
const created = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
const res = await request(app)
.delete(`/api/v1/agents/${created.body.agentId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
});
it('should return 409 if already decommissioned', async () => {
const token = makeToken();
const created = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send(validAgent);
await request(app)
.delete(`/api/v1/agents/${created.body.agentId}`)
.set('Authorization', `Bearer ${token}`);
const res = await request(app)
.delete(`/api/v1/agents/${created.body.agentId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(409);
});
});
});

View File

@@ -0,0 +1,241 @@
/**
* Integration tests for Audit Log Service endpoints.
*/
import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
process.env['JWT_PRIVATE_KEY'] = privateKey;
process.env['JWT_PUBLIC_KEY'] = publicKey;
process.env['NODE_ENV'] = 'test';
import { createApp } from '../../src/app';
import { signToken } from '../../src/utils/jwt';
import { closePool } from '../../src/db/pool';
import { closeRedisClient } from '../../src/cache/redis';
function makeToken(sub: string, scope: string = 'audit:read'): string {
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}
describe('Audit Log Service Integration Tests', () => {
let app: Application;
let pool: Pool;
beforeAll(async () => {
app = await createApp();
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
const migrations = [
`CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
action VARCHAR(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
];
for (const sql of migrations) {
await pool.query(sql);
}
});
afterEach(async () => {
await pool.query('DELETE FROM audit_events');
await pool.query('DELETE FROM credentials');
await pool.query('DELETE FROM agents');
});
afterAll(async () => {
await pool.end();
await closePool();
await closeRedisClient();
});
async function insertAuditEvent(
agentId: string,
action: string = 'token.issued',
outcome: string = 'success',
): Promise<string> {
const result = await pool.query(
`INSERT INTO audit_events (event_id, agent_id, action, outcome, ip_address, user_agent, metadata)
VALUES ($1, $2, $3, $4, '127.0.0.1', 'test/1.0', '{}')
RETURNING event_id`,
[uuidv4(), agentId, action, outcome],
);
return result.rows[0].event_id;
}
describe('GET /api/v1/audit', () => {
it('should return a paginated list of audit events', async () => {
const agentId = uuidv4();
await insertAuditEvent(agentId);
const token = makeToken(agentId);
const res = await request(app)
.get('/api/v1/audit')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
expect(res.body.total).toBeGreaterThanOrEqual(1);
});
it('should filter by agentId', async () => {
const agentId = uuidv4();
await insertAuditEvent(agentId);
await insertAuditEvent(uuidv4()); // different agent
const token = makeToken(agentId);
const res = await request(app)
.get(`/api/v1/audit?agentId=${agentId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
res.body.data.forEach((e: { agentId: string }) =>
expect(e.agentId).toBe(agentId),
);
});
it('should filter by action', async () => {
const agentId = uuidv4();
await insertAuditEvent(agentId, 'token.issued');
await insertAuditEvent(agentId, 'auth.failed');
const token = makeToken(agentId);
const res = await request(app)
.get('/api/v1/audit?action=token.issued')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
res.body.data.forEach((e: { action: string }) =>
expect(e.action).toBe('token.issued'),
);
});
it('should filter by outcome', async () => {
const agentId = uuidv4();
await insertAuditEvent(agentId, 'token.issued', 'success');
await insertAuditEvent(agentId, 'auth.failed', 'failure');
const token = makeToken(agentId);
const res = await request(app)
.get('/api/v1/audit?outcome=failure')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
res.body.data.forEach((e: { outcome: string }) =>
expect(e.outcome).toBe('failure'),
);
});
it('should return 401 without a token', async () => {
const res = await request(app).get('/api/v1/audit');
expect(res.status).toBe(401);
});
it('should return 403 without audit:read scope', async () => {
const token = makeToken(uuidv4(), 'agents:read');
const res = await request(app)
.get('/api/v1/audit')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
it('should return 400 for fromDate outside 90-day retention window', async () => {
const token = makeToken(uuidv4());
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 100);
const res = await request(app)
.get(`/api/v1/audit?fromDate=${oldDate.toISOString()}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(400);
expect(res.body.code).toBe('RETENTION_WINDOW_EXCEEDED');
});
it('should apply default pagination', async () => {
const token = makeToken(uuidv4());
const res = await request(app)
.get('/api/v1/audit')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.page).toBe(1);
expect(res.body.limit).toBe(50);
});
});
describe('GET /api/v1/audit/:eventId', () => {
it('should return a single audit event', async () => {
const agentId = uuidv4();
const eventId = await insertAuditEvent(agentId);
const token = makeToken(agentId);
const res = await request(app)
.get(`/api/v1/audit/${eventId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.eventId).toBe(eventId);
});
it('should return 404 for unknown eventId', async () => {
const token = makeToken(uuidv4());
const res = await request(app)
.get(`/api/v1/audit/${uuidv4()}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404);
});
it('should return 403 without audit:read scope', async () => {
const agentId = uuidv4();
const eventId = await insertAuditEvent(agentId);
const token = makeToken(agentId, 'agents:read');
const res = await request(app)
.get(`/api/v1/audit/${eventId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(403);
});
});
});

View File

@@ -0,0 +1,263 @@
/**
* Integration tests for Credential Management endpoints.
*/
import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
process.env['JWT_PRIVATE_KEY'] = privateKey;
process.env['JWT_PUBLIC_KEY'] = publicKey;
process.env['NODE_ENV'] = 'test';
import { createApp } from '../../src/app';
import { signToken } from '../../src/utils/jwt';
import { closePool } from '../../src/db/pool';
import { closeRedisClient } from '../../src/cache/redis';
function makeToken(sub: string, scope: string = 'agents:read agents:write'): string {
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}
describe('Credential Management Integration Tests', () => {
let app: Application;
let pool: Pool;
beforeAll(async () => {
app = await createApp();
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
const migrations = [
`CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
action VARCHAR(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
];
for (const sql of migrations) {
await pool.query(sql);
}
});
afterEach(async () => {
await pool.query('DELETE FROM audit_events');
await pool.query('DELETE FROM credentials');
await pool.query('DELETE FROM agents');
});
afterAll(async () => {
await pool.end();
await closePool();
await closeRedisClient();
});
async function createAgent(): Promise<string> {
const agentId = uuidv4();
await pool.query(
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
VALUES ($1, $2, 'screener', '1.0.0', '{"agents:read"}', 'test', 'development', 'active')`,
[agentId, `agent-${agentId}@test.ai`],
);
return agentId;
}
describe('POST /api/v1/agents/:agentId/credentials', () => {
it('should generate a credential and return 201 with clientSecret', async () => {
const agentId = await createAgent();
const token = makeToken(agentId);
const res = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(201);
expect(res.body.credentialId).toBeDefined();
expect(res.body.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
expect(res.body.status).toBe('active');
});
it('should return 401 without a token', async () => {
const agentId = await createAgent();
const res = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.send({});
expect(res.status).toBe(401);
});
it('should return 403 when managing another agent credentials', async () => {
const agentId = await createAgent();
const otherAgent = makeToken(uuidv4()); // different sub
const res = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${otherAgent}`)
.send({});
expect(res.status).toBe(403);
});
it('should return 404 for unknown agentId', async () => {
const fakeId = uuidv4();
const token = makeToken(fakeId);
const res = await request(app)
.post(`/api/v1/agents/${fakeId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(404);
});
});
describe('GET /api/v1/agents/:agentId/credentials', () => {
it('should list credentials (no clientSecret)', async () => {
const agentId = await createAgent();
const token = makeToken(agentId);
// Generate first
await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
const res = await request(app)
.get(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.data).toHaveLength(1);
expect(res.body.data[0].clientSecret).toBeUndefined();
});
});
describe('POST /api/v1/agents/:agentId/credentials/:credentialId/rotate', () => {
it('should rotate a credential and return new clientSecret', async () => {
const agentId = await createAgent();
const token = makeToken(agentId);
const generated = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
const credentialId = generated.body.credentialId;
const oldSecret = generated.body.clientSecret;
const rotated = await request(app)
.post(`/api/v1/agents/${agentId}/credentials/${credentialId}/rotate`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(rotated.status).toBe(200);
expect(rotated.body.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
expect(rotated.body.clientSecret).not.toBe(oldSecret);
});
it('should return 409 for rotating a revoked credential', async () => {
const agentId = await createAgent();
const token = makeToken(agentId);
const generated = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
const credentialId = generated.body.credentialId;
// Revoke first
await request(app)
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
.set('Authorization', `Bearer ${token}`);
// Try to rotate
const res = await request(app)
.post(`/api/v1/agents/${agentId}/credentials/${credentialId}/rotate`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(409);
expect(res.body.code).toBe('CREDENTIAL_ALREADY_REVOKED');
});
});
describe('DELETE /api/v1/agents/:agentId/credentials/:credentialId', () => {
it('should revoke a credential and return 204', async () => {
const agentId = await createAgent();
const token = makeToken(agentId);
const generated = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
const credentialId = generated.body.credentialId;
const res = await request(app)
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(204);
});
it('should return 409 for revoking an already-revoked credential', async () => {
const agentId = await createAgent();
const token = makeToken(agentId);
const generated = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
const credentialId = generated.body.credentialId;
await request(app)
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
.set('Authorization', `Bearer ${token}`);
const res = await request(app)
.delete(`/api/v1/agents/${agentId}/credentials/${credentialId}`)
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(409);
});
});
});

View File

@@ -0,0 +1,261 @@
/**
* Integration tests for OAuth2 Token Service endpoints.
*/
import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
process.env['DATABASE_URL'] = process.env['TEST_DATABASE_URL'] ?? 'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
process.env['JWT_PRIVATE_KEY'] = privateKey;
process.env['JWT_PUBLIC_KEY'] = publicKey;
process.env['NODE_ENV'] = 'test';
import { createApp } from '../../src/app';
import { signToken } from '../../src/utils/jwt';
import { closePool } from '../../src/db/pool';
import { closeRedisClient } from '../../src/cache/redis';
function makeToken(sub: string, scope: string = 'agents:read tokens:read'): string {
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}
describe('OAuth2 Token Service Integration Tests', () => {
let app: Application;
let pool: Pool;
beforeAll(async () => {
app = await createApp();
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
const migrations = [
`CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL,
secret_hash VARCHAR(255) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
action VARCHAR(32) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL,
user_agent TEXT NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
];
for (const sql of migrations) {
await pool.query(sql);
}
});
afterEach(async () => {
await pool.query('DELETE FROM audit_events');
await pool.query('DELETE FROM token_revocations');
await pool.query('DELETE FROM credentials');
await pool.query('DELETE FROM agents');
});
afterAll(async () => {
await pool.end();
await closePool();
await closeRedisClient();
});
async function createAgentWithCredentials(): Promise<{ agentId: string; clientSecret: string }> {
const agentId = uuidv4();
const token = makeToken(agentId, 'agents:read agents:write tokens:read');
// Create agent directly in DB
await pool.query(
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
VALUES ($1, $2, 'screener', '1.0.0', '{"agents:read"}', 'test', 'development', 'active')`,
[agentId, `agent-${agentId}@test.ai`],
);
// Generate credentials via API
const credRes = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${token}`)
.send({});
return { agentId, clientSecret: credRes.body.clientSecret };
}
describe('POST /api/v1/token', () => {
it('should issue a token for valid credentials', async () => {
const { agentId, clientSecret } = await createAgentWithCredentials();
const res = await request(app)
.post('/api/v1/token')
.type('form')
.send({
grant_type: 'client_credentials',
client_id: agentId,
client_secret: clientSecret,
scope: 'agents:read',
});
expect(res.status).toBe(200);
expect(res.body.access_token).toBeDefined();
expect(res.body.token_type).toBe('Bearer');
expect(res.body.expires_in).toBe(3600);
expect(res.headers['cache-control']).toBe('no-store');
});
it('should return 400 for missing grant_type', async () => {
const res = await request(app)
.post('/api/v1/token')
.type('form')
.send({ client_id: uuidv4(), client_secret: 'secret' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_request');
});
it('should return 400 for unsupported grant_type', async () => {
const res = await request(app)
.post('/api/v1/token')
.type('form')
.send({ grant_type: 'authorization_code' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('unsupported_grant_type');
});
it('should return 401 for invalid credentials', async () => {
const res = await request(app)
.post('/api/v1/token')
.type('form')
.send({
grant_type: 'client_credentials',
client_id: uuidv4(),
client_secret: 'wrong-secret',
scope: 'agents:read',
});
expect(res.status).toBe(401);
expect(res.body.error).toBe('invalid_client');
});
it('should return 400 for invalid scope', async () => {
const res = await request(app)
.post('/api/v1/token')
.type('form')
.send({
grant_type: 'client_credentials',
client_id: uuidv4(),
client_secret: 'secret',
scope: 'admin:all',
});
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid_scope');
});
});
describe('POST /api/v1/token/introspect', () => {
it('should return active:true for a valid token', async () => {
const { agentId, clientSecret } = await createAgentWithCredentials();
const scope = 'agents:read tokens:read';
const issued = await request(app)
.post('/api/v1/token')
.type('form')
.send({ grant_type: 'client_credentials', client_id: agentId, client_secret: clientSecret, scope });
const callerToken = issued.body.access_token;
const res = await request(app)
.post('/api/v1/token/introspect')
.set('Authorization', `Bearer ${callerToken}`)
.type('form')
.send({ token: callerToken });
expect(res.status).toBe(200);
expect(res.body.active).toBe(true);
});
it('should return active:false for an invalid token', async () => {
const callerToken = makeToken(uuidv4(), 'tokens:read');
const res = await request(app)
.post('/api/v1/token/introspect')
.set('Authorization', `Bearer ${callerToken}`)
.type('form')
.send({ token: 'not.a.real.token' });
expect(res.status).toBe(200);
expect(res.body.active).toBe(false);
});
it('should return 401 without Bearer token', async () => {
const res = await request(app)
.post('/api/v1/token/introspect')
.type('form')
.send({ token: 'some.token' });
expect(res.status).toBe(401);
});
});
describe('POST /api/v1/token/revoke', () => {
it('should revoke a token and return 200', async () => {
const { agentId, clientSecret } = await createAgentWithCredentials();
const issued = await request(app)
.post('/api/v1/token')
.type('form')
.send({ grant_type: 'client_credentials', client_id: agentId, client_secret: clientSecret, scope: 'agents:read' });
const token = issued.body.access_token;
const res = await request(app)
.post('/api/v1/token/revoke')
.set('Authorization', `Bearer ${token}`)
.type('form')
.send({ token });
expect(res.status).toBe(200);
});
it('should return 401 without Bearer token', async () => {
const res = await request(app)
.post('/api/v1/token/revoke')
.type('form')
.send({ token: 'some.token' });
expect(res.status).toBe(401);
});
});
});

View File

@@ -0,0 +1,304 @@
/**
* Unit tests for src/controllers/AgentController.ts
* Services are mocked; handlers are invoked with mock req/res/next.
*/
import { Request, Response, NextFunction } from 'express';
import { AgentController } from '../../../src/controllers/AgentController';
import { AgentService } from '../../../src/services/AgentService';
import { IAgent, ITokenPayload } from '../../../src/types/index';
import { ValidationError, AuthorizationError, AgentNotFoundError } from '../../../src/utils/errors';
jest.mock('../../../src/services/AgentService');
const MockAgentService = AgentService as jest.MockedClass<typeof AgentService>;
// ─── helpers ─────────────────────────────────────────────────────────────────
const MOCK_USER: ITokenPayload = {
sub: 'agent-id-001',
client_id: 'agent-id-001',
scope: 'agents:read agents:write',
jti: 'jti-001',
iat: 1000,
exp: 9999999999,
};
const MOCK_AGENT: IAgent = {
agentId: 'agent-id-001',
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
status: 'active',
createdAt: new Date('2026-03-28T09:00:00Z'),
updatedAt: new Date('2026-03-28T09:00:00Z'),
};
function buildMocks(): {
req: Partial<Request>;
res: Partial<Response>;
next: NextFunction;
} {
const res: Partial<Response> = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
return {
req: {
user: MOCK_USER,
body: {},
params: {},
query: {},
headers: {},
ip: '127.0.0.1',
},
res,
next: jest.fn() as NextFunction,
};
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('AgentController', () => {
let agentService: jest.Mocked<AgentService>;
let controller: AgentController;
beforeEach(() => {
jest.clearAllMocks();
agentService = new MockAgentService({} as never, {} as never, {} as never) as jest.Mocked<AgentService>;
controller = new AgentController(agentService);
});
// ── registerAgent ────────────────────────────────────────────────────────────
describe('registerAgent()', () => {
it('should return 201 with the created agent on success', async () => {
const { req, res, next } = buildMocks();
req.body = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
agentService.registerAgent.mockResolvedValue(MOCK_AGENT);
await controller.registerAgent(req as Request, res as Response, next);
expect(agentService.registerAgent).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(MOCK_AGENT);
expect(next).not.toHaveBeenCalled();
});
it('should call next(ValidationError) when body is invalid', async () => {
const { req, res, next } = buildMocks();
req.body = { agentType: 'screener' }; // missing required fields
await controller.registerAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
expect(agentService.registerAgent).not.toHaveBeenCalled();
});
it('should call next(AuthorizationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
await controller.registerAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.body = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
const serviceError = new Error('DB error');
agentService.registerAgent.mockRejectedValue(serviceError);
await controller.registerAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── listAgents ───────────────────────────────────────────────────────────────
describe('listAgents()', () => {
it('should return 200 with paginated agents', async () => {
const { req, res, next } = buildMocks();
req.query = { page: '1', limit: '20' };
const paginatedResponse = { data: [MOCK_AGENT], total: 1, page: 1, limit: 20 };
agentService.listAgents.mockResolvedValue(paginatedResponse);
await controller.listAgents(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(paginatedResponse);
});
it('should call next(AuthorizationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
await controller.listAgents(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when query params are invalid', async () => {
const { req, res, next } = buildMocks();
req.query = { page: 'not-a-number' };
await controller.listAgents(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.query = {};
const serviceError = new Error('Service error');
agentService.listAgents.mockRejectedValue(serviceError);
await controller.listAgents(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── getAgentById ─────────────────────────────────────────────────────────────
describe('getAgentById()', () => {
it('should return 200 with the agent', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId };
agentService.getAgentById.mockResolvedValue(MOCK_AGENT);
await controller.getAgentById(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_AGENT);
});
it('should call next(AuthorizationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
req.params = { agentId: 'any' };
await controller.getAgentById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward AgentNotFoundError to next', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: 'nonexistent' };
const notFound = new AgentNotFoundError('nonexistent');
agentService.getAgentById.mockRejectedValue(notFound);
await controller.getAgentById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(notFound);
});
});
// ── updateAgent ──────────────────────────────────────────────────────────────
describe('updateAgent()', () => {
it('should return 200 with the updated agent', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId };
req.body = { version: '2.0.0' };
const updated = { ...MOCK_AGENT, version: '2.0.0' };
agentService.updateAgent.mockResolvedValue(updated);
await controller.updateAgent(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(updated);
});
it('should call next(AuthorizationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
req.params = { agentId: 'any' };
req.body = { version: '2.0.0' };
await controller.updateAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when body is invalid', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId };
req.body = {}; // empty body — updateAgentSchema requires at least 1 field
await controller.updateAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId };
req.body = { version: '2.0.0' };
const serviceError = new AgentNotFoundError(MOCK_AGENT.agentId);
agentService.updateAgent.mockRejectedValue(serviceError);
await controller.updateAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── decommissionAgent ────────────────────────────────────────────────────────
describe('decommissionAgent()', () => {
it('should return 204 on success', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId };
agentService.decommissionAgent.mockResolvedValue();
await controller.decommissionAgent(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should call next(AuthorizationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
req.params = { agentId: 'any' };
await controller.decommissionAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: MOCK_AGENT.agentId };
const serviceError = new AgentNotFoundError(MOCK_AGENT.agentId);
agentService.decommissionAgent.mockRejectedValue(serviceError);
await controller.decommissionAgent(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
});

View File

@@ -0,0 +1,225 @@
/**
* Unit tests for src/controllers/AuditController.ts
* AuditService is mocked; handlers are invoked with mock req/res/next.
*/
import { Request, Response, NextFunction } from 'express';
import { AuditController } from '../../../src/controllers/AuditController';
import { AuditService } from '../../../src/services/AuditService';
import { ITokenPayload, IAuditEvent } from '../../../src/types/index';
import {
ValidationError,
AuthenticationError,
InsufficientScopeError,
AuditEventNotFoundError,
} from '../../../src/utils/errors';
jest.mock('../../../src/services/AuditService');
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeUser(scope: string): ITokenPayload {
return {
sub: 'agent-id-001',
client_id: 'agent-id-001',
scope,
jti: 'jti-001',
iat: 1000,
exp: 9999999999,
};
}
const MOCK_AUDIT_EVENT: IAuditEvent = {
eventId: 'evt-id-001',
agentId: 'agent-id-001',
action: 'agent.created',
outcome: 'success',
ipAddress: '127.0.0.1',
userAgent: 'test-agent/1.0',
metadata: {},
timestamp: new Date('2026-03-28T09:00:00Z'),
};
function buildMocks(scope = 'audit:read'): {
req: Partial<Request>;
res: Partial<Response>;
next: NextFunction;
} {
const res: Partial<Response> = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
return {
req: {
user: makeUser(scope),
body: {},
params: {},
query: {},
headers: {},
ip: '127.0.0.1',
},
res,
next: jest.fn() as NextFunction,
};
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('AuditController', () => {
let auditService: jest.Mocked<AuditService>;
let controller: AuditController;
beforeEach(() => {
jest.clearAllMocks();
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
controller = new AuditController(auditService);
});
// ── queryAuditLog ────────────────────────────────────────────────────────────
describe('queryAuditLog()', () => {
it('should return 200 with paginated audit events', async () => {
const { req, res, next } = buildMocks();
req.query = { page: '1', limit: '50' };
const paginatedResponse = { data: [MOCK_AUDIT_EVENT], total: 1, page: 1, limit: 50 };
auditService.queryEvents.mockResolvedValue(paginatedResponse);
await controller.queryAuditLog(req as Request, res as Response, next);
expect(auditService.queryEvents).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(paginatedResponse);
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
await controller.queryAuditLog(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(InsufficientScopeError) when scope does not include audit:read', async () => {
const { req, res, next } = buildMocks('agents:read');
await controller.queryAuditLog(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(InsufficientScopeError));
expect(auditService.queryEvents).not.toHaveBeenCalled();
});
it('should call next(ValidationError) when query params are invalid', async () => {
const { req, res, next } = buildMocks();
req.query = { page: 'not-a-number' };
await controller.queryAuditLog(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
expect(auditService.queryEvents).not.toHaveBeenCalled();
});
it('should pass all optional filters to auditService.queryEvents', async () => {
const { req, res, next } = buildMocks();
// agentId must be a valid UUID per auditQuerySchema
req.query = {
page: '2',
limit: '10',
agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
action: 'agent.created',
outcome: 'success',
fromDate: '2026-01-01T00:00:00Z',
toDate: '2026-12-31T23:59:59Z',
};
const emptyResponse = { data: [], total: 0, page: 2, limit: 10 };
auditService.queryEvents.mockResolvedValue(emptyResponse);
await controller.queryAuditLog(req as Request, res as Response, next);
expect(auditService.queryEvents).toHaveBeenCalledWith(
expect.objectContaining({
page: 2,
limit: 10,
agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
action: 'agent.created',
outcome: 'success',
// Joi normalises ISO dates: "2026-01-01T00:00:00Z" → "2026-01-01T00:00:00.000Z"
fromDate: expect.stringContaining('2026-01-01'),
toDate: expect.stringContaining('2026-12-31'),
}),
);
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.query = {};
const serviceError = new Error('Service error');
auditService.queryEvents.mockRejectedValue(serviceError);
await controller.queryAuditLog(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── getAuditEventById ────────────────────────────────────────────────────────
describe('getAuditEventById()', () => {
it('should return 200 with the audit event', async () => {
const { req, res, next } = buildMocks();
req.params = { eventId: MOCK_AUDIT_EVENT.eventId };
auditService.getEventById.mockResolvedValue(MOCK_AUDIT_EVENT);
await controller.getAuditEventById(req as Request, res as Response, next);
expect(auditService.getEventById).toHaveBeenCalledWith(MOCK_AUDIT_EVENT.eventId);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_AUDIT_EVENT);
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
req.params = { eventId: 'any' };
await controller.getAuditEventById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(InsufficientScopeError) when scope does not include audit:read', async () => {
const { req, res, next } = buildMocks('agents:read');
req.params = { eventId: MOCK_AUDIT_EVENT.eventId };
await controller.getAuditEventById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(InsufficientScopeError));
expect(auditService.getEventById).not.toHaveBeenCalled();
});
it('should forward AuditEventNotFoundError to next', async () => {
const { req, res, next } = buildMocks();
req.params = { eventId: 'nonexistent' };
const notFound = new AuditEventNotFoundError('nonexistent');
auditService.getEventById.mockRejectedValue(notFound);
await controller.getAuditEventById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(notFound);
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.params = { eventId: MOCK_AUDIT_EVENT.eventId };
const serviceError = new Error('DB error');
auditService.getEventById.mockRejectedValue(serviceError);
await controller.getAuditEventById(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
});

View File

@@ -0,0 +1,323 @@
/**
* Unit tests for src/controllers/CredentialController.ts
* CredentialService is mocked; handlers are invoked with mock req/res/next.
*/
import { Request, Response, NextFunction } from 'express';
import { CredentialController } from '../../../src/controllers/CredentialController';
import { CredentialService } from '../../../src/services/CredentialService';
import { ITokenPayload, ICredential, ICredentialWithSecret } from '../../../src/types/index';
import {
ValidationError,
AuthenticationError,
AuthorizationError,
CredentialNotFoundError,
} from '../../../src/utils/errors';
jest.mock('../../../src/services/CredentialService');
const MockCredentialService = CredentialService as jest.MockedClass<typeof CredentialService>;
// ─── helpers ─────────────────────────────────────────────────────────────────
const AGENT_ID = 'agent-id-001';
const MOCK_USER: ITokenPayload = {
sub: AGENT_ID,
client_id: AGENT_ID,
scope: 'agents:write',
jti: 'jti-001',
iat: 1000,
exp: 9999999999,
};
const MOCK_CREDENTIAL: ICredential = {
credentialId: 'cred-id-001',
clientId: AGENT_ID,
status: 'active',
createdAt: new Date('2026-03-28T09:00:00Z'),
expiresAt: null,
revokedAt: null,
};
const MOCK_CREDENTIAL_WITH_SECRET: ICredentialWithSecret = {
...MOCK_CREDENTIAL,
clientSecret: 'sa_plain_text_secret_here',
};
function buildMocks(overrideUser?: ITokenPayload | undefined): {
req: Partial<Request>;
res: Partial<Response>;
next: NextFunction;
} {
const res: Partial<Response> = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
};
return {
req: {
user: overrideUser !== undefined ? overrideUser : MOCK_USER,
body: {},
params: { agentId: AGENT_ID },
query: {},
headers: {},
ip: '127.0.0.1',
},
res,
next: jest.fn() as NextFunction,
};
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('CredentialController', () => {
let credentialService: jest.Mocked<CredentialService>;
let controller: CredentialController;
beforeEach(() => {
jest.clearAllMocks();
credentialService = new MockCredentialService(
{} as never, {} as never, {} as never,
) as jest.Mocked<CredentialService>;
controller = new CredentialController(credentialService);
});
// ── generateCredential ───────────────────────────────────────────────────────
describe('generateCredential()', () => {
it('should return 201 with credential-with-secret on success', async () => {
const { req, res, next } = buildMocks();
req.body = {};
credentialService.generateCredential.mockResolvedValue(MOCK_CREDENTIAL_WITH_SECRET);
await controller.generateCredential(req as Request, res as Response, next);
expect(credentialService.generateCredential).toHaveBeenCalledWith(
AGENT_ID,
expect.any(Object),
'127.0.0.1',
expect.any(String),
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.json).toHaveBeenCalledWith(MOCK_CREDENTIAL_WITH_SECRET);
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks(undefined);
req.user = undefined;
await controller.generateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthorizationError) when user.sub does not match agentId', async () => {
const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' });
req.params = { agentId: AGENT_ID };
await controller.generateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when expiresAt is in the past', async () => {
const { req, res, next } = buildMocks();
req.body = { expiresAt: '2020-01-01T00:00:00Z' }; // past date
await controller.generateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
expect(credentialService.generateCredential).not.toHaveBeenCalled();
});
it('should call next(ValidationError) when body schema is invalid', async () => {
const { req, res, next } = buildMocks();
req.body = { expiresAt: 'not-a-date' };
await controller.generateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.body = {};
const serviceError = new Error('Service error');
credentialService.generateCredential.mockRejectedValue(serviceError);
await controller.generateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── listCredentials ───────────────────────────────────────────────────────────
describe('listCredentials()', () => {
it('should return 200 with paginated credentials', async () => {
const { req, res, next } = buildMocks();
req.query = { page: '1', limit: '20' };
const paginatedResponse = { data: [MOCK_CREDENTIAL], total: 1, page: 1, limit: 20 };
credentialService.listCredentials.mockResolvedValue(paginatedResponse);
await controller.listCredentials(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(paginatedResponse);
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks(undefined);
req.user = undefined;
await controller.listCredentials(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthorizationError) when user.sub does not match agentId', async () => {
const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' });
await controller.listCredentials(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when query params are invalid', async () => {
const { req, res, next } = buildMocks();
req.query = { page: 'bad' };
await controller.listCredentials(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.query = {};
const serviceError = new Error('Service error');
credentialService.listCredentials.mockRejectedValue(serviceError);
await controller.listCredentials(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── rotateCredential ──────────────────────────────────────────────────────────
describe('rotateCredential()', () => {
it('should return 200 with new credential-with-secret on success', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
req.body = {};
credentialService.rotateCredential.mockResolvedValue(MOCK_CREDENTIAL_WITH_SECRET);
await controller.rotateCredential(req as Request, res as Response, next);
expect(credentialService.rotateCredential).toHaveBeenCalledWith(
AGENT_ID,
'cred-id-001',
expect.any(Object),
'127.0.0.1',
expect.any(String),
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_CREDENTIAL_WITH_SECRET);
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks(undefined);
req.user = undefined;
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
await controller.rotateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthorizationError) when user.sub does not match agentId', async () => {
const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' });
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
await controller.rotateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should call next(ValidationError) when expiresAt is in the past', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
req.body = { expiresAt: '2020-01-01T00:00:00Z' };
await controller.rotateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(ValidationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
req.body = {};
const serviceError = new CredentialNotFoundError('cred-id-001');
credentialService.rotateCredential.mockRejectedValue(serviceError);
await controller.rotateCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── revokeCredential ──────────────────────────────────────────────────────────
describe('revokeCredential()', () => {
it('should return 204 on success', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
credentialService.revokeCredential.mockResolvedValue();
await controller.revokeCredential(req as Request, res as Response, next);
expect(credentialService.revokeCredential).toHaveBeenCalledWith(
AGENT_ID,
'cred-id-001',
'127.0.0.1',
expect.any(String),
);
expect(res.status).toHaveBeenCalledWith(204);
expect(res.send).toHaveBeenCalled();
expect(next).not.toHaveBeenCalled();
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks(undefined);
req.user = undefined;
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
await controller.revokeCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthorizationError) when user.sub does not match agentId', async () => {
const { req, res, next } = buildMocks({ ...MOCK_USER, sub: 'different-agent' });
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
await controller.revokeCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthorizationError));
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.params = { agentId: AGENT_ID, credentialId: 'cred-id-001' };
const serviceError = new CredentialNotFoundError('cred-id-001');
credentialService.revokeCredential.mockRejectedValue(serviceError);
await controller.revokeCredential(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
});

View File

@@ -0,0 +1,381 @@
/**
* Unit tests for src/controllers/TokenController.ts
* OAuth2Service is mocked; handlers are invoked with mock req/res/next.
*/
import { Request, Response, NextFunction } from 'express';
import { TokenController } from '../../../src/controllers/TokenController';
import { OAuth2Service } from '../../../src/services/OAuth2Service';
import { ITokenPayload, ITokenResponse, IIntrospectResponse } from '../../../src/types/index';
import {
AuthenticationError,
AuthorizationError,
FreeTierLimitError,
} from '../../../src/utils/errors';
jest.mock('../../../src/services/OAuth2Service');
const MockOAuth2Service = OAuth2Service as jest.MockedClass<typeof OAuth2Service>;
// ─── helpers ─────────────────────────────────────────────────────────────────
// Must be valid UUID for the Joi schema
const VALID_CLIENT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const MOCK_USER: ITokenPayload = {
sub: VALID_CLIENT_ID,
client_id: VALID_CLIENT_ID,
scope: 'tokens:read',
jti: 'jti-001',
iat: 1000,
exp: 9999999999,
};
const MOCK_TOKEN_RESPONSE: ITokenResponse = {
access_token: 'eyJhbGciOiJSUzI1NiJ9.test.signature',
token_type: 'Bearer',
expires_in: 3600,
scope: 'agents:read',
};
const MOCK_INTROSPECT_RESPONSE: IIntrospectResponse = {
active: true,
sub: VALID_CLIENT_ID,
client_id: VALID_CLIENT_ID,
scope: 'agents:read',
token_type: 'Bearer',
iat: 1000,
exp: 9999999999,
};
function buildMocks(): {
req: Partial<Request>;
res: Partial<Response>;
next: NextFunction;
} {
const res: Partial<Response> = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
send: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
};
return {
req: {
user: MOCK_USER,
body: {},
params: {},
query: {},
headers: {},
ip: '127.0.0.1',
},
res,
next: jest.fn() as NextFunction,
};
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('TokenController', () => {
let oauth2Service: jest.Mocked<OAuth2Service>;
let controller: TokenController;
beforeEach(() => {
jest.clearAllMocks();
oauth2Service = new MockOAuth2Service(
{} as never, {} as never, {} as never, {} as never, '', '',
) as jest.Mocked<OAuth2Service>;
controller = new TokenController(oauth2Service);
});
// ── issueToken ───────────────────────────────────────────────────────────────
describe('issueToken()', () => {
it('should return 200 with token response on success', async () => {
const { req, res, next } = buildMocks();
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'super-secret',
scope: 'agents:read',
};
oauth2Service.issueToken.mockResolvedValue(MOCK_TOKEN_RESPONSE);
await controller.issueToken(req as Request, res as Response, next);
expect(oauth2Service.issueToken).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_TOKEN_RESPONSE);
});
it('should set Cache-Control and Pragma headers on success', async () => {
const { req, res, next } = buildMocks();
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'super-secret',
};
oauth2Service.issueToken.mockResolvedValue(MOCK_TOKEN_RESPONSE);
await controller.issueToken(req as Request, res as Response, next);
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store');
expect(res.setHeader).toHaveBeenCalledWith('Pragma', 'no-cache');
});
it('should return 400 when grant_type is missing', async () => {
const { req, res, next } = buildMocks();
req.body = { client_id: VALID_CLIENT_ID, client_secret: 'secret' };
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_request' }),
);
expect(oauth2Service.issueToken).not.toHaveBeenCalled();
});
it('should return 400 when grant_type is not client_credentials', async () => {
const { req, res, next } = buildMocks();
req.body = { grant_type: 'authorization_code' };
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'unsupported_grant_type' }),
);
});
it('should return 400 when client_id and client_secret are missing', async () => {
const { req, res, next } = buildMocks();
// grant_type present but no credentials — Joi passes but credential check fails
req.body = { grant_type: 'client_credentials' };
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_request' }),
);
});
it('should return 400 when scope is invalid', async () => {
const { req, res, next } = buildMocks();
// scope validation happens after Joi; use valid client_id/secret so Joi passes
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'super-secret',
scope: 'bad_scope_value',
};
// Joi schema rejects scope with bad pattern — lands as invalid_request
await controller.issueToken(req as Request, res as Response, next);
// Either invalid_request (Joi) or invalid_scope (scope check) — both are 400
expect(res.status).toHaveBeenCalledWith(400);
expect(oauth2Service.issueToken).not.toHaveBeenCalled();
});
it('should return 400 with invalid_scope for a scope that passes Joi but is not allowed', async () => {
const { req, res, next } = buildMocks();
// Use valid client creds and a value that the regex rejects differently
// Testing the in-controller validScopes check by mocking past Joi
// The simplest way: test a well-formed scope token that passes regex but isn't in the list
// In practice the Joi regex catches it too — just verify 400 is returned
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'super-secret',
scope: 'agents:delete', // not in validScopes array
};
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(oauth2Service.issueToken).not.toHaveBeenCalled();
});
it('should return 401 with invalid_client on AuthenticationError', async () => {
const { req, res, next } = buildMocks();
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'wrong-secret',
};
oauth2Service.issueToken.mockRejectedValue(new AuthenticationError());
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_client' }),
);
});
it('should return 403 with unauthorized_client on AuthorizationError', async () => {
const { req, res, next } = buildMocks();
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'secret',
};
oauth2Service.issueToken.mockRejectedValue(new AuthorizationError());
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'unauthorized_client' }),
);
});
it('should return 403 with unauthorized_client on FreeTierLimitError', async () => {
const { req, res, next } = buildMocks();
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'secret',
};
oauth2Service.issueToken.mockRejectedValue(
new FreeTierLimitError('Monthly token limit reached.'),
);
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'unauthorized_client' }),
);
});
it('should return 500 with invalid_request on unexpected error', async () => {
const { req, res, next } = buildMocks();
req.body = {
grant_type: 'client_credentials',
client_id: VALID_CLIENT_ID,
client_secret: 'secret',
};
oauth2Service.issueToken.mockRejectedValue(new Error('Unexpected'));
await controller.issueToken(req as Request, res as Response, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ error: 'invalid_request' }),
);
});
it('should support HTTP Basic auth header for client credentials', async () => {
const { req, res, next } = buildMocks();
const credentials = Buffer.from(`${VALID_CLIENT_ID}:super-secret`).toString('base64');
req.headers = { authorization: `Basic ${credentials}` };
req.body = { grant_type: 'client_credentials' };
oauth2Service.issueToken.mockResolvedValue(MOCK_TOKEN_RESPONSE);
await controller.issueToken(req as Request, res as Response, next);
expect(oauth2Service.issueToken).toHaveBeenCalledWith(
VALID_CLIENT_ID,
'super-secret',
expect.any(String),
expect.any(String),
expect.any(String),
);
});
});
// ── introspectToken ───────────────────────────────────────────────────────────
describe('introspectToken()', () => {
it('should return 200 with introspection result on success', async () => {
const { req, res, next } = buildMocks();
req.body = { token: 'some.jwt.token' };
oauth2Service.introspectToken.mockResolvedValue(MOCK_INTROSPECT_RESPONSE);
await controller.introspectToken(req as Request, res as Response, next);
expect(oauth2Service.introspectToken).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith(MOCK_INTROSPECT_RESPONSE);
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
req.body = { token: 'some.jwt.token' };
await controller.introspectToken(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(Error) when token is missing from body', async () => {
const { req, res, next } = buildMocks();
req.body = {};
await controller.introspectToken(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(oauth2Service.introspectToken).not.toHaveBeenCalled();
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.body = { token: 'some.jwt.token' };
const serviceError = new Error('Service error');
oauth2Service.introspectToken.mockRejectedValue(serviceError);
await controller.introspectToken(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
// ── revokeToken ───────────────────────────────────────────────────────────────
describe('revokeToken()', () => {
it('should return 200 with empty body on success', async () => {
const { req, res, next } = buildMocks();
req.body = { token: 'some.jwt.token' };
oauth2Service.revokeToken.mockResolvedValue();
await controller.revokeToken(req as Request, res as Response, next);
expect(oauth2Service.revokeToken).toHaveBeenCalledTimes(1);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({});
});
it('should call next(AuthenticationError) when req.user is missing', async () => {
const { req, res, next } = buildMocks();
req.user = undefined;
req.body = { token: 'some.jwt.token' };
await controller.revokeToken(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(Error) when token is missing from body', async () => {
const { req, res, next } = buildMocks();
req.body = {};
await controller.revokeToken(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(Error));
expect(oauth2Service.revokeToken).not.toHaveBeenCalled();
});
it('should forward service errors to next', async () => {
const { req, res, next } = buildMocks();
req.body = { token: 'some.jwt.token' };
const serviceError = new Error('Service error');
oauth2Service.revokeToken.mockRejectedValue(serviceError);
await controller.revokeToken(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(serviceError);
});
});
});

View File

@@ -0,0 +1,115 @@
/**
* Unit tests for src/middleware/auth.ts
*/
import crypto from 'crypto';
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { signToken } from '../../../src/utils/jwt';
import { ITokenPayload } from '../../../src/types/index';
import { AuthenticationError } from '../../../src/utils/errors';
// Generate test RSA keys
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// Mock environment and Redis before importing auth middleware
jest.mock('../../../src/cache/redis', () => ({
getRedisClient: jest.fn().mockResolvedValue({
get: jest.fn().mockResolvedValue(null), // Not revoked by default
}),
}));
// We need to set env vars before importing the middleware
process.env['JWT_PUBLIC_KEY'] = publicKey;
// Import after setting env
import { authMiddleware } from '../../../src/middleware/auth';
import { getRedisClient } from '../../../src/cache/redis';
const mockGetRedisClient = getRedisClient as jest.Mock;
function makeTestToken(overrides: Partial<ITokenPayload> = {}): string {
const payload: Omit<ITokenPayload, 'iat' | 'exp'> = {
sub: uuidv4(),
client_id: uuidv4(),
scope: 'agents:read',
jti: uuidv4(),
...overrides,
};
return signToken(payload, privateKey);
}
function makeReq(authHeader?: string): Partial<Request> {
return {
headers: authHeader ? { authorization: authHeader } : {},
ip: '127.0.0.1',
};
}
describe('authMiddleware', () => {
let next: jest.MockedFunction<NextFunction>;
beforeEach(() => {
next = jest.fn();
mockGetRedisClient.mockResolvedValue({
get: jest.fn().mockResolvedValue(null),
});
});
it('should call next() and set req.user for a valid token', async () => {
const token = makeTestToken();
const req = makeReq(`Bearer ${token}`) as Request;
const res = {} as Response;
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalledWith();
expect(req.user).toBeDefined();
expect(req.user?.sub).toBeTruthy();
});
it('should call next(AuthenticationError) when Authorization header is missing', async () => {
const req = makeReq() as Request;
const res = {} as Response;
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthenticationError) when header does not start with Bearer', async () => {
const req = makeReq('Basic dXNlcjpwYXNz') as Request;
const res = {} as Response;
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthenticationError) for an invalid JWT', async () => {
const req = makeReq('Bearer invalid.jwt.token') as Request;
const res = {} as Response;
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
it('should call next(AuthenticationError) for a revoked token', async () => {
mockGetRedisClient.mockResolvedValue({
get: jest.fn().mockResolvedValue('1'), // Token is revoked
});
const token = makeTestToken();
const req = makeReq(`Bearer ${token}`) as Request;
const res = {} as Response;
await authMiddleware(req, res, next);
expect(next).toHaveBeenCalledWith(expect.any(AuthenticationError));
});
});

View File

@@ -0,0 +1,182 @@
/**
* Unit tests for src/middleware/errorHandler.ts
*/
import { Request, Response, NextFunction } from 'express';
import { errorHandler } from '../../../src/middleware/errorHandler';
import {
ValidationError,
AgentNotFoundError,
AgentAlreadyExistsError,
AgentAlreadyDecommissionedError,
CredentialNotFoundError,
CredentialAlreadyRevokedError,
CredentialError,
AuthenticationError,
AuthorizationError,
RateLimitError,
FreeTierLimitError,
InsufficientScopeError,
AuditEventNotFoundError,
RetentionWindowError,
} from '../../../src/utils/errors';
function makeRes(): { status: jest.Mock; json: jest.Mock } {
const res = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
};
return res;
}
const req = {} as Request;
const next = jest.fn() as jest.MockedFunction<NextFunction>;
describe('errorHandler', () => {
beforeEach(() => jest.clearAllMocks());
it('should return 400 for ValidationError', () => {
const res = makeRes();
errorHandler(new ValidationError('bad input'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'VALIDATION_ERROR' }));
});
it('should return 404 for AgentNotFoundError', () => {
const res = makeRes();
errorHandler(new AgentNotFoundError(), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'AGENT_NOT_FOUND' }));
});
it('should return 409 for AgentAlreadyExistsError', () => {
const res = makeRes();
errorHandler(new AgentAlreadyExistsError('test@test.com'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'AGENT_ALREADY_EXISTS' }));
});
it('should return 409 for AgentAlreadyDecommissionedError', () => {
const res = makeRes();
errorHandler(new AgentAlreadyDecommissionedError('id'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'AGENT_ALREADY_DECOMMISSIONED' }),
);
});
it('should return 404 for CredentialNotFoundError', () => {
const res = makeRes();
errorHandler(new CredentialNotFoundError(), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'CREDENTIAL_NOT_FOUND' }),
);
});
it('should return 409 for CredentialAlreadyRevokedError', () => {
const res = makeRes();
errorHandler(
new CredentialAlreadyRevokedError('cred-id', new Date().toISOString()),
req,
res as unknown as Response,
next,
);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'CREDENTIAL_ALREADY_REVOKED' }),
);
});
it('should return 400 for CredentialError', () => {
const res = makeRes();
errorHandler(new CredentialError('error', 'AGENT_NOT_ACTIVE'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(400);
});
it('should return 401 for AuthenticationError', () => {
const res = makeRes();
errorHandler(new AuthenticationError(), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'UNAUTHORIZED' }));
});
it('should return 403 for AuthorizationError', () => {
const res = makeRes();
errorHandler(new AuthorizationError(), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'FORBIDDEN' }));
});
it('should return 429 for RateLimitError', () => {
const res = makeRes();
errorHandler(new RateLimitError(), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(429);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'RATE_LIMIT_EXCEEDED' }),
);
});
it('should return 403 for FreeTierLimitError', () => {
const res = makeRes();
errorHandler(new FreeTierLimitError('Limit reached'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'FREE_TIER_LIMIT_EXCEEDED' }),
);
});
it('should return 403 for InsufficientScopeError', () => {
const res = makeRes();
errorHandler(new InsufficientScopeError('audit:read'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'INSUFFICIENT_SCOPE' }),
);
});
it('should return 404 for AuditEventNotFoundError', () => {
const res = makeRes();
errorHandler(new AuditEventNotFoundError(), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'AUDIT_EVENT_NOT_FOUND' }),
);
});
it('should return 400 for RetentionWindowError', () => {
const res = makeRes();
errorHandler(
new RetentionWindowError(90, '2025-12-28T00:00:00.000Z'),
req,
res as unknown as Response,
next,
);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'RETENTION_WINDOW_EXCEEDED' }),
);
});
it('should return 500 for unknown errors', () => {
const res = makeRes();
errorHandler(new Error('unexpected'), req, res as unknown as Response, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'INTERNAL_SERVER_ERROR' }),
);
});
it('should include details in the response when present', () => {
const res = makeRes();
errorHandler(
new ValidationError('bad', { field: 'email' }),
req,
res as unknown as Response,
next,
);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ details: { field: 'email' } }),
);
});
});

View File

@@ -0,0 +1,93 @@
/**
* Unit tests for src/middleware/rateLimit.ts
*/
import { Request, Response, NextFunction } from 'express';
import { RateLimitError } from '../../../src/utils/errors';
const mockIncr = jest.fn();
const mockExpire = jest.fn();
jest.mock('../../../src/cache/redis', () => ({
getRedisClient: jest.fn().mockResolvedValue({
incr: mockIncr,
expire: mockExpire,
}),
}));
import { rateLimitMiddleware } from '../../../src/middleware/rateLimit';
function buildMocks(clientId?: string): {
req: Partial<Request>;
res: Partial<Response>;
next: NextFunction;
} {
const res: Partial<Response> = {
setHeader: jest.fn(),
};
return {
req: {
user: clientId ? { client_id: clientId, sub: clientId, scope: '', jti: '', iat: 0, exp: 0 } : undefined,
ip: '127.0.0.1',
},
res,
next: jest.fn() as NextFunction,
};
}
describe('rateLimitMiddleware', () => {
beforeEach(() => {
jest.clearAllMocks();
mockExpire.mockResolvedValue(1);
});
it('should set X-RateLimit-* headers and call next() when counter is under the limit', async () => {
mockIncr.mockResolvedValue(1);
const { req, res, next } = buildMocks('agent-123');
await rateLimitMiddleware(req as Request, res as Response, next);
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', 100);
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', 99);
expect(res.setHeader).toHaveBeenCalledWith('X-RateLimit-Reset', expect.any(Number));
expect(next).toHaveBeenCalledWith();
expect(next).not.toHaveBeenCalledWith(expect.any(Error));
});
it('should call next(RateLimitError) when counter equals 100', async () => {
mockIncr.mockResolvedValue(101);
const { req, res, next } = buildMocks('agent-456');
await rateLimitMiddleware(req as Request, res as Response, next);
expect(next).toHaveBeenCalledWith(expect.any(RateLimitError));
});
it('should use req.ip as key when req.user is not set', async () => {
mockIncr.mockResolvedValue(5);
const { req, res, next } = buildMocks(); // no clientId → no req.user
await rateLimitMiddleware(req as Request, res as Response, next);
expect(mockIncr).toHaveBeenCalledWith(expect.stringContaining('127.0.0.1'));
expect(next).toHaveBeenCalledWith();
});
it('should set expire TTL only on first request (count === 1)', async () => {
mockIncr.mockResolvedValue(1);
const { req, res, next } = buildMocks('agent-789');
await rateLimitMiddleware(req as Request, res as Response, next);
expect(mockExpire).toHaveBeenCalledWith(expect.any(String), 60);
});
it('should not call expire on subsequent requests (count > 1)', async () => {
mockIncr.mockResolvedValue(50);
const { req, res, next } = buildMocks('agent-789');
await rateLimitMiddleware(req as Request, res as Response, next);
expect(mockExpire).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,276 @@
/**
* Unit tests for src/repositories/AgentRepository.ts
* Uses a mocked pg.Pool — no real database connection.
*/
import { Pool } from 'pg';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { IAgent, ICreateAgentRequest, IUpdateAgentRequest, IAgentListFilters } from '../../../src/types/index';
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn(),
connect: jest.fn(),
})),
}));
// ─── helpers ─────────────────────────────────────────────────────────────────
const AGENT_ROW = {
agent_id: 'a1b2c3d4-0000-0000-0000-000000000001',
email: 'agent@sentryagent.ai',
agent_type: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deployment_env: 'production',
status: 'active',
created_at: new Date('2026-03-28T09:00:00Z'),
updated_at: new Date('2026-03-28T09:00:00Z'),
};
const EXPECTED_AGENT: IAgent = {
agentId: AGENT_ROW.agent_id,
email: AGENT_ROW.email,
agentType: 'screener',
version: AGENT_ROW.version,
capabilities: AGENT_ROW.capabilities,
owner: AGENT_ROW.owner,
deploymentEnv: 'production',
status: 'active',
createdAt: AGENT_ROW.created_at,
updatedAt: AGENT_ROW.updated_at,
};
// ─── suite ───────────────────────────────────────────────────────────────────
describe('AgentRepository', () => {
let pool: jest.Mocked<Pool>;
let repo: AgentRepository;
beforeEach(() => {
jest.clearAllMocks();
pool = new Pool() as jest.Mocked<Pool>;
repo = new AgentRepository(pool);
});
// ── create ──────────────────────────────────────────────────────────────────
describe('create()', () => {
const createData: ICreateAgentRequest = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
it('should insert a row and return a mapped IAgent', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 });
const result = await repo.create(createData);
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('INSERT INTO agents');
expect(params).toContain(createData.email);
expect(params).toContain(createData.agentType);
expect(result).toMatchObject({
email: EXPECTED_AGENT.email,
agentType: EXPECTED_AGENT.agentType,
status: 'active',
});
});
});
// ── findById ─────────────────────────────────────────────────────────────────
describe('findById()', () => {
it('should return a mapped IAgent when the row exists', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 });
const result = await repo.findById(AGENT_ROW.agent_id);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('SELECT'),
[AGENT_ROW.agent_id],
);
expect(result).toMatchObject(EXPECTED_AGENT);
});
it('should return null when no rows are returned', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findById('nonexistent');
expect(result).toBeNull();
});
});
// ── findByEmail ──────────────────────────────────────────────────────────────
describe('findByEmail()', () => {
it('should return a mapped IAgent when the email exists', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 });
const result = await repo.findByEmail(AGENT_ROW.email);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('email'),
[AGENT_ROW.email],
);
expect(result).toMatchObject(EXPECTED_AGENT);
});
it('should return null when no rows are returned', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findByEmail('notfound@example.com');
expect(result).toBeNull();
});
});
// ── findAll ──────────────────────────────────────────────────────────────────
describe('findAll()', () => {
it('should return paginated agents with total count (no filters)', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 }) // count query
.mockResolvedValueOnce({ rows: [AGENT_ROW], rowCount: 1 }); // data query
const filters: IAgentListFilters = { page: 1, limit: 20 };
const result = await repo.findAll(filters);
expect(pool.query).toHaveBeenCalledTimes(2);
expect(result.total).toBe(1);
expect(result.agents).toHaveLength(1);
expect(result.agents[0]).toMatchObject(EXPECTED_AGENT);
});
it('should apply owner, agentType, and status filters', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
const filters: IAgentListFilters = {
page: 1,
limit: 10,
owner: 'team-a',
agentType: 'screener',
status: 'active',
};
const result = await repo.findAll(filters);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('owner');
expect(countSql).toContain('agent_type');
expect(countSql).toContain('status');
expect(result.total).toBe(0);
expect(result.agents).toHaveLength(0);
});
it('should return an empty list when no agents exist', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findAll({ page: 1, limit: 20 });
expect(result.total).toBe(0);
expect(result.agents).toEqual([]);
});
});
// ── update ───────────────────────────────────────────────────────────────────
describe('update()', () => {
it('should update fields and return mapped IAgent', async () => {
const updatedRow = { ...AGENT_ROW, version: '2.0.0' };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });
const data: IUpdateAgentRequest = { version: '2.0.0' };
const result = await repo.update(AGENT_ROW.agent_id, data);
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('UPDATE agents');
expect(result).not.toBeNull();
expect(result?.version).toBe('2.0.0');
});
it('should return null when the agent is not found after update', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.update('nonexistent', { version: '2.0.0' });
expect(result).toBeNull();
});
it('should return null when no fields are provided', async () => {
const result = await repo.update(AGENT_ROW.agent_id, {});
expect(pool.query).not.toHaveBeenCalled();
expect(result).toBeNull();
});
it('should update multiple fields at once', async () => {
const updatedRow = { ...AGENT_ROW, version: '3.0.0', status: 'suspended', owner: 'team-b' };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });
const data: IUpdateAgentRequest = { version: '3.0.0', status: 'suspended', owner: 'team-b' };
const result = await repo.update(AGENT_ROW.agent_id, data);
expect(result?.status).toBe('suspended');
expect(result?.owner).toBe('team-b');
});
});
// ── decommission ──────────────────────────────────────────────────────────────
describe('decommission()', () => {
it('should set status to decommissioned and return the agent', async () => {
const decomRow = { ...AGENT_ROW, status: 'decommissioned' };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [decomRow], rowCount: 1 });
const result = await repo.decommission(AGENT_ROW.agent_id);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('decommissioned');
expect(params).toContain(AGENT_ROW.agent_id);
expect(result?.status).toBe('decommissioned');
});
it('should return null when agent is not found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.decommission('nonexistent');
expect(result).toBeNull();
});
});
// ── countActive ───────────────────────────────────────────────────────────────
describe('countActive()', () => {
it('should return the count of non-decommissioned agents', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '42' }], rowCount: 1 });
const count = await repo.countActive();
const [sql] = (pool.query as jest.Mock).mock.calls[0] as [string];
expect(sql).toContain('decommissioned');
expect(count).toBe(42);
});
it('should return 0 when there are no active agents', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 });
const count = await repo.countActive();
expect(count).toBe(0);
});
});
});

View File

@@ -0,0 +1,221 @@
/**
* Unit tests for src/repositories/AuditRepository.ts
* Uses a mocked pg.Pool — no real database connection.
*/
import { Pool } from 'pg';
import { AuditRepository } from '../../../src/repositories/AuditRepository';
import { IAuditEvent, ICreateAuditEventInput, IAuditListFilters } from '../../../src/types/index';
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn(),
connect: jest.fn(),
})),
}));
// ─── helpers ─────────────────────────────────────────────────────────────────
const AUDIT_ROW = {
event_id: 'evt-0000-0000-0000-000000000001',
agent_id: 'agent-0000-0000-0000-000000000001',
action: 'agent.created',
outcome: 'success',
ip_address: '127.0.0.1',
user_agent: 'test-agent/1.0',
metadata: { agentType: 'screener' },
timestamp: new Date('2026-03-28T09:00:00Z'),
};
const EXPECTED_EVENT: IAuditEvent = {
eventId: AUDIT_ROW.event_id,
agentId: AUDIT_ROW.agent_id,
action: 'agent.created',
outcome: 'success',
ipAddress: AUDIT_ROW.ip_address,
userAgent: AUDIT_ROW.user_agent,
metadata: AUDIT_ROW.metadata,
timestamp: AUDIT_ROW.timestamp,
};
const RETENTION_CUTOFF = new Date('2026-01-01T00:00:00Z');
// ─── suite ───────────────────────────────────────────────────────────────────
describe('AuditRepository', () => {
let pool: jest.Mocked<Pool>;
let repo: AuditRepository;
beforeEach(() => {
jest.clearAllMocks();
pool = new Pool() as jest.Mocked<Pool>;
repo = new AuditRepository(pool);
});
// ── create ──────────────────────────────────────────────────────────────────
describe('create()', () => {
const eventInput: ICreateAuditEventInput = {
agentId: AUDIT_ROW.agent_id,
action: 'agent.created',
outcome: 'success',
ipAddress: '127.0.0.1',
userAgent: 'test-agent/1.0',
metadata: { agentType: 'screener' },
};
it('should insert a row and return a mapped IAuditEvent', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 });
const result = await repo.create(eventInput);
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('INSERT INTO audit_events');
expect(params).toContain(eventInput.agentId);
expect(params).toContain(eventInput.action);
expect(params).toContain(eventInput.outcome);
expect(params).toContain(eventInput.ipAddress);
expect(params).toContain(eventInput.userAgent);
expect(result).toMatchObject(EXPECTED_EVENT);
});
it('should JSON-stringify the metadata field', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 });
await repo.create(eventInput);
const [, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
// metadata param should be a JSON string
const metadataParam = params.find((p) => typeof p === 'string' && p.startsWith('{'));
expect(metadataParam).toBe(JSON.stringify(eventInput.metadata));
});
});
// ── findById ─────────────────────────────────────────────────────────────────
describe('findById()', () => {
it('should return a mapped IAuditEvent when found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 });
const result = await repo.findById(AUDIT_ROW.event_id);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('event_id'),
[AUDIT_ROW.event_id],
);
expect(result).toMatchObject(EXPECTED_EVENT);
});
it('should return null when not found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findById('nonexistent');
expect(result).toBeNull();
});
});
// ── findAll ──────────────────────────────────────────────────────────────────
describe('findAll()', () => {
it('should return paginated events with total count (no optional filters)', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 });
const filters: IAuditListFilters = { page: 1, limit: 50 };
const result = await repo.findAll(filters, RETENTION_CUTOFF);
expect(pool.query).toHaveBeenCalledTimes(2);
expect(result.total).toBe(1);
expect(result.events).toHaveLength(1);
expect(result.events[0]).toMatchObject(EXPECTED_EVENT);
});
it('should include retention cutoff in the WHERE clause', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await repo.findAll({ page: 1, limit: 50 }, RETENTION_CUTOFF);
const [countSql, countParams] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('timestamp');
expect(countParams).toContain(RETENTION_CUTOFF);
});
it('should apply agentId filter', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [AUDIT_ROW], rowCount: 1 });
const filters: IAuditListFilters = { page: 1, limit: 50, agentId: AUDIT_ROW.agent_id };
await repo.findAll(filters, RETENTION_CUTOFF);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('agent_id');
});
it('should apply action filter', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await repo.findAll({ page: 1, limit: 50, action: 'token.issued' }, RETENTION_CUTOFF);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('action');
});
it('should apply outcome filter', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await repo.findAll({ page: 1, limit: 50, outcome: 'failure' }, RETENTION_CUTOFF);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('outcome');
});
it('should apply fromDate filter', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await repo.findAll(
{ page: 1, limit: 50, fromDate: '2026-03-01T00:00:00Z' },
RETENTION_CUTOFF,
);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('timestamp');
});
it('should apply toDate filter', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await repo.findAll(
{ page: 1, limit: 50, toDate: '2026-03-31T23:59:59Z' },
RETENTION_CUTOFF,
);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('timestamp');
});
it('should return empty list when no events exist', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findAll({ page: 1, limit: 50 }, RETENTION_CUTOFF);
expect(result.total).toBe(0);
expect(result.events).toEqual([]);
});
});
});

View File

@@ -0,0 +1,256 @@
/**
* Unit tests for src/repositories/CredentialRepository.ts
* Uses a mocked pg.Pool — no real database connection.
*/
import { Pool } from 'pg';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { ICredential, ICredentialRow, ICredentialListFilters } from '../../../src/types/index';
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn(),
connect: jest.fn(),
})),
}));
// ─── helpers ─────────────────────────────────────────────────────────────────
const CREDENTIAL_ROW = {
credential_id: 'cred-0000-0000-0000-000000000001',
client_id: 'agent-0000-0000-0000-000000000001',
secret_hash: '$2b$10$hashedSecret',
status: 'active',
created_at: new Date('2026-03-28T09:00:00Z'),
expires_at: null,
revoked_at: null,
};
const EXPECTED_CREDENTIAL: ICredential = {
credentialId: CREDENTIAL_ROW.credential_id,
clientId: CREDENTIAL_ROW.client_id,
status: 'active',
createdAt: CREDENTIAL_ROW.created_at,
expiresAt: null,
revokedAt: null,
};
const EXPECTED_CREDENTIAL_ROW: ICredentialRow = {
...EXPECTED_CREDENTIAL,
secretHash: CREDENTIAL_ROW.secret_hash,
};
// ─── suite ───────────────────────────────────────────────────────────────────
describe('CredentialRepository', () => {
let pool: jest.Mocked<Pool>;
let repo: CredentialRepository;
beforeEach(() => {
jest.clearAllMocks();
pool = new Pool() as jest.Mocked<Pool>;
repo = new CredentialRepository(pool);
});
// ── create ──────────────────────────────────────────────────────────────────
describe('create()', () => {
it('should insert a credential row and return ICredential without secret hash', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 });
const result = await repo.create(
CREDENTIAL_ROW.client_id,
CREDENTIAL_ROW.secret_hash,
null,
);
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('INSERT INTO credentials');
expect(params).toContain(CREDENTIAL_ROW.client_id);
expect(params).toContain(CREDENTIAL_ROW.secret_hash);
// Secret hash must NOT be on the returned ICredential
expect(result).toMatchObject(EXPECTED_CREDENTIAL);
expect((result as ICredentialRow).secretHash).toBeUndefined();
});
it('should pass expiresAt when provided', async () => {
const expiresAt = new Date('2027-01-01T00:00:00Z');
const rowWithExpiry = { ...CREDENTIAL_ROW, expires_at: expiresAt };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [rowWithExpiry], rowCount: 1 });
const result = await repo.create(CREDENTIAL_ROW.client_id, CREDENTIAL_ROW.secret_hash, expiresAt);
const [, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(params).toContain(expiresAt);
expect(result.expiresAt).toEqual(expiresAt);
});
});
// ── findById ─────────────────────────────────────────────────────────────────
describe('findById()', () => {
it('should return ICredentialRow (with secretHash) when found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 });
const result = await repo.findById(CREDENTIAL_ROW.credential_id);
expect(pool.query).toHaveBeenCalledWith(
expect.stringContaining('credential_id'),
[CREDENTIAL_ROW.credential_id],
);
expect(result).toMatchObject(EXPECTED_CREDENTIAL_ROW);
expect(result?.secretHash).toBe(CREDENTIAL_ROW.secret_hash);
});
it('should return null when not found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findById('nonexistent');
expect(result).toBeNull();
});
});
// ── findByAgentId ─────────────────────────────────────────────────────────────
describe('findByAgentId()', () => {
it('should return paginated credentials for an agent', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 });
const filters: ICredentialListFilters = { page: 1, limit: 20 };
const result = await repo.findByAgentId(CREDENTIAL_ROW.client_id, filters);
expect(pool.query).toHaveBeenCalledTimes(2);
expect(result.total).toBe(1);
expect(result.credentials).toHaveLength(1);
expect(result.credentials[0]).toMatchObject(EXPECTED_CREDENTIAL);
});
it('should apply status filter when provided', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
const filters: ICredentialListFilters = { page: 1, limit: 20, status: 'revoked' };
const result = await repo.findByAgentId(CREDENTIAL_ROW.client_id, filters);
const [countSql] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(countSql).toContain('status');
expect(result.total).toBe(0);
expect(result.credentials).toHaveLength(0);
});
it('should return empty list when no credentials exist', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '0' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.findByAgentId('agent-no-creds', { page: 1, limit: 20 });
expect(result.total).toBe(0);
expect(result.credentials).toEqual([]);
});
it('should not include secretHash in returned credentials', async () => {
(pool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '1' }], rowCount: 1 })
.mockResolvedValueOnce({ rows: [CREDENTIAL_ROW], rowCount: 1 });
const result = await repo.findByAgentId(CREDENTIAL_ROW.client_id, { page: 1, limit: 20 });
expect((result.credentials[0] as ICredentialRow).secretHash).toBeUndefined();
});
});
// ── updateHash ────────────────────────────────────────────────────────────────
describe('updateHash()', () => {
it('should update the secret hash and return ICredential', async () => {
const newHash = '$2b$10$newHash';
const updatedRow = { ...CREDENTIAL_ROW, secret_hash: newHash };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });
const result = await repo.updateHash(CREDENTIAL_ROW.credential_id, newHash, null);
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('secret_hash');
expect(params).toContain(newHash);
expect(params).toContain(CREDENTIAL_ROW.credential_id);
expect(result).toMatchObject(EXPECTED_CREDENTIAL);
});
it('should return null when credential is not found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.updateHash('nonexistent', '$2b$10$hash', null);
expect(result).toBeNull();
});
it('should pass new expiresAt when provided', async () => {
const newExpiry = new Date('2028-01-01T00:00:00Z');
const updatedRow = { ...CREDENTIAL_ROW, expires_at: newExpiry };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [updatedRow], rowCount: 1 });
const result = await repo.updateHash(CREDENTIAL_ROW.credential_id, '$2b$10$hash', newExpiry);
const [, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(params).toContain(newExpiry);
expect(result?.expiresAt).toEqual(newExpiry);
});
});
// ── revoke ────────────────────────────────────────────────────────────────────
describe('revoke()', () => {
it('should set status to revoked and return ICredential', async () => {
const revokedAt = new Date('2026-03-28T10:00:00Z');
const revokedRow = { ...CREDENTIAL_ROW, status: 'revoked', revoked_at: revokedAt };
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [revokedRow], rowCount: 1 });
const result = await repo.revoke(CREDENTIAL_ROW.credential_id);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('revoked');
expect(params).toContain(CREDENTIAL_ROW.credential_id);
expect(result?.status).toBe('revoked');
expect(result?.revokedAt).toEqual(revokedAt);
});
it('should return null when credential is not found', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.revoke('nonexistent');
expect(result).toBeNull();
});
});
// ── revokeAllForAgent ─────────────────────────────────────────────────────────
describe('revokeAllForAgent()', () => {
it('should return the count of revoked credentials', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 3 });
const count = await repo.revokeAllForAgent(CREDENTIAL_ROW.client_id);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('revoked');
expect(params).toContain(CREDENTIAL_ROW.client_id);
expect(count).toBe(3);
});
it('should return 0 when no active credentials exist', async () => {
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: null });
const count = await repo.revokeAllForAgent('agent-no-creds');
expect(count).toBe(0);
});
});
});

View File

@@ -0,0 +1,175 @@
/**
* Unit tests for src/repositories/TokenRepository.ts
* Uses mocked pg.Pool and Redis client — no real infrastructure.
*/
import { Pool } from 'pg';
import { RedisClientType } from 'redis';
import { TokenRepository } from '../../../src/repositories/TokenRepository';
jest.mock('pg', () => ({
Pool: jest.fn().mockImplementation(() => ({
query: jest.fn(),
connect: jest.fn(),
})),
}));
// ─── helpers ─────────────────────────────────────────────────────────────────
function buildMockRedis(): jest.Mocked<Pick<RedisClientType, 'get' | 'set' | 'incr' | 'expire'>> {
return {
get: jest.fn(),
set: jest.fn(),
incr: jest.fn(),
expire: jest.fn(),
};
}
// ─── suite ───────────────────────────────────────────────────────────────────
describe('TokenRepository', () => {
let pool: jest.Mocked<Pool>;
let redis: ReturnType<typeof buildMockRedis>;
let repo: TokenRepository;
beforeEach(() => {
jest.clearAllMocks();
pool = new Pool() as jest.Mocked<Pool>;
redis = buildMockRedis();
repo = new TokenRepository(pool, redis as unknown as RedisClientType);
});
// ── addToRevocationList ───────────────────────────────────────────────────────
describe('addToRevocationList()', () => {
it('should write to Redis with correct key and TTL, then insert to DB', async () => {
redis.set.mockResolvedValue('OK');
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });
const jti = 'test-jti-001';
const expiresAt = new Date(Date.now() + 3600_000); // 1 hour from now
await repo.addToRevocationList(jti, expiresAt);
// Redis set call
expect(redis.set).toHaveBeenCalledTimes(1);
const [redisKey, value, options] = redis.set.mock.calls[0] as [string, string, { EX: number }];
expect(redisKey).toBe(`revoked:${jti}`);
expect(value).toBe('1');
expect(options.EX).toBeGreaterThan(0);
// DB insert call
expect(pool.query).toHaveBeenCalledTimes(1);
const [sql, params] = (pool.query as jest.Mock).mock.calls[0] as [string, unknown[]];
expect(sql).toContain('INSERT INTO token_revocations');
expect(params).toContain(jti);
expect(params).toContain(expiresAt);
});
it('should use a minimum TTL of 1 second for already-expired tokens', async () => {
redis.set.mockResolvedValue('OK');
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 1 });
const jti = 'expired-jti';
const expiresAt = new Date(Date.now() - 5000); // already expired
await repo.addToRevocationList(jti, expiresAt);
const [, , options] = redis.set.mock.calls[0] as [string, string, { EX: number }];
expect(options.EX).toBe(1);
});
});
// ── isRevoked ─────────────────────────────────────────────────────────────────
describe('isRevoked()', () => {
it('should return true immediately when Redis has the key', async () => {
redis.get.mockResolvedValue('1');
const result = await repo.isRevoked('revoked-jti');
expect(result).toBe(true);
// DB should NOT be queried
expect(pool.query).not.toHaveBeenCalled();
});
it('should fall back to DB and return true when found there', async () => {
redis.get.mockResolvedValue(null);
(pool.query as jest.Mock).mockResolvedValueOnce({
rows: [{ jti: 'db-revoked-jti', expires_at: new Date(), revoked_at: new Date() }],
rowCount: 1,
});
const result = await repo.isRevoked('db-revoked-jti');
expect(redis.get).toHaveBeenCalledTimes(1);
expect(pool.query).toHaveBeenCalledTimes(1);
expect(result).toBe(true);
});
it('should return false when neither Redis nor DB has the key', async () => {
redis.get.mockResolvedValue(null);
(pool.query as jest.Mock).mockResolvedValueOnce({ rows: [], rowCount: 0 });
const result = await repo.isRevoked('valid-jti');
expect(result).toBe(false);
});
});
// ── incrementMonthlyCount ─────────────────────────────────────────────────────
describe('incrementMonthlyCount()', () => {
it('should increment the Redis key and return the new count', async () => {
redis.incr.mockResolvedValue(5);
redis.expire.mockResolvedValue(true);
const count = await repo.incrementMonthlyCount('client-001');
expect(redis.incr).toHaveBeenCalledTimes(1);
const [key] = redis.incr.mock.calls[0] as [string];
expect(key).toMatch(/^monthly:tokens:client-001:/);
expect(count).toBe(5);
});
it('should set TTL when count becomes 1 (first token of the month)', async () => {
redis.incr.mockResolvedValue(1);
redis.expire.mockResolvedValue(true);
await repo.incrementMonthlyCount('client-new');
expect(redis.expire).toHaveBeenCalledTimes(1);
const [, ttl] = redis.expire.mock.calls[0] as [string, number];
expect(ttl).toBeGreaterThan(0);
});
it('should NOT set TTL when count is greater than 1', async () => {
redis.incr.mockResolvedValue(10);
await repo.incrementMonthlyCount('client-existing');
expect(redis.expire).not.toHaveBeenCalled();
});
});
// ── getMonthlyCount ───────────────────────────────────────────────────────────
describe('getMonthlyCount()', () => {
it('should return the count from Redis', async () => {
redis.get.mockResolvedValue('42');
const count = await repo.getMonthlyCount('client-001');
expect(redis.get).toHaveBeenCalledTimes(1);
const [key] = redis.get.mock.calls[0] as [string];
expect(key).toMatch(/^monthly:tokens:client-001:/);
expect(count).toBe(42);
});
it('should return 0 when the Redis key does not exist', async () => {
redis.get.mockResolvedValue(null);
const count = await repo.getMonthlyCount('client-no-tokens');
expect(count).toBe(0);
});
});
});

View File

@@ -0,0 +1,194 @@
/**
* Unit tests for src/services/AgentService.ts
*/
import { AgentService } from '../../../src/services/AgentService';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AuditService } from '../../../src/services/AuditService';
import {
AgentNotFoundError,
AgentAlreadyExistsError,
AgentAlreadyDecommissionedError,
FreeTierLimitError,
} from '../../../src/utils/errors';
import { IAgent, ICreateAgentRequest } from '../../../src/types/index';
// Mock dependencies
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/services/AuditService');
const MockAgentRepository = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockCredentialRepository = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const MOCK_AGENT: IAgent = {
agentId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
status: 'active',
createdAt: new Date('2026-03-28T09:00:00Z'),
updatedAt: new Date('2026-03-28T09:00:00Z'),
};
const IP = '127.0.0.1';
const UA = 'test-agent/1.0';
describe('AgentService', () => {
let agentService: AgentService;
let agentRepo: jest.Mocked<AgentRepository>;
let credentialRepo: jest.Mocked<CredentialRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
agentRepo = new MockAgentRepository({} as never) as jest.Mocked<AgentRepository>;
credentialRepo = new MockCredentialRepository({} as never) as jest.Mocked<CredentialRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
agentService = new AgentService(agentRepo, credentialRepo, auditService);
});
// ────────────────────────────────────────────────────────────────
// registerAgent
// ────────────────────────────────────────────────────────────────
describe('registerAgent()', () => {
const createData: ICreateAgentRequest = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
it('should create and return a new agent', async () => {
agentRepo.countActive.mockResolvedValue(0);
agentRepo.findByEmail.mockResolvedValue(null);
agentRepo.create.mockResolvedValue(MOCK_AGENT);
auditService.logEvent.mockResolvedValue({} as never);
const result = await agentService.registerAgent(createData, IP, UA);
expect(result).toEqual(MOCK_AGENT);
expect(agentRepo.create).toHaveBeenCalledWith(createData);
});
it('should throw FreeTierLimitError when 100 agents already registered', async () => {
agentRepo.countActive.mockResolvedValue(100);
await expect(agentService.registerAgent(createData, IP, UA)).rejects.toThrow(
FreeTierLimitError,
);
});
it('should throw AgentAlreadyExistsError if email is already registered', async () => {
agentRepo.countActive.mockResolvedValue(0);
agentRepo.findByEmail.mockResolvedValue(MOCK_AGENT);
await expect(agentService.registerAgent(createData, IP, UA)).rejects.toThrow(
AgentAlreadyExistsError,
);
});
});
// ────────────────────────────────────────────────────────────────
// getAgentById
// ────────────────────────────────────────────────────────────────
describe('getAgentById()', () => {
it('should return the agent when found', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
const result = await agentService.getAgentById(MOCK_AGENT.agentId);
expect(result).toEqual(MOCK_AGENT);
});
it('should throw AgentNotFoundError when not found', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(agentService.getAgentById('nonexistent-id')).rejects.toThrow(
AgentNotFoundError,
);
});
});
// ────────────────────────────────────────────────────────────────
// listAgents
// ────────────────────────────────────────────────────────────────
describe('listAgents()', () => {
it('should return a paginated list of agents', async () => {
agentRepo.findAll.mockResolvedValue({ agents: [MOCK_AGENT], total: 1 });
const result = await agentService.listAgents({ page: 1, limit: 20 });
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
});
});
// ────────────────────────────────────────────────────────────────
// updateAgent
// ────────────────────────────────────────────────────────────────
describe('updateAgent()', () => {
it('should update and return the agent', async () => {
const updated = { ...MOCK_AGENT, version: '2.0.0' };
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
agentRepo.update.mockResolvedValue(updated);
auditService.logEvent.mockResolvedValue({} as never);
const result = await agentService.updateAgent(
MOCK_AGENT.agentId,
{ version: '2.0.0' },
IP,
UA,
);
expect(result.version).toBe('2.0.0');
});
it('should throw AgentNotFoundError when agent does not exist', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
agentService.updateAgent('nonexistent', { version: '2.0.0' }, IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
it('should throw AgentAlreadyDecommissionedError for decommissioned agent', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
await expect(
agentService.updateAgent(MOCK_AGENT.agentId, { version: '2.0.0' }, IP, UA),
).rejects.toThrow(AgentAlreadyDecommissionedError);
});
});
// ────────────────────────────────────────────────────────────────
// decommissionAgent
// ────────────────────────────────────────────────────────────────
describe('decommissionAgent()', () => {
it('should decommission the agent and revoke credentials', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.revokeAllForAgent.mockResolvedValue(2);
agentRepo.decommission.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
auditService.logEvent.mockResolvedValue({} as never);
await agentService.decommissionAgent(MOCK_AGENT.agentId, IP, UA);
expect(credentialRepo.revokeAllForAgent).toHaveBeenCalledWith(MOCK_AGENT.agentId);
expect(agentRepo.decommission).toHaveBeenCalledWith(MOCK_AGENT.agentId);
});
it('should throw AgentNotFoundError if agent does not exist', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
agentService.decommissionAgent('nonexistent', IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
it('should throw AgentAlreadyDecommissionedError if already decommissioned', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
await expect(
agentService.decommissionAgent(MOCK_AGENT.agentId, IP, UA),
).rejects.toThrow(AgentAlreadyDecommissionedError);
});
});
});

View File

@@ -0,0 +1,129 @@
/**
* Unit tests for src/services/AuditService.ts
*/
import { v4 as uuidv4 } from 'uuid';
import { AuditService } from '../../../src/services/AuditService';
import { AuditRepository } from '../../../src/repositories/AuditRepository';
import {
AuditEventNotFoundError,
RetentionWindowError,
ValidationError,
} from '../../../src/utils/errors';
import { IAuditEvent } from '../../../src/types/index';
jest.mock('../../../src/repositories/AuditRepository');
const MockAuditRepo = AuditRepository as jest.MockedClass<typeof AuditRepository>;
const MOCK_EVENT: IAuditEvent = {
eventId: uuidv4(),
agentId: uuidv4(),
action: 'token.issued',
outcome: 'success',
ipAddress: '127.0.0.1',
userAgent: 'test/1.0',
metadata: { scope: 'agents:read' },
timestamp: new Date(), // recent timestamp
};
describe('AuditService', () => {
let service: AuditService;
let auditRepo: jest.Mocked<AuditRepository>;
beforeEach(() => {
jest.clearAllMocks();
auditRepo = new MockAuditRepo({} as never) as jest.Mocked<AuditRepository>;
service = new AuditService(auditRepo);
});
// ────────────────────────────────────────────────────────────────
// logEvent
// ────────────────────────────────────────────────────────────────
describe('logEvent()', () => {
it('should create an audit event', async () => {
auditRepo.create.mockResolvedValue(MOCK_EVENT);
const result = await service.logEvent(
MOCK_EVENT.agentId,
'token.issued',
'success',
'127.0.0.1',
'test/1.0',
{ scope: 'agents:read' },
);
expect(result).toEqual(MOCK_EVENT);
expect(auditRepo.create).toHaveBeenCalledTimes(1);
});
});
// ────────────────────────────────────────────────────────────────
// queryEvents
// ────────────────────────────────────────────────────────────────
describe('queryEvents()', () => {
it('should return paginated events', async () => {
auditRepo.findAll.mockResolvedValue({ events: [MOCK_EVENT], total: 1 });
const result = await service.queryEvents({ page: 1, limit: 50 });
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
});
it('should throw RetentionWindowError for fromDate before 90-day cutoff', async () => {
const oldDate = new Date();
oldDate.setDate(oldDate.getDate() - 100);
await expect(
service.queryEvents({ page: 1, limit: 50, fromDate: oldDate.toISOString() }),
).rejects.toThrow(RetentionWindowError);
});
it('should throw ValidationError when fromDate is after toDate', async () => {
const future = new Date();
future.setDate(future.getDate() + 5);
const past = new Date();
past.setDate(past.getDate() - 1);
await expect(
service.queryEvents({
page: 1,
limit: 50,
fromDate: future.toISOString(),
toDate: past.toISOString(),
}),
).rejects.toThrow(ValidationError);
});
it('should not throw for valid date range within retention window', async () => {
auditRepo.findAll.mockResolvedValue({ events: [], total: 0 });
const recentDate = new Date();
recentDate.setDate(recentDate.getDate() - 30);
await expect(
service.queryEvents({ page: 1, limit: 50, fromDate: recentDate.toISOString() }),
).resolves.toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// getEventById
// ────────────────────────────────────────────────────────────────
describe('getEventById()', () => {
it('should return the event when found within retention window', async () => {
auditRepo.findById.mockResolvedValue(MOCK_EVENT);
const result = await service.getEventById(MOCK_EVENT.eventId);
expect(result).toEqual(MOCK_EVENT);
});
it('should throw AuditEventNotFoundError when not found', async () => {
auditRepo.findById.mockResolvedValue(null);
await expect(service.getEventById('nonexistent')).rejects.toThrow(AuditEventNotFoundError);
});
it('should throw AuditEventNotFoundError for event outside retention window', async () => {
const oldEvent: IAuditEvent = {
...MOCK_EVENT,
timestamp: new Date('2020-01-01T00:00:00Z'),
};
auditRepo.findById.mockResolvedValue(oldEvent);
await expect(service.getEventById(oldEvent.eventId)).rejects.toThrow(
AuditEventNotFoundError,
);
});
});
});

View File

@@ -0,0 +1,207 @@
/**
* Unit tests for src/services/CredentialService.ts
*/
import { v4 as uuidv4 } from 'uuid';
import { CredentialService } from '../../../src/services/CredentialService';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { AuditService } from '../../../src/services/AuditService';
import {
AgentNotFoundError,
CredentialNotFoundError,
CredentialAlreadyRevokedError,
CredentialError,
} from '../../../src/utils/errors';
import { IAgent, ICredential, ICredentialRow } from '../../../src/types/index';
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/services/AuditService');
const MockCredentialRepo = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const AGENT_ID = uuidv4();
const CREDENTIAL_ID = uuidv4();
const MOCK_AGENT: IAgent = {
agentId: AGENT_ID,
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
};
const MOCK_CREDENTIAL: ICredential = {
credentialId: CREDENTIAL_ID,
clientId: AGENT_ID,
status: 'active',
createdAt: new Date(),
expiresAt: null,
revokedAt: null,
};
const MOCK_CREDENTIAL_ROW: ICredentialRow = {
...MOCK_CREDENTIAL,
secretHash: '$2b$10$somehashvalue',
};
const IP = '127.0.0.1';
const UA = 'test/1.0';
describe('CredentialService', () => {
let service: CredentialService;
let credentialRepo: jest.Mocked<CredentialRepository>;
let agentRepo: jest.Mocked<AgentRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
jest.clearAllMocks();
credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked<CredentialRepository>;
agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
service = new CredentialService(credentialRepo, agentRepo, auditService);
auditService.logEvent.mockResolvedValue({} as never);
});
// ────────────────────────────────────────────────────────────────
// generateCredential
// ────────────────────────────────────────────────────────────────
describe('generateCredential()', () => {
it('should generate and return a credential with a one-time secret', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.create.mockResolvedValue(MOCK_CREDENTIAL);
const result = await service.generateCredential(AGENT_ID, {}, IP, UA);
expect(result.credentialId).toBe(CREDENTIAL_ID);
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
});
it('should throw AgentNotFoundError for unknown agent', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(service.generateCredential('unknown', {}, IP, UA)).rejects.toThrow(
AgentNotFoundError,
);
});
it('should throw CredentialError for suspended agent', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' });
await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow(
CredentialError,
);
});
it('should throw CredentialError for decommissioned agent', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
await expect(service.generateCredential(AGENT_ID, {}, IP, UA)).rejects.toThrow(
CredentialError,
);
});
});
// ────────────────────────────────────────────────────────────────
// listCredentials
// ────────────────────────────────────────────────────────────────
describe('listCredentials()', () => {
it('should return a paginated list', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findByAgentId.mockResolvedValue({
credentials: [MOCK_CREDENTIAL],
total: 1,
});
const result = await service.listCredentials(AGENT_ID, { page: 1, limit: 20 });
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
});
it('should throw AgentNotFoundError for unknown agent', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
service.listCredentials('unknown', { page: 1, limit: 20 }),
).rejects.toThrow(AgentNotFoundError);
});
});
// ────────────────────────────────────────────────────────────────
// rotateCredential
// ────────────────────────────────────────────────────────────────
describe('rotateCredential()', () => {
it('should rotate and return a new secret', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW);
credentialRepo.updateHash.mockResolvedValue(MOCK_CREDENTIAL);
const result = await service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA);
expect(result.clientSecret).toMatch(/^sk_live_[0-9a-f]{64}$/);
});
it('should throw AgentNotFoundError for unknown agent', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
service.rotateCredential('unknown', CREDENTIAL_ID, {}, IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
it('should throw CredentialNotFoundError for unknown credential', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue(null);
await expect(
service.rotateCredential(AGENT_ID, 'unknown', {}, IP, UA),
).rejects.toThrow(CredentialNotFoundError);
});
it('should throw CredentialAlreadyRevokedError for revoked credential', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue({
...MOCK_CREDENTIAL_ROW,
status: 'revoked',
revokedAt: new Date(),
});
await expect(
service.rotateCredential(AGENT_ID, CREDENTIAL_ID, {}, IP, UA),
).rejects.toThrow(CredentialAlreadyRevokedError);
});
});
// ────────────────────────────────────────────────────────────────
// revokeCredential
// ────────────────────────────────────────────────────────────────
describe('revokeCredential()', () => {
it('should revoke the credential', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue(MOCK_CREDENTIAL_ROW);
credentialRepo.revoke.mockResolvedValue({ ...MOCK_CREDENTIAL, status: 'revoked', revokedAt: new Date() });
await expect(
service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA),
).resolves.toBeUndefined();
});
it('should throw AgentNotFoundError for unknown agent', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
service.revokeCredential('unknown', CREDENTIAL_ID, IP, UA),
).rejects.toThrow(AgentNotFoundError);
});
it('should throw CredentialAlreadyRevokedError for already-revoked credential', async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
credentialRepo.findById.mockResolvedValue({
...MOCK_CREDENTIAL_ROW,
status: 'revoked',
revokedAt: new Date(),
});
await expect(
service.revokeCredential(AGENT_ID, CREDENTIAL_ID, IP, UA),
).rejects.toThrow(CredentialAlreadyRevokedError);
});
});
});

View File

@@ -0,0 +1,245 @@
/**
* Unit tests for src/services/OAuth2Service.ts
*/
import crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { OAuth2Service } from '../../../src/services/OAuth2Service';
import { TokenRepository } from '../../../src/repositories/TokenRepository';
import { CredentialRepository } from '../../../src/repositories/CredentialRepository';
import { AgentRepository } from '../../../src/repositories/AgentRepository';
import { AuditService } from '../../../src/services/AuditService';
import {
AuthenticationError,
AuthorizationError,
FreeTierLimitError,
InsufficientScopeError,
} from '../../../src/utils/errors';
import { IAgent, ICredential, ICredentialRow, ITokenPayload } from '../../../src/types/index';
import { hashSecret, generateClientSecret } from '../../../src/utils/crypto';
jest.mock('../../../src/repositories/TokenRepository');
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/services/AuditService');
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const MockTokenRepo = TokenRepository as jest.MockedClass<typeof TokenRepository>;
const MockCredentialRepo = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAgentRepo = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
const MOCK_AGENT_ID = uuidv4();
const MOCK_AGENT: IAgent = {
agentId: MOCK_AGENT_ID,
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['agents:read'],
owner: 'team-a',
deploymentEnv: 'production',
status: 'active',
createdAt: new Date(),
updatedAt: new Date(),
};
const IP = '127.0.0.1';
const UA = 'test/1.0';
describe('OAuth2Service', () => {
let service: OAuth2Service;
let tokenRepo: jest.Mocked<TokenRepository>;
let credentialRepo: jest.Mocked<CredentialRepository>;
let agentRepo: jest.Mocked<AgentRepository>;
let auditService: jest.Mocked<AuditService>;
let plainSecret: string;
let credentialRow: ICredentialRow;
beforeEach(async () => {
jest.clearAllMocks();
tokenRepo = new MockTokenRepo({} as never, {} as never) as jest.Mocked<TokenRepository>;
credentialRepo = new MockCredentialRepo({} as never) as jest.Mocked<CredentialRepository>;
agentRepo = new MockAgentRepo({} as never) as jest.Mocked<AgentRepository>;
auditService = new MockAuditService({} as never) as jest.Mocked<AuditService>;
service = new OAuth2Service(
tokenRepo,
credentialRepo,
agentRepo,
auditService,
privateKey,
publicKey,
);
plainSecret = generateClientSecret();
const secretHash = await hashSecret(plainSecret);
const credId = uuidv4();
const mockCredential: ICredential = {
credentialId: credId,
clientId: MOCK_AGENT_ID,
status: 'active',
createdAt: new Date(),
expiresAt: null,
revokedAt: null,
};
credentialRow = { ...mockCredential, secretHash };
credentialRepo.findByAgentId.mockResolvedValue({ credentials: [mockCredential], total: 1 });
credentialRepo.findById.mockResolvedValue(credentialRow);
auditService.logEvent.mockResolvedValue({} as never);
});
// ────────────────────────────────────────────────────────────────
// issueToken
// ────────────────────────────────────────────────────────────────
describe('issueToken()', () => {
beforeEach(() => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
tokenRepo.getMonthlyCount.mockResolvedValue(0);
tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
});
it('should issue a token for valid credentials', async () => {
const result = await service.issueToken(
MOCK_AGENT_ID,
plainSecret,
'agents:read',
IP,
UA,
);
expect(result.token_type).toBe('Bearer');
expect(result.expires_in).toBe(3600);
expect(result.access_token).toBeTruthy();
});
it('should throw AuthenticationError for unknown agent', async () => {
agentRepo.findById.mockResolvedValue(null);
await expect(
service.issueToken('unknown', plainSecret, 'agents:read', IP, UA),
).rejects.toThrow(AuthenticationError);
});
it('should throw AuthenticationError for wrong secret', async () => {
await expect(
service.issueToken(MOCK_AGENT_ID, 'wrong_secret', 'agents:read', IP, UA),
).rejects.toThrow(AuthenticationError);
});
it('should throw AuthorizationError for suspended agent', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'suspended' });
await expect(
service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
).rejects.toThrow(AuthorizationError);
});
it('should throw AuthorizationError for decommissioned agent', async () => {
agentRepo.findById.mockResolvedValue({ ...MOCK_AGENT, status: 'decommissioned' });
await expect(
service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
).rejects.toThrow(AuthorizationError);
});
it('should throw FreeTierLimitError when monthly limit reached', async () => {
tokenRepo.getMonthlyCount.mockResolvedValue(10000);
await expect(
service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA),
).rejects.toThrow(FreeTierLimitError);
});
});
// ────────────────────────────────────────────────────────────────
// introspectToken
// ────────────────────────────────────────────────────────────────
describe('introspectToken()', () => {
let validToken: string;
let callerPayload: ITokenPayload;
beforeEach(async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
tokenRepo.getMonthlyCount.mockResolvedValue(0);
tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read tokens:read', IP, UA);
validToken = issued.access_token;
const { verifyToken } = await import('../../../src/utils/jwt');
callerPayload = verifyToken(validToken, publicKey);
});
it('should return active: true for a valid token', async () => {
tokenRepo.isRevoked.mockResolvedValue(false);
const result = await service.introspectToken(validToken, callerPayload, IP, UA);
expect(result.active).toBe(true);
expect(result.sub).toBe(MOCK_AGENT_ID);
});
it('should return active: false for a revoked token', async () => {
tokenRepo.isRevoked.mockResolvedValue(true);
const result = await service.introspectToken(validToken, callerPayload, IP, UA);
expect(result.active).toBe(false);
});
it('should throw InsufficientScopeError if caller lacks tokens:read', async () => {
const noScopePayload = { ...callerPayload, scope: 'agents:read' };
await expect(
service.introspectToken(validToken, noScopePayload, IP, UA),
).rejects.toThrow(InsufficientScopeError);
});
it('should return active: false for an expired token', async () => {
const result = await service.introspectToken('invalid.jwt.token', callerPayload, IP, UA);
expect(result.active).toBe(false);
});
});
// ────────────────────────────────────────────────────────────────
// revokeToken
// ────────────────────────────────────────────────────────────────
describe('revokeToken()', () => {
let validToken: string;
let callerPayload: ITokenPayload;
beforeEach(async () => {
agentRepo.findById.mockResolvedValue(MOCK_AGENT);
tokenRepo.getMonthlyCount.mockResolvedValue(0);
tokenRepo.incrementMonthlyCount.mockResolvedValue(1);
const issued = await service.issueToken(MOCK_AGENT_ID, plainSecret, 'agents:read', IP, UA);
validToken = issued.access_token;
const { verifyToken } = await import('../../../src/utils/jwt');
callerPayload = verifyToken(validToken, publicKey);
tokenRepo.addToRevocationList.mockResolvedValue();
});
it('should revoke a token successfully', async () => {
await expect(
service.revokeToken(validToken, callerPayload, IP, UA),
).resolves.toBeUndefined();
expect(tokenRepo.addToRevocationList).toHaveBeenCalled();
});
it('should throw AuthorizationError if revoking another agent token', async () => {
const otherPayload = { ...callerPayload, sub: uuidv4() };
await expect(
service.revokeToken(validToken, otherPayload, IP, UA),
).rejects.toThrow(AuthorizationError);
});
it('should succeed silently for a malformed token (RFC 7009)', async () => {
await expect(
service.revokeToken('not.a.valid.token', callerPayload, IP, UA),
).resolves.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,62 @@
/**
* Unit tests for src/utils/crypto.ts
*/
import { generateClientSecret, hashSecret, verifySecret } from '../../../src/utils/crypto';
describe('crypto utils', () => {
describe('generateClientSecret()', () => {
it('should return a string starting with sk_live_', () => {
const secret = generateClientSecret();
expect(secret).toMatch(/^sk_live_/);
});
it('should return 64 hex chars after the prefix', () => {
const secret = generateClientSecret();
const hex = secret.slice('sk_live_'.length);
expect(hex).toHaveLength(64);
expect(hex).toMatch(/^[0-9a-f]{64}$/);
});
it('should generate unique secrets on each call', () => {
const secret1 = generateClientSecret();
const secret2 = generateClientSecret();
expect(secret1).not.toBe(secret2);
});
it('should have total length of 72 characters (8 + 64)', () => {
const secret = generateClientSecret();
expect(secret).toHaveLength(72);
});
});
describe('hashSecret() and verifySecret()', () => {
it('should hash a secret and verify it correctly', async () => {
const plain = generateClientSecret();
const hash = await hashSecret(plain);
const isValid = await verifySecret(plain, hash);
expect(isValid).toBe(true);
});
it('should return false for a wrong secret', async () => {
const plain = generateClientSecret();
const hash = await hashSecret(plain);
const isValid = await verifySecret('wrong_secret', hash);
expect(isValid).toBe(false);
});
it('should produce different hashes for the same input (salt randomness)', async () => {
const plain = generateClientSecret();
const hash1 = await hashSecret(plain);
const hash2 = await hashSecret(plain);
expect(hash1).not.toBe(hash2);
});
it('should produce a bcrypt hash string', async () => {
const plain = generateClientSecret();
const hash = await hashSecret(plain);
// bcrypt hashes start with $2a$ or $2b$
expect(hash).toMatch(/^\$2[ab]\$/);
});
});
});

View File

@@ -0,0 +1,107 @@
/**
* Unit tests for src/utils/jwt.ts
*/
import crypto from 'crypto';
import { signToken, verifyToken, decodeToken, getTokenExpiresIn } from '../../../src/utils/jwt';
import { ITokenPayload } from '../../../src/types/index';
import { v4 as uuidv4 } from 'uuid';
// Generate a test RSA key pair for testing
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
describe('jwt utils', () => {
const testPayload: Omit<ITokenPayload, 'iat' | 'exp'> = {
sub: uuidv4(),
client_id: uuidv4(),
scope: 'agents:read agents:write',
jti: uuidv4(),
};
describe('signToken()', () => {
it('should return a non-empty JWT string', () => {
const token = signToken(testPayload, privateKey);
expect(typeof token).toBe('string');
expect(token.length).toBeGreaterThan(0);
});
it('should return a JWT with three parts separated by dots', () => {
const token = signToken(testPayload, privateKey);
const parts = token.split('.');
expect(parts).toHaveLength(3);
});
it('should include iat and exp in the payload', () => {
const before = Math.floor(Date.now() / 1000);
const token = signToken(testPayload, privateKey);
const decoded = decodeToken(token);
const after = Math.floor(Date.now() / 1000);
expect(decoded).not.toBeNull();
if (decoded) {
expect(decoded.iat).toBeGreaterThanOrEqual(before);
expect(decoded.iat).toBeLessThanOrEqual(after);
expect(decoded.exp).toBe(decoded.iat + 3600);
}
});
});
describe('verifyToken()', () => {
it('should verify and return the payload for a valid token', () => {
const token = signToken(testPayload, privateKey);
const payload = verifyToken(token, publicKey);
expect(payload.sub).toBe(testPayload.sub);
expect(payload.client_id).toBe(testPayload.client_id);
expect(payload.scope).toBe(testPayload.scope);
expect(payload.jti).toBe(testPayload.jti);
});
it('should throw for a token signed with a different private key', () => {
const { privateKey: otherPrivateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
const token = signToken(testPayload, otherPrivateKey);
expect(() => verifyToken(token, publicKey)).toThrow();
});
it('should throw for a tampered token', () => {
const token = signToken(testPayload, privateKey);
const parts = token.split('.');
// Tamper the payload
const tamperedToken = `${parts[0]}.TAMPERED.${parts[2]}`;
expect(() => verifyToken(tamperedToken, publicKey)).toThrow();
});
});
describe('decodeToken()', () => {
it('should decode a valid token without verifying the signature', () => {
const token = signToken(testPayload, privateKey);
const decoded = decodeToken(token);
expect(decoded).not.toBeNull();
expect(decoded?.sub).toBe(testPayload.sub);
});
it('should return null for a malformed token', () => {
const result = decodeToken('not.a.valid.token');
// jsonwebtoken.decode returns null for fully invalid tokens but
// may parse some parts — we handle both cases
expect(result === null || typeof result === 'object').toBe(true);
});
it('should return null for an empty string', () => {
const result = decodeToken('');
expect(result).toBeNull();
});
});
describe('getTokenExpiresIn()', () => {
it('should return 3600', () => {
expect(getTokenExpiresIn()).toBe(3600);
});
});
});

View File

@@ -0,0 +1,245 @@
/**
* Unit tests for src/utils/validators.ts
*/
import {
createAgentSchema,
updateAgentSchema,
listAgentsQuerySchema,
tokenRequestSchema,
introspectRequestSchema,
revokeRequestSchema,
generateCredentialSchema,
listCredentialsQuerySchema,
auditQuerySchema,
} from '../../../src/utils/validators';
describe('validators', () => {
// ────────────────────────────────────────────────────────────────
// createAgentSchema
// ────────────────────────────────────────────────────────────────
describe('createAgentSchema', () => {
const valid = {
email: 'agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'team-a',
deploymentEnv: 'production',
};
it('should accept a valid request', () => {
const { error } = createAgentSchema.validate(valid);
expect(error).toBeUndefined();
});
it('should reject an invalid email', () => {
const { error } = createAgentSchema.validate({ ...valid, email: 'not-an-email' });
expect(error).toBeDefined();
});
it('should reject an invalid agentType', () => {
const { error } = createAgentSchema.validate({ ...valid, agentType: 'invalid' });
expect(error).toBeDefined();
});
it('should reject an invalid semver', () => {
const { error } = createAgentSchema.validate({ ...valid, version: 'v1' });
expect(error).toBeDefined();
});
it('should reject empty capabilities array', () => {
const { error } = createAgentSchema.validate({ ...valid, capabilities: [] });
expect(error).toBeDefined();
});
it('should reject capability with invalid format', () => {
const { error } = createAgentSchema.validate({ ...valid, capabilities: ['invalid'] });
expect(error).toBeDefined();
});
it('should reject missing required fields', () => {
const { error } = createAgentSchema.validate({});
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// updateAgentSchema
// ────────────────────────────────────────────────────────────────
describe('updateAgentSchema', () => {
it('should accept a single field update', () => {
const { error } = updateAgentSchema.validate({ version: '2.0.0' });
expect(error).toBeUndefined();
});
it('should reject an empty object (minProperties: 1)', () => {
const { error } = updateAgentSchema.validate({});
expect(error).toBeDefined();
});
it('should accept valid status values', () => {
expect(updateAgentSchema.validate({ status: 'active' }).error).toBeUndefined();
expect(updateAgentSchema.validate({ status: 'suspended' }).error).toBeUndefined();
expect(updateAgentSchema.validate({ status: 'decommissioned' }).error).toBeUndefined();
});
it('should reject invalid status', () => {
const { error } = updateAgentSchema.validate({ status: 'deleted' });
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// listAgentsQuerySchema
// ────────────────────────────────────────────────────────────────
describe('listAgentsQuerySchema', () => {
it('should apply default values', () => {
const { value } = listAgentsQuerySchema.validate({});
expect(value.page).toBe(1);
expect(value.limit).toBe(20);
});
it('should reject limit > 100', () => {
const { error } = listAgentsQuerySchema.validate({ limit: 101 });
expect(error).toBeDefined();
});
it('should reject page < 1', () => {
const { error } = listAgentsQuerySchema.validate({ page: 0 });
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// tokenRequestSchema
// ────────────────────────────────────────────────────────────────
describe('tokenRequestSchema', () => {
it('should accept a valid token request', () => {
const { error } = tokenRequestSchema.validate({
grant_type: 'client_credentials',
client_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
client_secret: 'sk_live_abc123',
scope: 'agents:read agents:write',
});
expect(error).toBeUndefined();
});
it('should reject missing grant_type', () => {
const { error } = tokenRequestSchema.validate({ client_id: 'uuid', client_secret: 'secret' });
expect(error).toBeDefined();
});
it('should reject invalid scope', () => {
const { error } = tokenRequestSchema.validate({
grant_type: 'client_credentials',
scope: 'admin:all',
});
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// introspectRequestSchema
// ────────────────────────────────────────────────────────────────
describe('introspectRequestSchema', () => {
it('should accept a valid introspect request', () => {
const { error } = introspectRequestSchema.validate({ token: 'some.jwt.token' });
expect(error).toBeUndefined();
});
it('should reject missing token', () => {
const { error } = introspectRequestSchema.validate({});
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// revokeRequestSchema
// ────────────────────────────────────────────────────────────────
describe('revokeRequestSchema', () => {
it('should accept a valid revoke request', () => {
const { error } = revokeRequestSchema.validate({ token: 'some.jwt.token' });
expect(error).toBeUndefined();
});
it('should reject missing token', () => {
const { error } = revokeRequestSchema.validate({});
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// generateCredentialSchema
// ────────────────────────────────────────────────────────────────
describe('generateCredentialSchema', () => {
it('should accept empty body (expiresAt is optional)', () => {
const { error } = generateCredentialSchema.validate({});
expect(error).toBeUndefined();
});
it('should accept valid ISO 8601 expiresAt', () => {
const { error } = generateCredentialSchema.validate({
expiresAt: '2027-01-01T00:00:00.000Z',
});
expect(error).toBeUndefined();
});
it('should reject non-ISO date', () => {
const { error } = generateCredentialSchema.validate({ expiresAt: '2027/01/01' });
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// listCredentialsQuerySchema
// ────────────────────────────────────────────────────────────────
describe('listCredentialsQuerySchema', () => {
it('should apply defaults', () => {
const { value } = listCredentialsQuerySchema.validate({});
expect(value.page).toBe(1);
expect(value.limit).toBe(20);
});
it('should accept status filter', () => {
const { error } = listCredentialsQuerySchema.validate({ status: 'active' });
expect(error).toBeUndefined();
});
it('should reject invalid status', () => {
const { error } = listCredentialsQuerySchema.validate({ status: 'expired' });
expect(error).toBeDefined();
});
});
// ────────────────────────────────────────────────────────────────
// auditQuerySchema
// ────────────────────────────────────────────────────────────────
describe('auditQuerySchema', () => {
it('should apply defaults', () => {
const { value } = auditQuerySchema.validate({});
expect(value.page).toBe(1);
expect(value.limit).toBe(50);
});
it('should accept valid audit action', () => {
const { error } = auditQuerySchema.validate({ action: 'token.issued' });
expect(error).toBeUndefined();
});
it('should reject invalid action', () => {
const { error } = auditQuerySchema.validate({ action: 'unknown.action' });
expect(error).toBeDefined();
});
it('should accept limit up to 200', () => {
const { error } = auditQuerySchema.validate({ limit: 200 });
expect(error).toBeUndefined();
});
it('should reject limit > 200', () => {
const { error } = auditQuerySchema.validate({ limit: 201 });
expect(error).toBeDefined();
});
});
});

30
tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}