feat: Phase 1 P1 — Dockerfile, AGNTCY alignment docs, Node.js SDK

Three remaining Phase 1 P1 deliverables:

1. Dockerfile — multi-stage build (builder + production), node:18-alpine,
   non-root USER node, .dockerignore excluding secrets and dev artifacts

2. AGNTCY alignment docs (docs/agntcy/) — README and alignment.md mapping
   all 6 AGNTCY domains to AgentIdP features with Phase 2/3 pending items noted

3. Node.js SDK (@sentryagent/idp-sdk) — TypeScript strict, zero any, native
   fetch (Node 18+), TokenManager with 60s auto-refresh, service clients for
   all 14 endpoints (agents, credentials, tokens, audit), AgentIdPError typed
   error hierarchy, full README

All three changes tracked under openspec/changes/ with tasks marked complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 14:46:53 +00:00
parent d94a8cedc0
commit aa5167835e
34 changed files with 1572 additions and 0 deletions

66
sdk/src/client.ts Normal file
View File

@@ -0,0 +1,66 @@
import { TokenManager } from './token-manager.js';
import { AgentRegistryClient } from './services/agents.js';
import { CredentialClient } from './services/credentials.js';
import { TokenClient } from './services/token.js';
import { AuditClient } from './services/audit.js';
import type { AgentIdPClientConfig, OAuthScope } from './types.js';
/**
* Top-level client for the SentryAgent.ai AgentIdP API.
*
* Composes all service clients under a single entry point.
* Handles token acquisition and caching automatically via TokenManager.
*
* @example
* ```typescript
* const client = new AgentIdPClient({
* baseUrl: 'http://localhost:3000',
* clientId: 'your-agent-id',
* clientSecret: 'your-client-secret',
* });
*
* const agents = await client.agents.listAgents();
* ```
*/
export class AgentIdPClient {
/** Agent Registry operations: register, list, get, update, decommission. */
readonly agents: AgentRegistryClient;
/** Credential operations: generate, list, rotate, revoke. */
readonly credentials: CredentialClient;
/** Token operations: introspect, revoke. */
readonly tokens: TokenClient;
/** Audit log operations: query, get event. */
readonly audit: AuditClient;
private readonly tokenManager: TokenManager;
constructor(config: AgentIdPClientConfig) {
const defaultScopes: OAuthScope[] = ['agents:read', 'agents:write', 'tokens:read', 'audit:read'];
const scopes = (config.scopes ?? defaultScopes).join(' ');
this.tokenManager = new TokenManager(
config.baseUrl,
config.clientId,
config.clientSecret,
scopes,
);
const getToken = () => this.tokenManager.getToken();
this.agents = new AgentRegistryClient(config.baseUrl, getToken);
this.credentials = new CredentialClient(config.baseUrl, getToken);
this.tokens = new TokenClient(config.baseUrl, getToken);
this.audit = new AuditClient(config.baseUrl, getToken);
}
/**
* Clear the cached access token. The next API call will request a new token.
* Use this after rotating credentials or when you suspect the token is stale.
*/
clearTokenCache(): void {
this.tokenManager.clearCache();
}
}

71
sdk/src/errors.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Error types for the SentryAgent.ai AgentIdP SDK.
*/
/** Standard error response shape from the AgentIdP API. */
interface ApiErrorBody {
code: string;
message: string;
details?: Record<string, unknown>;
}
/** OAuth 2.0 error response shape from the token endpoint. */
interface OAuth2ErrorBody {
error: string;
error_description: string;
}
/**
* Typed error thrown by the AgentIdP SDK for all API failures.
* Never throws raw fetch errors or untyped exceptions.
*/
export class AgentIdPError extends Error {
/** Machine-readable error code from the API (e.g. AGENT_NOT_FOUND). */
readonly code: string;
/** HTTP status code of the failed response. */
readonly httpStatus: number;
/** Optional structured details from the API error response. */
readonly details?: Record<string, unknown>;
constructor(code: string, message: string, httpStatus: number, details?: Record<string, unknown>) {
super(message);
this.name = 'AgentIdPError';
this.code = code;
this.httpStatus = httpStatus;
this.details = details;
}
/**
* Creates an AgentIdPError from a standard API error response body.
* Accepts unknown to allow callers to pass raw parsed JSON without pre-casting.
*
* @param body - Parsed API error response (or unknown).
* @param httpStatus - HTTP status code.
* @returns AgentIdPError instance.
*/
static fromApiError(body: unknown, httpStatus: number): AgentIdPError {
if (
typeof body === 'object' &&
body !== null &&
'code' in body &&
'message' in body &&
typeof (body as ApiErrorBody).code === 'string' &&
typeof (body as ApiErrorBody).message === 'string'
) {
const typed = body as ApiErrorBody;
return new AgentIdPError(typed.code, typed.message, httpStatus, typed.details);
}
return new AgentIdPError('UNKNOWN_ERROR', String(body), httpStatus);
}
/**
* Creates an AgentIdPError from an OAuth 2.0 error response body.
*
* @param body - Parsed OAuth2 error response.
* @param httpStatus - HTTP status code.
* @returns AgentIdPError instance.
*/
static fromOAuth2Error(body: OAuth2ErrorBody, httpStatus: number): AgentIdPError {
return new AgentIdPError(body.error, body.error_description, httpStatus);
}
}

35
sdk/src/index.ts Normal file
View File

@@ -0,0 +1,35 @@
export { AgentIdPClient } from './client.js';
export { AgentIdPError } from './errors.js';
export { TokenManager } from './token-manager.js';
export type {
// Config
AgentIdPClientConfig,
// Enums / union types
AgentType,
AgentStatus,
DeploymentEnv,
CredentialStatus,
OAuthScope,
AuditAction,
AuditOutcome,
// Agent Registry
Agent,
RegisterAgentRequest,
UpdateAgentRequest,
ListAgentsParams,
PaginatedAgents,
// Credentials
Credential,
CredentialWithSecret,
GenerateCredentialRequest,
ListCredentialsParams,
PaginatedCredentials,
// Tokens
TokenResponse,
IntrospectResponse,
// Audit
AuditEvent,
QueryAuditLogParams,
PaginatedAuditEvents,
} from './types.js';

72
sdk/src/request.ts Normal file
View File

@@ -0,0 +1,72 @@
import { AgentIdPError } from './errors.js';
type HttpMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
interface RequestOptions {
method: HttpMethod;
path: string;
token: string;
body?: unknown;
query?: Record<string, string | number | boolean | undefined>;
}
/**
* Shared HTTP request helper for all AgentIdP API calls.
* Sets Authorization header, serialises JSON body, parses response,
* and maps API error shapes to AgentIdPError.
*/
export async function request<T>(baseUrl: string, opts: RequestOptions): Promise<T> {
const url = new URL(opts.path, baseUrl);
if (opts.query) {
for (const [key, value] of Object.entries(opts.query)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
}
const headers: Record<string, string> = {
Authorization: `Bearer ${opts.token}`,
Accept: 'application/json',
};
let bodyPayload: string | undefined;
if (opts.body !== undefined) {
headers['Content-Type'] = 'application/json';
bodyPayload = JSON.stringify(opts.body);
}
let response: Response;
try {
response = await fetch(url.toString(), {
method: opts.method,
headers,
body: bodyPayload,
});
} catch (err) {
throw new AgentIdPError(
'NETWORK_ERROR',
`Network error: ${err instanceof Error ? err.message : String(err)}`,
0,
);
}
if (response.status === 204) {
return undefined as unknown as T;
}
let data: unknown;
const contentType = response.headers.get('content-type') ?? '';
if (contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
if (!response.ok) {
throw AgentIdPError.fromApiError(data, response.status);
}
return data as T;
}

View File

@@ -0,0 +1,90 @@
import { request } from '../request.js';
import type {
Agent,
RegisterAgentRequest,
UpdateAgentRequest,
ListAgentsParams,
PaginatedAgents,
} from '../types.js';
/**
* Client for the Agent Registry service.
* Covers all agent CRUD operations: register, list, get, update, decommission.
*/
export class AgentRegistryClient {
constructor(
private readonly baseUrl: string,
private readonly getToken: () => Promise<string>,
) {}
/**
* Register a new AI agent.
* Returns the created agent record including its agentId and agentSecret.
*/
async registerAgent(params: RegisterAgentRequest): Promise<Agent> {
const token = await this.getToken();
return request<Agent>(this.baseUrl, {
method: 'POST',
path: '/api/v1/agents',
token,
body: params,
});
}
/**
* List all registered agents with optional filters and pagination.
*/
async listAgents(params: ListAgentsParams = {}): Promise<PaginatedAgents> {
const token = await this.getToken();
return request<PaginatedAgents>(this.baseUrl, {
method: 'GET',
path: '/api/v1/agents',
token,
query: {
status: params.status,
agentType: params.agentType,
page: params.page,
limit: params.limit,
},
});
}
/**
* Get a single agent by its agentId.
*/
async getAgent(agentId: string): Promise<Agent> {
const token = await this.getToken();
return request<Agent>(this.baseUrl, {
method: 'GET',
path: `/api/v1/agents/${agentId}`,
token,
});
}
/**
* Update mutable fields on an existing agent (name, description, capabilities, metadata).
* Returns the updated agent record.
*/
async updateAgent(agentId: string, params: UpdateAgentRequest): Promise<Agent> {
const token = await this.getToken();
return request<Agent>(this.baseUrl, {
method: 'PATCH',
path: `/api/v1/agents/${agentId}`,
token,
body: params,
});
}
/**
* Decommission an agent. This is irreversible — the agent can no longer
* authenticate or obtain tokens after decommission.
*/
async decommissionAgent(agentId: string): Promise<void> {
const token = await this.getToken();
return request<void>(this.baseUrl, {
method: 'DELETE',
path: `/api/v1/agents/${agentId}`,
token,
});
}
}

48
sdk/src/services/audit.ts Normal file
View File

@@ -0,0 +1,48 @@
import { request } from '../request.js';
import type { AuditEvent, QueryAuditLogParams, PaginatedAuditEvents } from '../types.js';
/**
* Client for the Audit Log service.
* Covers querying the audit event list and fetching individual events.
*/
export class AuditClient {
constructor(
private readonly baseUrl: string,
private readonly getToken: () => Promise<string>,
) {}
/**
* Query audit log events with optional filters and pagination.
* Events are retained for 90 days. Requires `audit:read` scope.
*/
async queryAuditLog(params: QueryAuditLogParams = {}): Promise<PaginatedAuditEvents> {
const token = await this.getToken();
return request<PaginatedAuditEvents>(this.baseUrl, {
method: 'GET',
path: '/api/v1/audit',
token,
query: {
agentId: params.agentId,
action: params.action,
outcome: params.outcome,
fromDate: params.fromDate,
toDate: params.toDate,
page: params.page,
limit: params.limit,
},
});
}
/**
* Get a single audit event by its eventId.
* Requires `audit:read` scope.
*/
async getAuditEvent(eventId: string): Promise<AuditEvent> {
const token = await this.getToken();
return request<AuditEvent>(this.baseUrl, {
method: 'GET',
path: `/api/v1/audit/${eventId}`,
token,
});
}
}

View File

@@ -0,0 +1,83 @@
import { request } from '../request.js';
import type {
Credential,
CredentialWithSecret,
GenerateCredentialRequest,
ListCredentialsParams,
PaginatedCredentials,
} from '../types.js';
/**
* Client for the Credential Management service.
* Covers generate, list, rotate, and revoke operations for agent credentials.
*/
export class CredentialClient {
constructor(
private readonly baseUrl: string,
private readonly getToken: () => Promise<string>,
) {}
/**
* Generate a new credential for an agent.
* The `clientSecret` in the response is shown ONCE — store it securely immediately.
*/
async generateCredential(
agentId: string,
params: GenerateCredentialRequest = {},
): Promise<CredentialWithSecret> {
const token = await this.getToken();
return request<CredentialWithSecret>(this.baseUrl, {
method: 'POST',
path: `/api/v1/agents/${agentId}/credentials`,
token,
body: Object.keys(params).length > 0 ? params : undefined,
});
}
/**
* List all credentials for an agent with optional pagination.
* Secrets are never returned in list responses.
*/
async listCredentials(
agentId: string,
params: ListCredentialsParams = {},
): Promise<PaginatedCredentials> {
const token = await this.getToken();
return request<PaginatedCredentials>(this.baseUrl, {
method: 'GET',
path: `/api/v1/agents/${agentId}/credentials`,
token,
query: {
page: params.page,
limit: params.limit,
},
});
}
/**
* Rotate a credential. The same credentialId is retained but a new secret is issued.
* The old secret is immediately invalidated upon rotation.
* The new `clientSecret` is shown ONCE — store it securely immediately.
*/
async rotateCredential(agentId: string, credentialId: string): Promise<CredentialWithSecret> {
const token = await this.getToken();
return request<CredentialWithSecret>(this.baseUrl, {
method: 'POST',
path: `/api/v1/agents/${agentId}/credentials/${credentialId}/rotate`,
token,
});
}
/**
* Revoke a credential. Any tokens previously issued with this credential
* remain valid until they expire — use token revocation to invalidate them immediately.
*/
async revokeCredential(agentId: string, credentialId: string): Promise<Credential> {
const token = await this.getToken();
return request<Credential>(this.baseUrl, {
method: 'DELETE',
path: `/api/v1/agents/${agentId}/credentials/${credentialId}`,
token,
});
}
}

80
sdk/src/services/token.ts Normal file
View File

@@ -0,0 +1,80 @@
import { request } from '../request.js';
import { AgentIdPError } from '../errors.js';
import type { IntrospectResponse } from '../types.js';
/**
* Client for OAuth 2.0 token operations (introspect and revoke).
* Token issuance is handled separately by TokenManager.
*/
export class TokenClient {
constructor(
private readonly baseUrl: string,
private readonly getToken: () => Promise<string>,
) {}
/**
* Introspect a token to check whether it is currently active.
* Always returns 200 — check the `active` field to determine validity.
* Requires a Bearer token with `tokens:read` scope.
*/
async introspectToken(tokenToCheck: string): Promise<IntrospectResponse> {
const token = await this.getToken();
const body = new URLSearchParams({ token: tokenToCheck });
let response: Response;
try {
response = await fetch(new URL('/api/v1/token/introspect', this.baseUrl).toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
} catch (err) {
throw new AgentIdPError(
'NETWORK_ERROR',
`Network error: ${err instanceof Error ? err.message : String(err)}`,
0,
);
}
const data: unknown = await response.json();
if (!response.ok) {
throw AgentIdPError.fromApiError(data, response.status);
}
return data as IntrospectResponse;
}
/**
* Revoke a token immediately. Idempotent — revoking an already-revoked
* or expired token is not an error (RFC 7009).
*/
async revokeToken(tokenToRevoke: string): Promise<void> {
const token = await this.getToken();
const body = new URLSearchParams({ token: tokenToRevoke });
let response: Response;
try {
response = await fetch(new URL('/api/v1/token/revoke', this.baseUrl).toString(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.toString(),
});
} catch (err) {
throw new AgentIdPError(
'NETWORK_ERROR',
`Network error: ${err instanceof Error ? err.message : String(err)}`,
0,
);
}
if (!response.ok) {
const data: unknown = await response.json();
throw AgentIdPError.fromApiError(data, response.status);
}
}
}

97
sdk/src/token-manager.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* TokenManager — handles OAuth 2.0 token acquisition, caching, and refresh.
* Tokens are re-issued automatically when expired or within 60 seconds of expiry.
*/
import { AgentIdPError } from './errors.js';
import { TokenResponse } from './types.js';
/** Seconds before expiry at which a token refresh is triggered. */
const REFRESH_BUFFER_SECONDS = 60;
interface CachedToken {
accessToken: string;
expiresAt: number; // Unix seconds
}
/**
* Manages token acquisition and caching for the AgentIdP SDK.
* Callers request a token via `getToken()` — the manager handles issuance and refresh transparently.
*/
export class TokenManager {
private cached: CachedToken | null = null;
/**
* @param baseUrl - AgentIdP API base URL.
* @param clientId - The agent's clientId (agentId UUID).
* @param clientSecret - The agent's clientSecret.
* @param scopes - Space-separated OAuth 2.0 scopes to request.
*/
constructor(
private readonly baseUrl: string,
private readonly clientId: string,
private readonly clientSecret: string,
private readonly scopes: string,
) {}
/**
* Returns a valid access token.
* Acquires a new token if none is cached or the cached token is near expiry.
*
* @returns A valid JWT access token string.
* @throws AgentIdPError if token acquisition fails.
*/
async getToken(): Promise<string> {
const now = Math.floor(Date.now() / 1000);
if (this.cached && this.cached.expiresAt - now > REFRESH_BUFFER_SECONDS) {
return this.cached.accessToken;
}
const token = await this.issueToken();
this.cached = {
accessToken: token.access_token,
expiresAt: now + token.expires_in,
};
return this.cached.accessToken;
}
/**
* Calls POST /token to issue a new access token.
*
* @returns TokenResponse from the API.
* @throws AgentIdPError on authentication failure or API error.
*/
private async issueToken(): Promise<TokenResponse> {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: this.scopes,
});
const response = await fetch(`${this.baseUrl}/api/v1/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
const data = (await response.json()) as Record<string, unknown>;
if (!response.ok) {
const errorBody = data as { error: string; error_description: string };
throw AgentIdPError.fromOAuth2Error(
{ error: String(errorBody.error ?? 'unknown_error'), error_description: String(errorBody.error_description ?? 'Token request failed.') },
response.status,
);
}
return data as unknown as TokenResponse;
}
/** Clears the cached token, forcing re-acquisition on next getToken() call. */
clearCache(): void {
this.cached = null;
}
}

217
sdk/src/types.ts Normal file
View File

@@ -0,0 +1,217 @@
/**
* TypeScript types for the SentryAgent.ai AgentIdP SDK.
* All request and response shapes derived from the AgentIdP OpenAPI 3.0 specs.
*/
// ─────────────────────────────────────────────────────────────────────────────
// Shared enums
// ─────────────────────────────────────────────────────────────────────────────
/** 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. */
export type DeploymentEnv = 'development' | 'staging' | 'production';
/** Lifecycle status of a credential. */
export type CredentialStatus = 'active' | 'revoked';
/** OAuth 2.0 scopes supported by AgentIdP. */
export type OAuthScope = 'agents:read' | 'agents:write' | 'tokens:read' | 'audit:read';
/** Audit event action types. */
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
// ─────────────────────────────────────────────────────────────────────────────
/** A registered AI agent identity. */
export interface Agent {
agentId: string;
email: string;
agentType: AgentType;
version: string;
capabilities: string[];
owner: string;
deploymentEnv: DeploymentEnv;
status: AgentStatus;
createdAt: string;
updatedAt: string;
}
/** Request body for registering a new agent. */
export interface RegisterAgentRequest {
email: string;
agentType: AgentType;
version: string;
capabilities: string[];
owner: string;
deploymentEnv: DeploymentEnv;
}
/** Request body for updating agent metadata (all fields optional). */
export interface UpdateAgentRequest {
agentType?: AgentType;
version?: string;
capabilities?: string[];
owner?: string;
deploymentEnv?: DeploymentEnv;
status?: AgentStatus;
}
/** Query parameters for listing agents. */
export interface ListAgentsParams {
page?: number;
limit?: number;
owner?: string;
agentType?: AgentType;
status?: AgentStatus;
}
/** Paginated list of agents. */
export interface PaginatedAgents {
data: Agent[];
total: number;
page: number;
limit: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Credential Management
// ─────────────────────────────────────────────────────────────────────────────
/** A credential record (clientSecret never included). */
export interface Credential {
credentialId: string;
clientId: string;
status: CredentialStatus;
createdAt: string;
expiresAt: string | null;
revokedAt: string | null;
}
/** A credential record with the plain-text secret — returned once only on create/rotate. */
export interface CredentialWithSecret extends Credential {
clientSecret: string;
}
/** Optional request body for generating or rotating a credential. */
export interface GenerateCredentialRequest {
expiresAt?: string;
}
/** Query parameters for listing credentials. */
export interface ListCredentialsParams {
page?: number;
limit?: number;
status?: CredentialStatus;
}
/** Paginated list of credentials. */
export interface PaginatedCredentials {
data: Credential[];
total: number;
page: number;
limit: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// OAuth 2.0 Tokens
// ─────────────────────────────────────────────────────────────────────────────
/** OAuth 2.0 access token response. */
export interface TokenResponse {
access_token: string;
token_type: 'Bearer';
expires_in: number;
scope: string;
}
/** Token introspection response (RFC 7662). */
export interface IntrospectResponse {
active: boolean;
sub?: string;
client_id?: string;
scope?: string;
token_type?: string;
iat?: number;
exp?: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// Audit Log
// ─────────────────────────────────────────────────────────────────────────────
/** An immutable audit event record. */
export interface AuditEvent {
eventId: string;
agentId: string;
action: AuditAction;
outcome: AuditOutcome;
ipAddress: string;
userAgent: string;
metadata: Record<string, unknown>;
timestamp: string;
}
/** Query parameters for the audit log. */
export interface QueryAuditLogParams {
page?: number;
limit?: number;
agentId?: string;
action?: AuditAction;
outcome?: AuditOutcome;
fromDate?: string;
toDate?: string;
}
/** Paginated list of audit events. */
export interface PaginatedAuditEvents {
data: AuditEvent[];
total: number;
page: number;
limit: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// SDK Config
// ─────────────────────────────────────────────────────────────────────────────
/** Configuration for AgentIdPClient. */
export interface AgentIdPClientConfig {
/** Base URL of the AgentIdP server, e.g. http://localhost:3000/api/v1 */
baseUrl: string;
/** The agent's clientId (agentId UUID). */
clientId: string;
/** The agent's clientSecret. */
clientSecret: string;
/** OAuth 2.0 scopes to request. Defaults to all scopes. */
scopes?: OAuthScope[];
}