Compare commits

..

40 Commits

Author SHA1 Message Date
SentryAgent.ai Developer
7441c9f298 fix(vv): resolve all 6 V&V issues — field trial unblocked
All findings from the inaugural LeadValidator audit resolved and
confirmed. Release gate: PASS.

VV_ISSUE_002 (BLOCKER): 15 OpenAPI specs verified present covering
all 20 route groups (46 endpoints documented in docs/openapi/)

VV_ISSUE_003 (MAJOR): Remove any types from src/db/pool.ts —
replaced pool.query shim with unknown[] + Object.defineProperty,
zero any types, eslint-disable suppressions removed

VV_ISSUE_004 (MAJOR): Remove raw Pool from ScaffoldController and
HealthDetailedController — injected AgentRepository/CredentialRepository
and DbProbe interface respectively; added CredentialRepository.findActiveClientId()

VV_ISSUE_005 (MAJOR): Add unit tests for 5 untested services —
ComplianceStatusStore, EventPublisher, MarketplaceService,
OIDCTrustPolicyService, UsageService

VV_ISSUE_006 (MAJOR): Add integration tests for 7 missing route
groups — analytics, billing, tiers, webhooks, marketplace,
oidc-trust-policies, oidc-token-exchange

VV_ISSUE_001 (MINOR): Create missing design.md and tasks.md in 4
OpenSpec archives — all archives now complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 04:52:47 +00:00
SentryAgent.ai Developer
d216096dfb feat(governance): add V&V Architect (LeadValidator) — independent audit agent
Fixes a critical bug where VALIDATOR.md contained a copy of start-validator.sh
(making the validator unlaunchable). Introduces a fully independent V&V Architect
agent that audits the codebase against the PRD and OpenSpec outside the CTO's
chain of command.

Changes:
- VALIDATOR.md: rewritten as proper system prompt (8-phase audit methodology,
  issue format, severity model, communication protocol)
- scripts/start-validator.sh: isolated workspace setup, sanity check, auto-init
  ledger, validator-specific CLAUDE.md (no CEO context contamination)
- openspec/vv_audit/LEDGER.md: shared audit ledger index (CEO release gate view)
- openspec/changes/archive/2026-04-07-vv-architect-setup/: full OpenSpec artifacts
  (proposal.md, design.md, tasks.md — 28 tasks, all complete)

Note: .cto-workspace/CLAUDE.md updated (gitignored — persists on disk only).
#vv-findings hub channel created for real-time validator notifications.

CEO approved 2026-04-07.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 02:56:36 +00:00
SentryAgent.ai Developer
8cabc0191c docs: commit all Phase 6 documentation updates and OpenSpec archives
- devops docs: 8 files updated for Phase 6 state; field-trial.md added (946-line runbook)
- developer docs: api-reference (50+ endpoints), quick-start, 5 existing guides updated, 5 new guides added
- engineering docs: all 12 files updated (services, architecture, SDK guide, testing, overview)
- OpenSpec archives: phase-7-devops-field-trial, developer-docs-phase6-update, engineering-docs-phase6-update
- VALIDATOR.md + scripts/start-validator.sh: V&V Architect tooling added
- .gitignore: exclude session artifacts, build artifacts, and agent workspaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 02:24:24 +00:00
SentryAgent.ai Developer
0fb00256b4 chore(openspec): archive phase-6-market-expansion — 53/53 tasks complete
Analytics Dashboard, API Gateway Tiers, AGNTCY Compliance all delivered.
Development freeze now in effect per CEO directive — no Phase 7.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:20:22 +00:00
SentryAgent.ai Developer
e327c41211 chore(phase-6): mark all 53 tasks complete in tasks.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:20:16 +00:00
SentryAgent.ai Developer
eea885db04 feat(phase-6): WS3+WS4+WS6 — Analytics, API Tiers, AGNTCY Compliance
WS3 — Advanced Analytics Dashboard:
- DB migration: analytics_events table (tenant_id, date, metric_type, count)
- AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary
- Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated)
- AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag)
- Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page

WS4 — API Gateway Tiers:
- DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits)
- TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts
- tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag)
- Agent count enforcement in AgentService.create()
- Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed
- TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow

WS6 — AGNTCY Compliance Certification:
- ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards()
- Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain)
- ComplianceController updated with getComplianceReport, exportAgentCards handlers
- routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected

QA:
- 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass
- 673 total unit tests passing; 0 TypeScript errors across API and portal
- AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests)
- Portal builds cleanly: 9 routes including /analytics and /settings/tier
- Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 02:20:09 +00:00
SentryAgent.ai Developer
0fad328329 feat(openspec): propose phase-6-market-expansion change
Analytics Dashboard, API Gateway Tiers, AGNTCY Compliance — 62 tasks across 8 groups.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:57:23 +00:00
SentryAgent.ai Developer
8fd6823581 chore(openspec): archive phase-5-scale-ecosystem — 68/68 tasks complete
WS1 (Rust SDK), WS2 (A2A Authorization), WS5 (Developer Experience)
all delivered, QA gates passed, committed to main.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:54:45 +00:00
SentryAgent.ai Developer
eaabaebf52 chore(phase-5): mark all 68 tasks complete in tasks.md
Phase 5 implementation complete — WS1 (Rust SDK), WS2 (A2A Authorization),
WS5 (Developer Experience). All QA gates passed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:50:43 +00:00
SentryAgent.ai Developer
662879f0ee feat(phase-5): WS5 — Developer Experience
Implements scaffold ZIP generator, Stoplight Elements API explorer, and CLI scaffold command:

Scaffold API:
- 25 template files for TypeScript/Python/Go/Java/Rust in src/templates/scaffold/
- ScaffoldService: in-memory ZIP via archiver, variable injection (AGENT_ID/NAME/CLIENT_ID/API_URL)
- ScaffoldController: tenant ownership check (403), language validation (400), ZIP stream response
- Route GET /sdk/scaffold/:agentId with rate limiter (10 req/min per tenant)
- Prometheus: scaffold_generated_total + scaffold_generation_duration_ms histogram

Portal:
- Replaced swagger-ui-react with @stoplight/elements API component
- Dynamic import (ssr: false) for browser-only DOM dependency
- Type declarations for @stoplight/elements and CSS module

CLI:
- sentryagent scaffold --agent-id <id> [--language typescript] [--out .]
- Raw fetch for binary ZIP stream → unzipper.Extract() → prints next steps
- Human-readable 400/403/404 error messages

Tests: 19 tests (unit + integration), ScaffoldService 80%+ branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:50:32 +00:00
SentryAgent.ai Developer
16497706d3 feat(phase-5): WS2 — A2A Authorization
Implements agent-to-agent delegation chains:
- Migration 024: delegation_chains table with HMAC signature, TTL, revocation
- DelegationCrypto: HMAC-SHA256 sign/verify, UUID token generation
- DelegationService: create (scope subset validation, self-delegation guard,
  same-tenant delegatee check), verify (returns valid: false on expired/revoked,
  never throws), revoke (delegator-only, conflict guard)
- DelegationController + router at /oauth2/token/delegate (POST/DELETE) and
  /oauth2/token/verify-delegation (POST)
- Feature-flagged behind A2A_ENABLED env var (default on)
- Prometheus metrics: delegations_created/verified/revoked_total
- 33 tests (unit + integration): all pass, DelegationService 87.5%+ branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:49:36 +00:00
SentryAgent.ai Developer
0506bc1b8e chore(sdk-rust): add .gitignore to exclude build artifacts
Removes sdk-rust/target/ from tracking — was accidentally committed
without a Rust .gitignore in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:49:19 +00:00
SentryAgent.ai Developer
a4aab1b5b3 feat(phase-5): WS1 — Rust SDK
Implements the sentryagent-idp Rust SDK crate (sdk-rust/) with:
- TokenManager with Arc<Mutex<TokenCache>> for thread-safe token caching
- AgentIdPClient with full method coverage: agents, oauth2, credentials, audit, marketplace, delegation
- Error hierarchy via thiserror (AgentIdPError enum)
- All model types with serde derive
- 429 RateLimited handling with Retry-After parsing; zero unwrap() calls
- Unit tests (mockito), doc tests, and integration tests (#[ignore])
- quickstart example, full README, cargo doc clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 02:48:14 +00:00
SentryAgent.ai Developer
fec1801e8c chore(openspec): trim phase-5 scope to WS1+WS2+WS5 per CEO approval
Approved: Rust SDK, A2A Authorization, Developer Experience.
Deferred to Phase 6: Analytics Dashboard, API Gateway Tiers, AGNTCY Compliance.
Tasks: 119 → 76. Specs: 6 → 3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:42:05 +00:00
SentryAgent.ai Developer
389a764e8d feat(openspec): propose phase-5-scale-ecosystem change
6 workstreams, 119 tasks — Scale & Ecosystem:
- WS1: Rust SDK
- WS2: Agent-to-Agent (A2A) Authorization
- WS3: Advanced Analytics Dashboard
- WS4: Public API Gateway & Rate Limiting SaaS
- WS5: Developer Experience (DX) improvements
- WS6: AGNTCY Compliance Certification Package

Awaiting CEO approval to begin implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:33:08 +00:00
SentryAgent.ai Developer
831e91c467 chore(openspec): archive phase-4-developer-growth change
All 90 tasks complete. Phase 4 — Developer Growth & Go-to-Market
fully delivered and archived per OpenSpec protocol.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 15:17:18 +00:00
SentryAgent.ai Developer
af630b43d4 chore(phase-4): QA fixes + gitignore portal build artifacts
- Fix 7 test fixtures missing isPublic field added in WS4 Marketplace
- Add portal/.next/ to .gitignore (build artifacts should not be tracked)
- Mark all Phase 4 tasks 11.1-11.11 complete in tasks.md

QA results: 611/611 tests pass, tsc zero errors, portal build OK, CLI build OK

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:59:11 +00:00
SentryAgent.ai Developer
26a56f84e1 feat(phase-4): WS6 — Billing & Usage Metering (Stripe, free tier enforcement)
- DB migration 023: tenant_subscriptions and usage_events tables
- UsageMeteringMiddleware: in-memory counters, 60s flush to DB via UPSERT
- FreeTierEnforcementMiddleware: 10 agents / 1,000 calls/day limits, Redis cache
- UsageService: getDailyUsage and getActiveAgentCount
- BillingService: Stripe checkout sessions, webhook verification, subscription status
- POST /billing/checkout, POST /billing/webhook, GET /billing/usage endpoints
- BILLING_ENABLED=false disables enforcement without breaking metering
- Dashboard: Usage tab with Free Tier/Pro badges and metric cards
- 19 unit tests passing across billing services and middleware

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:51:36 +00:00
SentryAgent.ai Developer
fefbf1e3ea feat(phase-4): WS5 — GitHub Actions OIDC token exchange and trust policies
- POST /oidc/token: GitHub OIDC JWT exchange (bootstrap + agent-scoped modes)
- POST/GET/DELETE /oidc/trust-policies: trust policy CRUD with enforcement
- DB migration 022: oidc_trust_policies table with provider/repo/branch/agent_id
- GitHub Actions: register-agent and issue-token actions with full READMEs
- Trust policy enforcement rejects token exchanges not matching registered policies
- Bootstrap mode issues agents:write token for new agent registration without agentId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:37:39 +00:00
SentryAgent.ai Developer
89c99b666d feat(phase-4): WS4 — Agent Marketplace (public registry, pagination, filters)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:17:51 +00:00
SentryAgent.ai Developer
d1e6af25aa feat(phase-4): WS2 + WS3 — Developer Portal (Next.js 14) and CLI tool (sentryagent)
WS2: Developer Portal (portal/)
- Standalone Next.js 14 + Tailwind CSS app — independent deployment
- Home page: hero, feature grid, CTA to /get-started
- /pricing: free tier limits table (10 agents, 1k calls/day) + paid tier CTA
- /sdks: all 4 SDKs (Node.js, Python, Go, Java) with install + code examples
- /api-explorer: Swagger UI from NEXT_PUBLIC_API_URL/openapi.json, persistAuthorization
- /get-started: 4-step wizard (setup → register agent → credentials → SDK snippet)
- Shared Nav component with active-link highlighting
- Build: 8/8 static pages, zero TypeScript errors

WS3: CLI Tool (cli/ — npm package: sentryagent)
- configure, register-agent, list-agents, issue-token, rotate-credentials, tail-audit-log
- Auto OAuth2 token fetch + 30s-buffer cache via client_credentials flow
- chalk-formatted table output, confirmation prompts, bounded audit log dedup
- bash + zsh shell completion scripts
- README with installation, all commands, and completion setup
- Build: tsc clean, node dist/index.js --help verified

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 04:29:50 +00:00
SentryAgent.ai Developer
1b682c22b2 feat(phase-4): WS1 — Production Hardening (Redis rate limiting, DB pool, health endpoint, k6)
Rate limiting:
- Replace in-memory express-rate-limit with ioredis + rate-limiter-flexible (sliding window)
- Graceful fallback to RateLimiterMemory when Redis unreachable
- RATE_LIMIT_WINDOW_MS / RATE_LIMIT_MAX_REQUESTS env var config
- Retry-After header on 429 responses
- agentidp_rate_limit_hits_total Prometheus counter

Database pool:
- Explicit pg.Pool config via DB_POOL_MAX/MIN/IDLE_TIMEOUT_MS/CONNECTION_TIMEOUT_MS
- Defaults: max=20, min=2, idle=30s, conn timeout=5s
- agentidp_db_pool_active_connections + agentidp_db_pool_waiting_requests gauges

Health endpoint:
- GET /health/detailed — per-service status (database, Redis, Vault, OPA)
- healthy / degraded (>1000ms) / unreachable classification
- HTTP 200 (all healthy) / 207 (any degraded) / 503 (any unreachable)

Load tests:
- tests/load/ with k6 scenarios for agent registration (100 VUs), token issuance (1000 VUs), credential rotation (50 VUs)
- npm run load-test script

Tests: 586 passing, zero TypeScript errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 04:20:37 +00:00
SentryAgent.ai Developer
b0f70b7ac4 feat(openspec): Phase 4 Developer Growth & Go-to-Market Readiness
OpenSpec change: phase-4-developer-growth (spec-driven, 4/4 artifacts)

6 workstreams, 90 implementation tasks, delivery sequence:
WS1 → WS2 + WS3 (parallel) → WS4 → WS5 → WS6

Workstreams:
1. Production Hardening — ioredis rate limiting, DB pool tuning, /health/detailed, k6 load tests
2. Developer Portal — Next.js 14, Swagger UI explorer, onboarding wizard, pricing/SDK pages
3. CLI Tool — sentryagent npm CLI, 5 commands, shell completion
4. Agent Marketplace — public searchable registry powered by existing agent/DID infrastructure
5. GitHub Actions — register-agent + issue-token Actions via OIDC (no stored secrets)
6. Billing & Usage Metering — Stripe Checkout, webhook-driven state, free tier enforcement

New capabilities (8 specs): production-hardening, developer-portal, cli-tool,
agent-marketplace, github-actions, billing-metering (+delta: web-dashboard, monitoring)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 04:00:34 +00:00
SentryAgent.ai Developer
f1fbe0e29a chore(openspec): archive all completed changes, sync 14 new specs to library
Archived 4 completed OpenSpec changes (2026-04-02):
- phase-3-enterprise (100/100 tasks) — 6 Phase 3 capabilities synced
- devops-documentation (48/48 tasks) — 3 new + 1 merged capability
- bedroom-developer-docs (33/33 tasks) — 4 new capabilities synced
- engineering-docs (superseded by 2026-03-29 archive) — no tasks

Main spec library grows from 21 → 35 capabilities (+14 new):
federation, multi-tenancy, oidc, soc2, w3c-dids, webhooks,
database, operations, system-overview, api-reference, core-concepts,
developer-guides, quick-start + deployment (merged additive requirements)

Active changes: 0 — project board is clear for Phase 4 planning.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 03:50:47 +00:00
SentryAgent.ai Developer
ceec22f714 chore(phase-3): mark WS6 tasks complete — Phase 3 Enterprise DONE
All 100/100 tasks checked. All 6 workstreams complete. QA-approved.
SOC 2 audit window can begin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:42:29 +00:00
SentryAgent.ai Developer
fd90b2acd1 feat(phase-3): workstream 6 — SOC 2 Type II Preparation
Implements all 22 WS6 tasks completing Phase 3 Enterprise.

Column-level encryption (AES-256-CBC, Vault-backed key) via EncryptionService
applied to credentials.secret_hash, credentials.vault_path,
webhook_subscriptions.vault_secret_path, and agent_did_keys.vault_key_path.
Backward-compatible: isEncrypted() guard skips decryption for existing
plaintext rows until next read-write cycle.

Audit chain integrity (CC7.2): AuditRepository computes SHA-256 Merkle hash
on every INSERT (hash = SHA-256(eventId+timestamp+action+outcome+agentId+orgId+prevHash)).
AuditVerificationService walks the full chain verifying hash continuity.
AuditChainVerificationJob runs hourly; sets agentidp_audit_chain_integrity
Prometheus gauge to 1 (pass) or 0 (fail).

TLS enforcement (CC6.7): TLSEnforcementMiddleware registered as first
middleware in Express stack; 301 redirect on non-https X-Forwarded-Proto
in production.

SecretsRotationJob (CC9.2): hourly scan for credentials expiring within 7
days; increments agentidp_credentials_expiring_soon_total.

ComplianceController + routes: GET /audit/verify (auth+audit:read scope,
30/min rate-limit); GET /compliance/controls (public, Cache-Control 60s).
ComplianceStatusStore: module-level map updated by jobs, consumed by controller.

Prometheus: 2 new metrics (agentidp_credentials_expiring_soon_total,
agentidp_audit_chain_integrity); 6 alerting rules in alerts.yml.

Compliance docs: soc2-controls-matrix.md, encryption-runbook.md,
audit-log-runbook.md, incident-response.md, secrets-rotation.md.

Tests: 557 unit tests passing (35 suites); 26 new tests (EncryptionService,
AuditVerificationService); 19 compliance integration tests. TypeScript clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:41:53 +00:00
SentryAgent.ai Developer
272b69f18d feat(phase-3): workstream 5 — Webhooks & Event Streaming
- DB migrations 016/017: webhook_subscriptions and webhook_deliveries tables
- WebhookService: CRUD for subscriptions, Vault-backed secret storage, delivery history
- WebhookDeliveryWorker: Bull queue, HMAC-SHA256 signatures, exponential backoff,
  SSRF protection (RFC 1918 + loopback + link-local rejection), dead-letter handling
- EventPublisher: publishes 10 event types (agent/credential/token lifecycle);
  optional Kafka adapter activated via KAFKA_BROKERS env var
- AgentService, CredentialService, OAuth2Service: wired to EventPublisher
- WebhookController + routes: 6 endpoints with webhooks:read / webhooks:write scope guards
- KafkaAdapter: optional Kafka producer (kafkajs), no-op when KAFKA_BROKERS unset
- OAuthScope extended: webhooks:read, webhooks:write
- AuditAction extended: webhook.created, webhook.updated, webhook.deleted
- Metrics: agentidp_webhook_dead_letters_total counter added to registry
- 523 unit tests passing; TypeScript strict throughout, zero `any`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:07:41 +00:00
SentryAgent.ai Developer
03b5de300c feat(phase-3): workstream 4 — AGNTCY Federation
Implements cross-IdP token verification for the AGNTCY ecosystem:

- Migration 015: federation_partners table (issuer, jwks_uri,
  allowed_organizations JSONB, status, expires_at)
- FederationService: registerPartner (JWKS validation at registration),
  listPartners, getPartner, updatePartner, deletePartner,
  verifyFederatedToken (alg:none rejected, RS256/ES256 only,
  allowedOrganizations filter, expiry enforcement)
- JWKS caching in Redis (TTL: FEDERATION_JWKS_CACHE_TTL_SECONDS);
  cache invalidated on partner delete and jwks_uri change
- FederationController + routes: 5 admin:orgs endpoints +
  POST /federation/verify (agents:read)
- OPA policy: 5 federation admin endpoint → admin:orgs mappings
- 499 unit tests passing; 94.69% statement coverage on FederationService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:13:49 +00:00
SentryAgent.ai Developer
5e465e596a feat(phase-3): workstream 3 — OpenID Connect (OIDC) Provider
Implements full OIDC layer on top of the existing OAuth 2.0 token service:

- Migration 014: oidc_keys table (RSA/EC key pairs, is_current flag, expires_at
  for rotation grace period)
- OIDCKeyService: key generation (RS256/ES256), Vault storage, JWKS with Redis
  cache, key rotation with grace period, pruneExpiredKeys
- IDTokenService: buildIDTokenClaims (agent claims, nonce, DID), signIDToken
  (kid in JWT header), verifyIDToken (alg:none rejected, RS256/ES256 only)
- OIDCController: discovery document, JWKS (Cache-Control), /agent-info
- OIDC routes mounted at / — /.well-known/openid-configuration,
  /.well-known/jwks.json, /agent-info
- OAuth2Service: id_token appended to token response when openid scope requested
- 473 unit tests passing (100% OIDCKeyService stmts, 95.91% IDTokenService stmts)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:54:26 +00:00
SentryAgent.ai Developer
3d1fff15f6 feat(phase-3): workstream 2 — W3C DIDs
Implements W3C DID Core 1.0 per-agent identity for every registered agent:

Schema:
- agent_did_keys table: stores EC P-256 public key JWK + Vault path for private key
- agents.did + agents.did_created_at columns

Key management:
- EC P-256 key pair generated on every agent registration via Node.js crypto
- Private key stored in Vault KV v2 (dev:no-vault marker when Vault not configured)
- Public key JWK stored in PostgreSQL agent_did_keys table

API (4 new endpoints):
- GET /.well-known/did.json — instance DID Document (public, cached)
- GET /api/v1/agents/:id/did — per-agent DID Document (public, 410 for decommissioned)
- GET /api/v1/agents/:id/did/resolve — W3C DID Resolution result (agents:read scope)
- GET /api/v1/agents/:id/did/card — AGNTCY agent card (public)

Implementation:
- DIDService: DID construction, key generation, Redis caching (TTL configurable)
- DIDController: 410 Gone for decommissioned agents, correct Content-Type on resolve
- AgentService: calls DIDService.generateDIDForAgent on every new registration

Tests: 429 passing, DIDService 98.93% coverage, private key absence verified in all responses

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:47:59 +00:00
SentryAgent.ai Developer
d252097f71 feat(phase-3): workstream 1 — Multi-Tenancy
Introduces full multi-tenant organization model to AgentIdP:

Schema:
- 6 migrations: organizations + organization_members tables; organization_id FK
  added to agents, credentials, audit_logs; PostgreSQL RLS policies on all three
  tables; system org seed + backfill

API:
- 6 new /api/v1/organizations endpoints (CRUD + members) gated by admin:orgs scope
- OPA scopes.json updated with 6 new org endpoint → admin:orgs mappings

Implementation:
- OrgRepository, OrgService, OrgController, createOrgsRouter
- OrgContextMiddleware: sets app.organization_id session variable so RLS enforces
  per-request org isolation at the database layer
- JWT payload extended with organization_id claim; auth.ts backfills org_system
  for backward-compatible tokens
- New error classes: OrgNotFoundError, OrgHasActiveAgentsError, AlreadyMemberError

Tests: 373 passing, 80.64% branch coverage, zero any types

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 00:29:32 +00:00
SentryAgent.ai Developer
cb7d079ef6 feat(openspec): Phase 3 Enterprise — proposal, design, specs, and tasks
Scaffolds the phase-3-enterprise OpenSpec change (proposal only — awaiting CEO
approval before implementation). 6 workstreams, 95 implementation tasks:

WS1: Multi-Tenancy (21 tasks) — org model, RLS, admin API
WS2: W3C DIDs (12 tasks) — DID:WEB, agent DID documents, AGNTCY cards
WS3: OIDC (12 tasks) — oidc-provider, ID tokens, JWKS, discovery
WS4: Federation (11 tasks) — cross-instance trust, JWT assertions
WS5: Webhooks (17 tasks) — subscriptions, Bull queue, HMAC, retry
WS6: SOC2 (22 tasks) — encryption at rest, Merkle audit chain, controls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:53:31 +00:00
SentryAgent.ai Developer
d42c653eea chore(openspec): archive engineering-docs and phase-2-production-ready changes
- engineering-docs → archive/2026-03-29-engineering-docs (63/63 tasks complete)
- phase-2-production-ready → archive/2026-03-29-phase-2-production-ready (89/89 tasks complete)
- openspec/specs/ synced with all Phase 1 + Phase 2 + engineering-docs capabilities (22 specs total)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:41:53 +00:00
SentryAgent.ai Developer
eced5f8699 docs: engineering knowledge base for new hires
Complete docs/engineering/ suite — 12 documents covering company overview,
system architecture, tech stack ADRs, codebase structure, service deep dives,
annotated code walkthroughs, dev setup, engineering workflow, testing strategy,
deployment/ops, SDK guide, and README index. All content verified against
source files. All 82 tasks in openspec/changes/engineering-docs/tasks.md
marked complete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 12:38:42 +00:00
SentryAgent.ai Developer
1f95cfe89d release: Phase 2 — Production-Ready AgentIdP
Merges all 8 Phase 2 workstreams from develop into main.

Workstreams delivered:
- WS1: HashiCorp Vault credential storage
- WS2: Python SDK (sentryagent-idp)
- WS3: Go SDK (github.com/sentryagent/idp-sdk-go)
- WS4: Java SDK (ai.sentryagent:idp-sdk)
- WS5: OPA Policy Engine (hot-reloadable authz, Rego + Wasm)
- WS6: Web Dashboard UI (React 18 + Vite 5, 6 pages)
- WS7: Prometheus + Grafana Monitoring (7 metrics, auto-provisioned dashboard)
- WS8: Multi-Region Terraform Deployment (AWS ECS/RDS/ElastiCache + GCP Cloud Run/SQL/Memorystore)

Quality gates: 344/344 unit tests passing, 96.71% coverage, TypeScript strict throughout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 06:27:09 +00:00
SentryAgent.ai Developer
6913d62648 feat(phase-2): workstream 8 — Multi-Region Terraform Deployment
AWS environment:
- VPC (3-AZ, public + private subnets, NAT gateways, VPC endpoints for ECR/SM/CW)
- ECS Fargate service (sentryagent/agentidp) — secrets from Secrets Manager
- RDS PostgreSQL 14 (Multi-AZ, encrypted, VPC-internal, storage autoscaling)
- ElastiCache Redis 7 (primary + replica, at-rest + in-transit encryption)
- ALB with HTTPS/443, HTTP→HTTPS redirect, ACM certificate
- Route 53 alias record

GCP environment:
- VPC + private services access + Serverless VPC connector
- Cloud Run service — secrets from Secret Manager
- Cloud SQL PostgreSQL 14 (private IP, no public endpoint)
- Cloud Memorystore Redis 7 (VPC-internal, AUTH enabled)

Shared:
- 4 reusable modules: agentidp (dual AWS/GCP), rds, redis, lb
- No hardcoded secrets; all sensitive vars marked sensitive=true
- terraform.tfvars.example for both environments
- docs/devops/deployment.md — AWS + GCP step-by-step walkthrough, rollback procedures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 06:25:14 +00:00
SentryAgent.ai Developer
a504964e5f feat(phase-2): workstream 7 — Prometheus + Grafana Monitoring
- Add prom-client 15; shared registry in src/metrics/registry.ts (7 metrics)
- HTTP request counter + duration histogram via metricsMiddleware
- DB query duration histogram wrapping pg Pool.query
- Redis command duration histogram via typed instrumentRedisMethod wrapper
- agentidp_tokens_issued_total in OAuth2Service
- agentidp_agents_registered_total in AgentService
- GET /metrics unauthenticated endpoint (Prometheus text format)
- docker-compose.monitoring.yml overlay (Prometheus + Grafana)
- Grafana auto-provisioned datasource + pre-built AgentIdP dashboard
- docs/devops/operations.md monitoring section added
- 36/36 unit tests passing, 100% coverage on new metrics code
- Fix pre-existing unused import in tests/integration/agents.test.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 06:13:41 +00:00
SentryAgent.ai Developer
7d6e248a14 feat(phase-2): workstream 6 — Web Dashboard UI
- dashboard/: Vite 5 + React 18 + TypeScript strict SPA
  - Auth: sessionStorage credentials, TokenManager validation, AuthProvider context
  - Pages: Login, Agents (search + filter), AgentDetail (suspend/reactivate),
    Credentials (generate/rotate/revoke, new secret shown once),
    AuditLog (filters + pagination), Health (PG + Redis status, 30s refresh)
  - Components: Button, Badge, ConfirmDialog, AppShell, RequireAuth
  - All destructive actions gated by ConfirmDialog
  - Zero dangerouslySetInnerHTML; sessionStorage only (OWASP compliant)
- src/routes/health.ts: unauthenticated GET /health — PG + Redis connectivity
- src/app.ts: health route + dashboard/dist/ served at /dashboard with SPA fallback
- 6 new health route tests; 308/308 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:19:18 +00:00
SentryAgent.ai Developer
7328a61c44 feat(phase-2): workstream 5 — OPA Policy Engine
- policies/authz.rego: Rego policy with path normalisation and scope enforcement
- policies/data/scopes.json: all 13 endpoint → scope mappings
- src/middleware/opa.ts: OpaMiddleware with Wasm primary path + scopes.json fallback;
  exports createOpaMiddleware() and reloadOpaPolicy() for SIGHUP hot-reload
- All four route files: opaMiddleware wired after authMiddleware
- AuditController, OAuth2Service: manual scope checks removed (now centralised in OPA)
- src/server.ts: SIGHUP handler calls reloadOpaPolicy()
- docs/devops/environment-variables.md: POLICY_DIR documented
- 38 new tests; 302/302 passing; opa.ts coverage 98.66% statements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:02:11 +00:00
SentryAgent.ai Developer
c8f916b849 release: Phase 1 MVP complete — merge develop into main
Phase 1 deliverables:
- Agent Registry, OAuth 2.0 Token, Credential Management, Audit Log services
- PostgreSQL + Redis persistence layer with full migration suite
- 14 REST API endpoints, OpenAPI 3.0 specs for all four services
- Node.js SDK (@sentryagent/idp-sdk) — all 14 endpoints, TypeScript strict, zero any
- Multi-stage Dockerfile + .dockerignore
- AGNTCY alignment documentation (6 domains mapped)
- Bedroom developer docs (quick-start, concepts, 4 guides, API reference)
- DevOps docs (architecture, env vars, database, local-dev, security, operations)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:49:39 +00:00
541 changed files with 88284 additions and 650 deletions

110
.github/actions/issue-token/README.md vendored Normal file
View File

@@ -0,0 +1,110 @@
# sentryagent/issue-token
Issues a SentryAgent.ai OAuth2 Bearer token for an existing agent from a GitHub
Actions workflow.
No long-lived API credentials are required. The action uses a GitHub-issued OIDC
token to authenticate with the SentryAgent.ai AgentIdP via `POST /oidc/token`.
The returned access token is automatically masked with `core.setSecret()` so it
never appears in plaintext in workflow logs.
## Prerequisites
### 1. Register the agent
The agent must already exist in SentryAgent.ai. If you need to create the agent
in CI, use [`sentryagent/register-agent@v1`](../register-agent/README.md) first.
### 2. Configure an OIDC Trust Policy for the agent
A trust policy linking the repository to the specific agent must be registered:
```bash
curl -X POST https://idp.sentryagent.ai/api/v1/oidc/trust-policies \
-H "Authorization: Bearer <your-admin-token>" \
-H "Content-Type: application/json" \
-d '{
"provider": "github",
"repository": "org/your-repo",
"branch": "main",
"agentId": "<agent-uuid>"
}'
```
Omit `branch` to allow any branch to issue tokens for this agent.
### 3. Grant `id-token: write` permission
The workflow must have permission to request a GitHub OIDC token:
```yaml
permissions:
id-token: write
contents: read
```
## Inputs
| Input | Required | Description |
|-------|----------|-------------|
| `api-url` | Yes | Base URL of the SentryAgent.ai API (e.g. `https://idp.sentryagent.ai`) |
| `agent-id` | Yes | UUID of the agent for which to issue an access token |
## Outputs
| Output | Description |
|--------|-------------|
| `access-token` | Short-lived Bearer token. Masked in all log output. |
| `expires-at` | ISO 8601 timestamp indicating when the token expires. |
## Example workflow
```yaml
name: Deploy with Agent Token
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Issue SentryAgent access token
id: token
uses: sentryagent/issue-token@v1
with:
api-url: https://idp.sentryagent.ai
agent-id: ${{ vars.SENTRY_AGENT_ID }}
- name: Call authenticated API
run: |
curl -H "Authorization: Bearer ${{ steps.token.outputs.access-token }}" \
https://my-service.example.com/deploy
```
## Troubleshooting
**HTTP 403 — Trust policy violation**
No trust policy exists for this repository + agent combination. Register a trust
policy using the Prerequisites steps above.
**HTTP 403 — Branch not permitted**
A trust policy exists but specifies a branch constraint that does not match the
current workflow's branch. Add a policy for the current branch, or remove the
branch constraint to allow all branches.
**Failed to obtain a GitHub OIDC token**
Ensure `id-token: write` is set in the workflow's `permissions` block.
**Token expires too quickly**
The default token TTL is set by the SentryAgent.ai server configuration. Check
`expires-at` and re-issue a token before it expires if your workflow is long-running.
## Full documentation
[https://docs.sentryagent.ai/github-actions](https://docs.sentryagent.ai/github-actions)

153
.github/actions/issue-token/action.js vendored Normal file
View File

@@ -0,0 +1,153 @@
/**
* issue-token GitHub Action script.
*
* Flow:
* 1. Request a GitHub OIDC token via @actions/core.getIDToken()
* 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token
* 3. Set outputs: access-token (masked) and expires-at (ISO 8601)
*
* The access token is immediately registered with core.setSecret() so it never
* appears in plaintext in workflow logs.
*
* Error handling:
* - OIDC exchange failures emit a clear message with a link to the trust policy setup docs
*/
'use strict';
const core = require('@actions/core');
const { HttpClient } = require('@actions/http-client');
/**
* Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token for a specific agent.
*
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
* @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken().
* @param {string} agentId - UUID of the agent for which to issue a token.
* @returns {Promise<{ accessToken: string; expiresIn: number }>} The access token and its TTL in seconds.
* @throws {Error} If the exchange fails, with a message including trust policy setup instructions.
*/
async function exchangeOIDCToken(apiUrl, oidcToken, agentId) {
const client = new HttpClient('sentryagent-issue-token/1.0');
const url = `${apiUrl}/api/v1/oidc/token`;
const body = JSON.stringify({
provider: 'github',
token: oidcToken,
agentId,
});
let response;
try {
response = await client.post(url, body, {
'Content-Type': 'application/json',
Accept: 'application/json',
});
} catch (err) {
throw new Error(
`Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` +
`Check that the api-url input is correct and the API is reachable.\n` +
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const rawBody = await response.readBody();
const statusCode = response.message.statusCode ?? 0;
if (statusCode === 403) {
throw new Error(
'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' +
'This usually means no trust policy has been registered for this repository.\n\n' +
'To fix this, register a trust policy by calling:\n' +
` POST ${apiUrl}/oidc/trust-policies\n` +
' Body: { "provider": "github", "repository": "org/repo", "agentId": "<agent-id>" }\n\n' +
'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
if (statusCode < 200 || statusCode >= 300) {
let detail = rawBody;
try {
const parsed = JSON.parse(rawBody);
detail = parsed.message ?? parsed.error_description ?? rawBody;
} catch {
// use rawBody as-is
}
throw new Error(
`OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` +
'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
let tokenData;
try {
tokenData = JSON.parse(rawBody);
} catch {
throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`);
}
if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) {
throw new Error('OIDC token exchange response did not include an access_token.');
}
const expiresIn = typeof tokenData.expires_in === 'number' ? tokenData.expires_in : 3600;
return { accessToken: tokenData.access_token, expiresIn };
}
/**
* Computes an ISO 8601 expiry timestamp from a TTL in seconds.
*
* @param {number} expiresInSeconds - Number of seconds until the token expires.
* @returns {string} ISO 8601 timestamp string.
*/
function computeExpiresAt(expiresInSeconds) {
return new Date(Date.now() + expiresInSeconds * 1000).toISOString();
}
/**
* Main entry point for the issue-token GitHub Action.
*
* @returns {Promise<void>}
*/
async function run() {
try {
// Read inputs
const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, '');
const agentId = core.getInput('agent-id', { required: true });
core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`);
let oidcToken;
try {
oidcToken = await core.getIDToken(apiUrl);
} catch (err) {
throw new Error(
'Failed to obtain a GitHub OIDC token. ' +
"Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" +
'Example:\n' +
'permissions:\n' +
' id-token: write\n' +
' contents: read\n\n' +
`Underlying error: ${err instanceof Error ? err.message : String(err)}\n` +
'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
core.info(`Exchanging GitHub OIDC token for SentryAgent.ai access token (agent: ${agentId})...`);
const { accessToken, expiresIn } = await exchangeOIDCToken(apiUrl, oidcToken, agentId);
// Mask the token immediately — must happen before any logging or output
core.setSecret(accessToken);
const expiresAt = computeExpiresAt(expiresIn);
core.setOutput('access-token', accessToken);
core.setOutput('expires-at', expiresAt);
core.info(`Access token issued successfully. Expires at: ${expiresAt}`);
} catch (err) {
core.setFailed(err instanceof Error ? err.message : String(err));
}
}
run();

37
.github/actions/issue-token/action.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: 'SentryAgent Issue Token'
description: >
Issues a SentryAgent.ai OAuth2 access token for an agent using GitHub OIDC
token exchange. No long-lived API credentials required. The issued access
token is automatically masked in GitHub Actions logs via core.setSecret().
author: 'SentryAgent.ai'
branding:
icon: 'key'
color: 'blue'
inputs:
api-url:
description: >
Base URL of the SentryAgent.ai AgentIdP API.
Example: https://idp.sentryagent.ai
required: true
agent-id:
description: >
The UUID of the agent for which to issue an access token.
Obtain this from the register-agent action output or from the API.
required: true
outputs:
access-token:
description: >
A short-lived Bearer access token for the specified agent.
The token value is masked in all GitHub Actions log output.
expires-at:
description: >
ISO 8601 timestamp indicating when the access token expires.
Use this to decide when to re-issue a fresh token.
runs:
using: 'node20'
main: 'action.js'

View File

@@ -0,0 +1,96 @@
# sentryagent/register-agent
Registers a new AI agent in SentryAgent.ai from a GitHub Actions workflow.
No long-lived API credentials are required. The action uses a GitHub-issued OIDC
token to authenticate with the SentryAgent.ai AgentIdP via `POST /oidc/token`, then
calls `POST /agents` to create the agent.
## Prerequisites
### 1. Configure an OIDC Trust Policy
Before this action can exchange tokens, a trust policy must be registered in
SentryAgent.ai for the repository that will run the workflow.
```bash
curl -X POST https://idp.sentryagent.ai/api/v1/oidc/trust-policies \
-H "Authorization: Bearer <your-admin-token>" \
-H "Content-Type: application/json" \
-d '{
"provider": "github",
"repository": "org/your-repo",
"branch": "main"
}'
```
Omit `branch` to allow any branch to register agents from this repository.
### 2. Grant `id-token: write` permission
The workflow must have permission to request a GitHub OIDC token:
```yaml
permissions:
id-token: write
contents: read
```
## Inputs
| Input | Required | Description |
|-------|----------|-------------|
| `api-url` | Yes | Base URL of the SentryAgent.ai API (e.g. `https://idp.sentryagent.ai`) |
| `agent-name` | Yes | Unique name (email format) for the new agent |
| `agent-description` | No | Human-readable description of the agent's purpose |
## Outputs
| Output | Description |
|--------|-------------|
| `agent-id` | UUID of the newly registered agent. Use in subsequent steps to issue tokens or manage credentials. |
## Example workflow
```yaml
name: Register Agent
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
register:
runs-on: ubuntu-latest
steps:
- name: Register SentryAgent
id: register
uses: sentryagent/register-agent@v1
with:
api-url: https://idp.sentryagent.ai
agent-name: my-ci-agent@acme.com
agent-description: CI agent for the acme/my-repo build pipeline
- name: Print agent ID
run: echo "Registered agent ${{ steps.register.outputs.agent-id }}"
```
## Troubleshooting
**HTTP 403 — Trust policy not configured**
Register a trust policy for this repository first. See the Prerequisites section above.
**Failed to obtain a GitHub OIDC token**
Ensure `id-token: write` is set in the workflow's `permissions` block.
**Agent registration failed with HTTP 401**
The OIDC token exchange succeeded but the returned access token was rejected by
`POST /agents`. Check that the SentryAgent.ai API version matches and the
bootstrap token has `agents:write` scope.
## Full documentation
[https://docs.sentryagent.ai/github-actions](https://docs.sentryagent.ai/github-actions)

200
.github/actions/register-agent/action.js vendored Normal file
View File

@@ -0,0 +1,200 @@
/**
* register-agent GitHub Action script.
*
* Flow:
* 1. Request a GitHub OIDC token via @actions/core.getIDToken()
* 2. Exchange the OIDC token for a SentryAgent.ai access token via POST /oidc/token
* 3. Register a new agent via POST /agents using the access token
* 4. Set the `agent-id` output
*
* Error handling:
* - OIDC exchange failures emit a clear message with a link to the trust policy setup docs
* - Agent registration failures surface the API error message
*/
'use strict';
const core = require('@actions/core');
const { HttpClient, BearerCredentialHandler } = require('@actions/http-client');
/**
* Exchanges a GitHub OIDC JWT for a SentryAgent.ai access token.
*
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
* @param {string} oidcToken - GitHub OIDC JWT obtained from core.getIDToken().
* @returns {Promise<string>} The SentryAgent.ai access token.
* @throws {Error} If the exchange fails, with a message including trust policy setup instructions.
*/
async function exchangeOIDCToken(apiUrl, oidcToken) {
const client = new HttpClient('sentryagent-register-agent/1.0');
const url = `${apiUrl}/api/v1/oidc/token`;
const body = JSON.stringify({
provider: 'github',
token: oidcToken,
});
let response;
try {
response = await client.post(url, body, {
'Content-Type': 'application/json',
Accept: 'application/json',
});
} catch (err) {
throw new Error(
`Failed to reach the SentryAgent.ai OIDC token endpoint at ${url}. ` +
`Check that the api-url input is correct and the API is reachable.\n` +
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const rawBody = await response.readBody();
const statusCode = response.message.statusCode ?? 0;
if (statusCode === 403) {
throw new Error(
'GitHub OIDC token exchange was rejected with HTTP 403 (Forbidden). ' +
'This usually means no trust policy has been registered for this repository.\n\n' +
'To fix this, register a trust policy by calling:\n' +
` POST ${apiUrl}/oidc/trust-policies\n` +
' Body: { "provider": "github", "repository": "org/repo", "agentId": "<agent-id>" }\n\n' +
'For full setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
if (statusCode < 200 || statusCode >= 300) {
let detail = rawBody;
try {
const parsed = JSON.parse(rawBody);
detail = parsed.message ?? parsed.error_description ?? rawBody;
} catch {
// use rawBody as-is
}
throw new Error(
`OIDC token exchange failed with HTTP ${statusCode}: ${detail}\n` +
'For trust policy setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
let tokenData;
try {
tokenData = JSON.parse(rawBody);
} catch {
throw new Error(`OIDC token exchange returned non-JSON response: ${rawBody}`);
}
if (typeof tokenData.access_token !== 'string' || tokenData.access_token.length === 0) {
throw new Error('OIDC token exchange response did not include an access_token.');
}
return tokenData.access_token;
}
/**
* Registers a new agent via POST /agents.
*
* @param {string} apiUrl - Base URL of the SentryAgent.ai AgentIdP API.
* @param {string} accessToken - A valid SentryAgent.ai Bearer access token.
* @param {string} agentName - Email (unique name) for the new agent.
* @param {string} agentDescription - Optional description stored as the owner field.
* @returns {Promise<string>} The UUID of the newly registered agent.
* @throws {Error} If the API returns a non-2xx response.
*/
async function registerAgent(apiUrl, accessToken, agentName, agentDescription) {
const auth = new BearerCredentialHandler(accessToken);
const client = new HttpClient('sentryagent-register-agent/1.0', [auth]);
const url = `${apiUrl}/api/v1/agents`;
const payload = {
email: agentName,
agentType: 'custom',
version: '1.0.0',
capabilities: [],
owner: agentDescription || agentName,
deploymentEnv: 'production',
};
let response;
try {
response = await client.post(url, JSON.stringify(payload), {
'Content-Type': 'application/json',
Accept: 'application/json',
});
} catch (err) {
throw new Error(
`Failed to reach the SentryAgent.ai agents endpoint at ${url}.\n` +
`Underlying error: ${err instanceof Error ? err.message : String(err)}`,
);
}
const rawBody = await response.readBody();
const statusCode = response.message.statusCode ?? 0;
if (statusCode < 200 || statusCode >= 300) {
let detail = rawBody;
try {
const parsed = JSON.parse(rawBody);
detail = parsed.message ?? parsed.error ?? rawBody;
} catch {
// use rawBody as-is
}
throw new Error(`Agent registration failed with HTTP ${statusCode}: ${detail}`);
}
let agentData;
try {
agentData = JSON.parse(rawBody);
} catch {
throw new Error(`Agent registration returned non-JSON response: ${rawBody}`);
}
if (typeof agentData.agentId !== 'string' || agentData.agentId.length === 0) {
throw new Error('Agent registration response did not include an agentId.');
}
return agentData.agentId;
}
/**
* Main entry point for the register-agent GitHub Action.
*
* @returns {Promise<void>}
*/
async function run() {
try {
// Read inputs
const apiUrl = core.getInput('api-url', { required: true }).replace(/\/$/, '');
const agentName = core.getInput('agent-name', { required: true });
const agentDescription = core.getInput('agent-description') || '';
core.info(`Requesting GitHub OIDC token for audience: ${apiUrl}`);
let oidcToken;
try {
oidcToken = await core.getIDToken(apiUrl);
} catch (err) {
throw new Error(
'Failed to obtain a GitHub OIDC token. ' +
"Ensure the workflow has 'id-token: write' permission in its permissions block.\n\n" +
'Example:\n' +
'permissions:\n' +
' id-token: write\n' +
' contents: read\n\n' +
`Underlying error: ${err instanceof Error ? err.message : String(err)}\n` +
'For setup instructions, visit: https://docs.sentryagent.ai/github-actions#trust-policy',
);
}
core.info('Exchanging GitHub OIDC token for SentryAgent.ai access token...');
const accessToken = await exchangeOIDCToken(apiUrl, oidcToken);
core.info(`Registering agent: ${agentName}`);
const agentId = await registerAgent(apiUrl, accessToken, agentName, agentDescription);
core.setOutput('agent-id', agentId);
core.info(`Agent registered successfully. agent-id: ${agentId}`);
} catch (err) {
core.setFailed(err instanceof Error ? err.message : String(err));
}
}
run();

View File

@@ -0,0 +1,39 @@
name: 'SentryAgent Register Agent'
description: >
Registers a new agent in SentryAgent.ai using GitHub OIDC token exchange.
No long-lived API credentials required — the GitHub Actions OIDC token is
exchanged for a short-lived SentryAgent.ai access token to call POST /agents.
author: 'SentryAgent.ai'
branding:
icon: 'shield'
color: 'blue'
inputs:
api-url:
description: >
Base URL of the SentryAgent.ai AgentIdP API.
Example: https://idp.sentryagent.ai
required: true
agent-name:
description: >
Unique name (email) for the agent being registered.
Must be a valid email address format used as the agent identity.
required: true
agent-description:
description: >
Optional human-readable description of the agent's purpose.
Stored as the agent owner field.
required: false
default: ''
outputs:
agent-id:
description: >
The UUID of the newly registered agent.
Use in subsequent steps to issue tokens or manage credentials.
runs:
using: 'node20'
main: 'action.js'

14
.gitignore vendored
View File

@@ -5,3 +5,17 @@ coverage/
.env.* .env.*
*.log *.log
.DS_Store .DS_Store
# Next.js build output
portal/.next/
portal/node_modules/
portal/tsconfig.tsbuildinfo
# Agent workspace directories
.cto-workspace/
.validator-workspace/
# Session artifacts
conversation_backup.txt
next_steps.md
vj_notes/

View File

@@ -8,7 +8,7 @@ This is a PRIVATE project session for SentryAgent.ai.
## STARTUP PROTOCOL (Required on every new session) ## STARTUP PROTOCOL (Required on every new session)
On startup, Claude MUST (in order): On startup, Claude MUST (in order):
1. Read `/README.md` in full before any action 1. Read `/README.md` in full before any action — this is the project PRD (Product Requirements Document) and single source of truth
2. Register with central hub as `CEO-Session` 2. Register with central hub as `CEO-Session`
3. Check `#vpe-cto-approvals` for any pending CTO messages 3. Check `#vpe-cto-approvals` for any pending CTO messages
4. Identify current phase and sprint status 4. Identify current phase and sprint status
@@ -37,6 +37,8 @@ The Virtual CTO runs as a SEPARATE Claude Code instance.
**Channel guide:** **Channel guide:**
- `#vpe-cto-approvals` — CEO ↔ CTO communication, approvals, status reports (only channel CEO uses) - `#vpe-cto-approvals` — CEO ↔ CTO communication, approvals, status reports (only channel CEO uses)
- `#vv-cto-resolution` — Lead Validator ↔ CTO direct channel for V&V findings and resolution. CEO is NOT part of this channel unless escalated after two failed resolution rounds.
- `#vv-findings` — Informational V&V status log (read-only reference for CEO)
## VIRTUAL ENGINEERING TEAM ROLES ## VIRTUAL ENGINEERING TEAM ROLES
Claude operates as a Virtual Engineering Team — NOT as a chatbot. Claude operates as a Virtual Engineering Team — NOT as a chatbot.

View File

@@ -6,6 +6,7 @@
**Git Repository**: https://git.sentryagent.ai/ **Git Repository**: https://git.sentryagent.ai/
**AI Partner**: Anthropic (Claude — All Development, Implementation & Deployment) **AI Partner**: Anthropic (Claude — All Development, Implementation & Deployment)
**Standards**: AGNTCY (Linux Foundation), OpenAPI 3.0, OAuth 2.0, OIDC **Standards**: AGNTCY (Linux Foundation), OpenAPI 3.0, OAuth 2.0, OIDC
**Document Role**: Product Requirements Document (PRD) — this file is the single source of truth for all product requirements, scope, and standards
**Last Updated**: 2026-03-28 **Last Updated**: 2026-03-28
**Status**: ? Active — Phase 1 MVP **Status**: ? Active — Phase 1 MVP

275
VALIDATOR.md Normal file
View File

@@ -0,0 +1,275 @@
# SentryAgent.ai — V&V Architect (Lead Validator)
## IDENTITY & INDEPENDENCE
You are the **V&V Architect (Lead Validator)** for SentryAgent.ai AgentIdP.
- **Instance ID:** `LeadValidator`
- **Role:** Independent verification and validation — you are NOT part of the engineering team
- **Authority:** You report findings directly to the CEO. The CTO has no authority to dismiss your findings.
- **Mandate:** Ensure that everything the engineering team built actually matches what was specified in the PRD and OpenSpec
- **Isolation:** Do NOT carry context from any other project or session. This is a private, independent audit session.
You are a check on the system — not a builder. You never implement features, never approve architectural changes, and never take direction from the Virtual CTO. Your only job is to find gaps, deviations, and violations and formally log them.
---
## STARTUP PROTOCOL (Execute on every new session — no exceptions)
Execute these steps in order before doing anything else:
### Step 1 — Read the source of truth
Read `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/README.md` in full.
This is the PRD. Everything the engineering team built must conform to it.
### Step 2 — Register on central hub
Register as `LeadValidator` on the central hub.
### Step 3 — Check existing open issues
Read all files in `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/openspec/vv_audit/` — this is your ledger.
List any issues currently with status `OPEN` or `DISPUTED`.
### Step 4 — Check #vv-findings channel
Check the `#vv-findings` channel on the central hub for any recent messages from the CTO regarding issue resolution or disputes.
### Step 5 — Report readiness to CEO
Post a status message to `#vv-findings` channel:
- How many open/disputed issues exist
- Whether you are performing a fresh audit or continuing an existing one
- What you plan to audit this session
### Step 6 — Begin audit
Execute the audit methodology below.
---
## AUDIT METHODOLOGY
### Phase A — OpenSpec Completeness Check
For every archived OpenSpec change, verify the tasks were fully implemented.
**Archived changes location:** `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/openspec/changes/archive/`
For each archived change:
1. Read its `tasks.md`
2. All tasks marked `[x]` — verify the corresponding code actually exists and matches the task description
3. Any task marked `[ ]` — this is a BLOCKER finding (incomplete implementation)
### Phase B — API Surface Audit
Verify every API endpoint has a corresponding OpenAPI spec.
**OpenAPI specs location:** `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/docs/openapi/`
For every route registered in `src/routes/` and `src/app.ts`:
1. Confirm there is an OpenAPI spec entry covering that endpoint
2. Confirm the spec matches the implementation (method, path, request schema, response schemas, auth requirement)
3. Any endpoint without a spec → BLOCKER
4. Any endpoint where spec and implementation diverge → MAJOR
### Phase C — TypeScript Standards Audit
Read source files in `src/` and verify:
1. No `any` types used anywhere — search for `: any`, `as any`, `<any>`
2. All public classes and methods have JSDoc comments
3. `tsconfig.json` has `"strict": true` and all strict flags enabled
4. Custom error hierarchy: all errors extend `SentryAgentError`
Violations:
- `any` type usage → MAJOR per occurrence
- Missing JSDoc on public methods → MINOR per file
- Disabled strict flags → BLOCKER
### Phase D — DRY Principle Audit
Search for code duplication:
1. Look for identical or near-identical logic blocks across files
2. Check that all crypto operations live in `src/utils/crypto.ts`
3. Check that all JWT operations live in `src/utils/jwt.ts`
4. Check that all validation logic lives in `src/utils/validators.ts`
5. Check that all error classes live in `src/utils/errors.ts` or `src/errors/`
6. Check that no controller directly accesses the database (must go through services)
Violations: DRY violation → MAJOR (BLOCKER if in a critical path)
### Phase E — SOLID Principles Audit
Spot-check key services:
1. `AgentService` — does agent CRUD only (no token logic, no audit logic)
2. `OAuth2Service` — does token issuance only (no agent CRUD, no billing)
3. `CredentialService` — does credential management only
4. `AuditService` — does audit logging only
5. All services use constructor injection (no direct `new Dependency()` inside business logic)
6. Services depend on interfaces/abstractions, not concrete implementations
Violations: SRP violation → MAJOR
### Phase F — Test Coverage Audit
Check test completeness:
1. Every service in `src/services/` has a corresponding test in `tests/`
2. Every API route has integration tests
3. Run `npm test -- --coverage` and check that overall coverage is >80%
4. Check that edge cases are covered: null inputs, invalid inputs, auth failures, rate limits
Violations:
- Coverage <80% → BLOCKER
- Missing integration test for an endpoint → MAJOR
- Missing edge case tests → MINOR
### Phase G — AGNTCY Compliance Audit
Verify AGNTCY alignment (per PRD Section 3.1 and Phase 3 scope):
1. Agents have unique, immutable IDs
2. Authentication uses OAuth 2.0 Client Credentials flow
3. Authorization uses scope-based access control
4. Audit logs are immutable
5. Agent lifecycle operations (provision, rotate, revoke) are fully implemented
6. W3C DID support implemented (Phase 3 deliverable)
7. AGNTCY conformance tests pass (see `tests/agntcy-conformance/`)
Violations: AGNTCY deviation → BLOCKER
### Phase H — Security Audit
Scan for OWASP Top 10 vulnerabilities:
1. SQL injection — all DB queries use parameterized statements
2. Authentication bypass — all protected routes have auth middleware
3. Sensitive data exposure — no secrets in logs or error responses
4. Broken access control — tenant isolation enforced on all queries
5. Security headers — helmet middleware applied
6. Rate limiting — enforced on token endpoints
Violations: Security finding → BLOCKER
---
## ISSUE FORMAT
Every finding is written as a file in the shared ledger:
`/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/openspec/vv_audit/`
**Filename:** `VV_ISSUE_<NNN>.md` (zero-padded, e.g., `VV_ISSUE_001.md`)
**File template:**
```markdown
# VV_ISSUE_<NNN> — <Short title>
**Status:** OPEN | RESOLVED | DISPUTED
**Severity:** BLOCKER | MAJOR | MINOR
**Category:** SPEC_DEVIATION | DRY_VIOLATION | TYPE_VIOLATION | SOLID_VIOLATION | TEST_GAP | SECURITY | AGNTCY | DOCS
**Logged by:** LeadValidator
**Date:** <ISO date>
**Audit phase:** Phase AH label
## Finding
<Clear description of what is wrong>
## Evidence
<File path(s) and line numbers where the violation exists>
## Required Action
<What must be done to resolve this finding>
## CTO Response
<Leave blank — CTO fills this in>
## Resolution
<Leave blank — filled on resolution>
```
---
## SEVERITY DEFINITIONS
| Severity | Definition | Who can close |
|----------|-----------|---------------|
| **BLOCKER** | Prevents release. PRD requirement missing, security vulnerability, <80% test coverage, spec-implementation mismatch on a core feature | CTO resolves, Validator confirms. CEO notified only if CTO and Validator cannot agree. |
| **MAJOR** | Significant deviation from standards. `any` types, DRY violation, missing integration test, SOLID violation | CTO resolves, Validator confirms |
| **MINOR** | Standards improvement. Missing JSDoc, minor duplication, cosmetic spec gap | CTO resolves, no confirmation needed |
---
## COMMUNICATION PROTOCOL
### Primary channel: #vv-cto-resolution (Lead Validator ↔ CTO)
All findings — routine, MAJOR, and BLOCKER — go to `#vv-cto-resolution` first.
The CTO is responsible for reviewing and resolving all findings with the engineering team.
The Lead Validator confirms resolution in the same channel.
**Do NOT post findings to `#vpe-cto-approvals` (CEO channel) unless escalation is required (see below).**
### Routine findings
After each audit phase, post a summary to `#vv-cto-resolution`:
- Phase completed
- Number of issues found (BLOCKER / MAJOR / MINOR)
- Issue file names
### BLOCKER findings
Post immediately to `#vv-cto-resolution` with full finding detail.
The CTO must acknowledge and provide a resolution plan within the same session.
**CEO is NOT notified of BLOCKERs by default — the CTO owns resolution.**
### Disputes
If the CTO marks an issue as `DISPUTED`:
1. Read the CTO's technical justification in the issue file
2. Evaluate whether the justification is valid against the PRD
3. If you accept the justification → change status to `RESOLVED`, note reason in `#vv-cto-resolution`
4. If you reject the justification → change status back to `OPEN`, add your counter-argument in `#vv-cto-resolution`, and attempt a second round of resolution with the CTO
5. **Only if two rounds of resolution fail** → escalate to `#vpe-cto-approvals` for CEO decision, with a clear summary of both positions
### CEO escalation (last resort only)
Escalate to `#vpe-cto-approvals` ONLY when:
- CTO and Lead Validator have attempted resolution and remain deadlocked after two rounds
- Include: issue ID, CTO's position, Lead Validator's position, and why they are irreconcilable
### Session close
When you have completed your audit session, post a final summary to `#vv-cto-resolution`:
- Total issues logged this session
- Breakdown by severity
- Overall V&V status: PASS (0 BLOCKERs) | BLOCKED (≥1 BLOCKER open)
Also post a brief one-line status to `#vv-findings` for informational tracking.
---
## AUDIT LEDGER INDEX
After each session, update `/home/ubuntu/vj_ai_agents_dev/sentryagent-idp/openspec/vv_audit/LEDGER.md`:
- Total issues logged to date
- Open / Resolved / Disputed counts
- Date of last audit
- Overall release gate status
---
## INDEPENDENCE PRINCIPLES
1. **You do not take orders from the CTO.** The CTO can respond to your findings in the issue file. Only the CEO can instruct you to drop a BLOCKER.
2. **You do not implement fixes.** If you find a problem, you log it. The CTO's team fixes it.
3. **You do not negotiate severity.** Severity is set by the PRD requirements and these definitions. If the CTO disagrees, it becomes DISPUTED and goes to CEO.
4. **You do not skip phases.** Every audit session runs all phases, or explicitly documents why a phase was skipped.
5. **You are not adversarial.** Your goal is product quality, not finding fault. A clean audit is a success.
---
## STANDARDS REFERENCE (from PRD Section 6)
| Standard | Requirement |
|----------|------------|
| TypeScript | Strict mode, zero `any` types |
| DRY | Zero code duplication, logic lives in exactly one place |
| SOLID | Single responsibility per service, constructor injection |
| OpenAPI | Spec exists BEFORE implementation, spec matches implementation |
| Tests | >80% coverage, all endpoints integration-tested |
| JSDoc | All public classes and methods documented |
| Errors | All errors typed, extend SentryAgentError hierarchy |
| Security | No OWASP Top 10 vulnerabilities |
| AGNTCY | Full compliance with Linux Foundation agent identity standard |
| Performance | Token endpoints <100ms, all others <200ms |

348
cli/README.md Normal file
View File

@@ -0,0 +1,348 @@
# sentryagent CLI
The official command-line interface for [SentryAgent.ai](https://sentryagent.ai) — manage agents, issue OAuth2 tokens, rotate credentials, and stream audit logs from your terminal.
---
## Installation
### From npm (once published)
```bash
npm install -g sentryagent
```
### From source
```bash
cd cli/
npm install
npm run build
npm install -g .
```
---
## Configuration
Before using any command, configure the CLI with your API endpoint and credentials:
```bash
sentryagent configure
```
You will be prompted for:
| Field | Description |
|---------------|--------------------------------------------------|
| API URL | The SentryAgent.ai API base URL (e.g. `https://api.sentryagent.ai`) |
| Client ID | Your tenant client ID |
| Client Secret | Your tenant client secret |
Configuration is stored at `~/.sentryagent/config.json` with permissions `0600`.
If any command is run before `sentryagent configure` has been called, the CLI exits with:
```
Not configured. Run `sentryagent configure` first.
```
---
## Commands
### `sentryagent --version` / `-v`
Output the installed CLI version.
```bash
sentryagent --version
# 1.0.0
```
### `sentryagent --help` / `-h`
Show all available commands and global options.
```bash
sentryagent --help
```
---
### `sentryagent configure`
Interactively configure the CLI.
```bash
sentryagent configure
```
**Prompts:**
```
SentryAgent CLI Configuration
────────────────────────────────────────
API URL (e.g. https://api.sentryagent.ai): https://api.sentryagent.ai
Client ID: tenant_01ABC...
Client Secret: ****
✓ Configuration saved to ~/.sentryagent/config.json
```
---
### `sentryagent register-agent`
Register a new agent with the identity provider.
```bash
sentryagent register-agent --name <name> [--description <desc>]
```
**Options:**
| Flag | Required | Description |
|-------------------|----------|---------------------|
| `--name <name>` | Yes | Agent display name |
| `--description` | No | Agent description |
**Example:**
```bash
sentryagent register-agent --name "billing-agent" --description "Handles billing workflows"
```
**Output:**
```
✓ Agent registered successfully
Agent ID: 01ARZ3NDEKTSV4RRFFQ69G5FAV
Name: billing-agent
Description: Handles billing workflows
Status: active
```
---
### `sentryagent list-agents`
List all agents registered for your tenant, displayed as a formatted table.
```bash
sentryagent list-agents
```
**Output:**
```
AGENT ID NAME STATUS CREATED AT
────────────────────────────────────────────────────────────────────────────
01ARZ3NDEKTSV4RRFFQ69G5FAV billing-agent active 4/2/2026, 9:00:00 AM
01ARZ3NDEKTSV4RRFFQ69G5FAX auth-agent active 4/1/2026, 3:00:00 PM
────────────────────────────────────────────────────────────────────────────
Total: 2
```
---
### `sentryagent issue-token`
Issue an OAuth2 `client_credentials` access token for a specific agent.
```bash
sentryagent issue-token --agent-id <id>
```
**Options:**
| Flag | Required | Description |
|--------------------|----------|-------------------------|
| `--agent-id <id>` | Yes | Target agent ID |
**Example:**
```bash
sentryagent issue-token --agent-id 01ARZ3NDEKTSV4RRFFQ69G5FAV
```
**Output:**
```
✓ Token issued successfully
Access Token:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
Token Type: Bearer
Expires In: 3600s
Expires At: 2026-04-02T10:00:00.000Z
```
---
### `sentryagent rotate-credentials`
Rotate the client secret for an agent. Prompts for confirmation before proceeding.
```bash
sentryagent rotate-credentials --agent-id <id>
```
**Options:**
| Flag | Required | Description |
|--------------------|----------|-------------------------|
| `--agent-id <id>` | Yes | Target agent ID |
**Example:**
```bash
sentryagent rotate-credentials --agent-id 01ARZ3NDEKTSV4RRFFQ69G5FAV
```
**Output:**
```
⚠ This will invalidate the current secret for agent 01ARZ3NDEKTSV4RRFFQ69G5FAV
This will invalidate the current secret. Continue? [y/N] y
✓ Credentials rotated successfully
Client ID: 01ARZ3NDEKTSV4RRFFQ69G5FAV
Client Secret: cs_new_secret_value_here
Store the new client secret securely — it will not be shown again.
```
---
### `sentryagent tail-audit-log`
Poll the audit log API every 5 seconds and stream new events to stdout. Press **Ctrl+C** to stop.
```bash
sentryagent tail-audit-log [--agent-id <id>]
```
**Options:**
| Flag | Required | Description |
|--------------------|----------|------------------------------------|
| `--agent-id <id>` | No | Filter events for a specific agent |
**Example (all events):**
```bash
sentryagent tail-audit-log
```
**Example (filtered by agent):**
```bash
sentryagent tail-audit-log --agent-id 01ARZ3NDEKTSV4RRFFQ69G5FAV
```
**Output:**
```
Tailing audit log — press Ctrl+C to stop
────────────────────────────────────────────────────────────
4/2/2026, 9:05:00 AM agent.token.issued outcome=success agent=01ARZ3NDEKTSV... id=evt_01...
4/2/2026, 9:10:03 AM agent.registered outcome=success id=evt_02...
^C
Stopped.
```
---
### `sentryagent completion`
Output shell completion scripts.
#### Bash
```bash
sentryagent completion bash
```
To enable permanently, add to `~/.bashrc` or `~/.bash_profile`:
```bash
source <(sentryagent completion bash)
```
Or write to a file:
```bash
sentryagent completion bash > ~/.bash_completion.d/sentryagent
```
#### Zsh
```bash
sentryagent completion zsh
```
To enable permanently, add to `~/.zshrc`:
```bash
source <(sentryagent completion zsh)
```
Or write to a file in your `$fpath`:
```bash
sentryagent completion zsh > ~/.zsh/completions/_sentryagent
```
---
## Shell Completion Setup
### Bash (one-time setup)
```bash
mkdir -p ~/.bash_completion.d
sentryagent completion bash > ~/.bash_completion.d/sentryagent
echo 'source ~/.bash_completion.d/sentryagent' >> ~/.bashrc
source ~/.bashrc
```
### Zsh (one-time setup)
```bash
mkdir -p ~/.zsh/completions
sentryagent completion zsh > ~/.zsh/completions/_sentryagent
echo 'fpath=(~/.zsh/completions $fpath)' >> ~/.zshrc
echo 'autoload -Uz compinit && compinit' >> ~/.zshrc
source ~/.zshrc
```
After setup, pressing **Tab** after `sentryagent` will autocomplete commands and flags.
---
## Configuration File
The config file is stored at `~/.sentryagent/config.json`:
```json
{
"apiUrl": "https://api.sentryagent.ai",
"clientId": "tenant_01ABC...",
"clientSecret": "cs_secret_value"
}
```
The directory is created with mode `0700` and the file with mode `0600` to prevent other users from reading your credentials.
---
## Environment
- Node.js >= 18.0.0 is required (uses the built-in `fetch` API)
- All HTTP requests use OAuth2 `client_credentials` tokens fetched automatically from your configuration
- Tokens are cached in memory for the duration of the CLI session (refreshed 30 seconds before expiry)

411
cli/package-lock.json generated Normal file
View File

@@ -0,0 +1,411 @@
{
"name": "sentryagent",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sentryagent",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@types/unzipper": "^0.10.11",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"unzipper": "^0.12.3"
},
"bin": {
"sentryagent": "dist/index.js"
},
"devDependencies": {
"@types/node": "^20.12.7",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/unzipper": {
"version": "0.10.11",
"resolved": "https://registry.npmjs.org/@types/unzipper/-/unzipper-0.10.11.tgz",
"integrity": "sha512-D25im2zjyMCcgL9ag6N46+wbtJBnXIr7SI4zHf9eJD2Dw2tEB5e+p5MYkrxKIVRscs5QV0EhtU9rgXSPx90oJg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/commander": {
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
"integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
"license": "BSD-3-Clause",
"dependencies": {
"readable-stream": "^2.0.2"
}
},
"node_modules/fs-extra": {
"version": "11.3.4",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz",
"integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"license": "MIT"
},
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
},
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/unzipper": {
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
"license": "MIT",
"dependencies": {
"bluebird": "~3.7.2",
"duplexer2": "~0.1.4",
"fs-extra": "^11.2.0",
"graceful-fs": "^4.2.2",
"node-int64": "^0.4.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

36
cli/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "sentryagent",
"version": "1.0.0",
"description": "SentryAgent.ai CLI — manage agents, tokens, and audit logs",
"main": "dist/index.js",
"bin": {
"sentryagent": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"dev": "ts-node src/index.ts",
"clean": "rm -rf dist"
},
"dependencies": {
"@types/unzipper": "^0.10.11",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"unzipper": "^0.12.3"
},
"devDependencies": {
"@types/node": "^20.12.7",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"sentryagent",
"agentidp",
"cli",
"agents",
"identity"
],
"license": "MIT"
}

95
cli/src/api.ts Normal file
View File

@@ -0,0 +1,95 @@
import { Config } from './config';
interface TokenCache {
accessToken: string;
expiresAt: number;
}
let tokenCache: TokenCache | null = null;
interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
}
async function fetchToken(config: Config): Promise<string> {
const now = Date.now();
if (tokenCache !== null && tokenCache.expiresAt > now + 30_000) {
return tokenCache.accessToken;
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
});
const res = await fetch(`${config.apiUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Authentication failed (${res.status}): ${text}`);
}
const data = (await res.json()) as TokenResponse;
tokenCache = {
accessToken: data.access_token,
expiresAt: now + data.expires_in * 1000,
};
return tokenCache.accessToken;
}
export function clearTokenCache(): void {
tokenCache = null;
}
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
interface ApiRequestOptions {
method?: HttpMethod;
body?: unknown;
params?: Record<string, string>;
}
export async function apiRequest<T>(
config: Config,
endpoint: string,
options: ApiRequestOptions = {},
): Promise<T> {
const token = await fetchToken(config);
const { method = 'GET', body, params } = options;
let url = `${config.apiUrl}${endpoint}`;
if (params !== undefined && Object.keys(params).length > 0) {
const qs = new URLSearchParams(params);
url = `${url}?${qs.toString()}`;
}
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
const fetchOptions: RequestInit = { method, headers };
if (body !== undefined) {
fetchOptions.body = JSON.stringify(body);
}
const res = await fetch(url, fetchOptions);
if (!res.ok) {
const text = await res.text();
throw new Error(`API error (${res.status}): ${text}`);
}
if (res.status === 204) {
return undefined as unknown as T;
}
return (await res.json()) as T;
}

View File

@@ -0,0 +1,155 @@
import { Command } from 'commander';
const BASH_COMPLETION = `
# sentryagent bash completion
# Add to ~/.bashrc or ~/.bash_profile:
# source <(sentryagent completion bash)
_sentryagent_completion() {
local cur prev words cword
_init_completion || return
local commands="configure register-agent list-agents issue-token rotate-credentials tail-audit-log completion"
local global_opts="--help --version"
case "\${prev}" in
sentryagent)
COMPREPLY=( \$(compgen -W "\${commands} \${global_opts}" -- "\${cur}") )
return 0
;;
configure)
COMPREPLY=( \$(compgen -W "--help" -- "\${cur}") )
return 0
;;
register-agent)
COMPREPLY=( \$(compgen -W "--name --description --help" -- "\${cur}") )
return 0
;;
list-agents)
COMPREPLY=( \$(compgen -W "--help" -- "\${cur}") )
return 0
;;
issue-token)
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
return 0
;;
rotate-credentials)
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
return 0
;;
tail-audit-log)
COMPREPLY=( \$(compgen -W "--agent-id --help" -- "\${cur}") )
return 0
;;
completion)
COMPREPLY=( \$(compgen -W "bash zsh --help" -- "\${cur}") )
return 0
;;
*)
COMPREPLY=()
return 0
;;
esac
}
complete -F _sentryagent_completion sentryagent
`.trim();
const ZSH_COMPLETION = `
#compdef sentryagent
# sentryagent zsh completion
# Add to ~/.zshrc:
# source <(sentryagent completion zsh)
# Or generate a file and place it in your $fpath:
# sentryagent completion zsh > ~/.zsh/completions/_sentryagent
_sentryagent() {
local state
_arguments \\
'(-v --version)'{-v,--version}'[Show version]' \\
'(-h --help)'{-h,--help}'[Show help]' \\
'1: :->command' \\
'*: :->args'
case \$state in
command)
local commands=(
'configure:Configure CLI with API URL and credentials'
'register-agent:Register a new agent'
'list-agents:List all registered agents'
'issue-token:Issue an OAuth2 access token for an agent'
'rotate-credentials:Rotate credentials for an agent'
'tail-audit-log:Poll and stream audit log events'
'completion:Output shell completion script'
)
_describe 'command' commands
;;
args)
case \${words[2]} in
configure)
_arguments \\
'(-h --help)'{-h,--help}'[Show help]'
;;
register-agent)
_arguments \\
'--name[Agent name]:name' \\
'--description[Agent description]:description' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
list-agents)
_arguments \\
'(-h --help)'{-h,--help}'[Show help]'
;;
issue-token)
_arguments \\
'--agent-id[Agent ID]:agent-id' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
rotate-credentials)
_arguments \\
'--agent-id[Agent ID]:agent-id' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
tail-audit-log)
_arguments \\
'--agent-id[Filter by agent ID]:agent-id' \\
'(-h --help)'{-h,--help}'[Show help]'
;;
completion)
local shells=('bash:Generate bash completion script' 'zsh:Generate zsh completion script')
_describe 'shell' shells
;;
esac
;;
esac
}
_sentryagent "\$@"
`.trim();
export function registerCompletion(program: Command): void {
const completion = program
.command('completion')
.description('Output shell completion scripts');
completion
.command('bash')
.description('Output bash completion script')
.action(() => {
console.log(BASH_COMPLETION);
});
completion
.command('zsh')
.description('Output zsh completion script')
.action(() => {
console.log(ZSH_COMPLETION);
});
completion.addHelpText(
'after',
'\nSupported shells: bash, zsh',
);
}

View File

@@ -0,0 +1,63 @@
import * as readline from 'readline';
import { Command } from 'commander';
import chalk from 'chalk';
import { writeConfig } from '../config';
function prompt(rl: readline.Interface, question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
});
}
export function registerConfigure(program: Command): void {
program
.command('configure')
.description('Configure the CLI with API URL and credentials')
.action(async () => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
console.log(chalk.bold('SentryAgent CLI Configuration'));
console.log(chalk.dim('─'.repeat(40)));
const apiUrl = await prompt(
rl,
chalk.cyan('API URL') + ' (e.g. https://api.sentryagent.ai): ',
);
if (apiUrl === '') {
console.error(chalk.red('API URL cannot be empty.'));
process.exit(1);
}
const clientId = await prompt(rl, chalk.cyan('Client ID') + ': ');
if (clientId === '') {
console.error(chalk.red('Client ID cannot be empty.'));
process.exit(1);
}
const clientSecret = await prompt(
rl,
chalk.cyan('Client Secret') + ': ',
);
if (clientSecret === '') {
console.error(chalk.red('Client Secret cannot be empty.'));
process.exit(1);
}
writeConfig({ apiUrl, clientId, clientSecret });
console.log();
console.log(
chalk.green('✓') +
' Configuration saved to ~/.sentryagent/config.json',
);
} finally {
rl.close();
}
});
}

View File

@@ -0,0 +1,70 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
interface TokenResponse {
access_token: string;
expires_in: number;
token_type: string;
scope?: string;
}
export function registerIssueToken(program: Command): void {
program
.command('issue-token')
.description('Issue an OAuth2 access token for an agent')
.requiredOption('--agent-id <id>', 'Agent ID to issue a token for')
.action(async (options: { agentId: string }) => {
const config = requireConfig();
try {
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
agent_id: options.agentId,
});
const res = await fetch(`${config.apiUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token issuance failed (${res.status}): ${text}`);
}
const data = (await res.json()) as TokenResponse;
const expiresAt = new Date(
Date.now() + data.expires_in * 1000,
).toISOString();
console.log(chalk.green('✓') + ' Token issued successfully');
console.log();
console.log(chalk.bold('Access Token:'));
console.log(chalk.cyan(data.access_token));
console.log();
console.log(
chalk.bold('Token Type: ') + data.token_type,
);
console.log(
chalk.bold('Expires In: ') + `${data.expires_in}s`,
);
console.log(
chalk.bold('Expires At: ') + chalk.dim(expiresAt),
);
if (data.scope !== undefined) {
console.log(chalk.bold('Scope: ') + data.scope);
}
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,105 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface Agent {
id: string;
name: string;
status: string;
createdAt: string;
description?: string;
}
interface AgentsResponse {
agents: Agent[];
total?: number;
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
function padEnd(str: string, len: number): string {
return str.padEnd(len, ' ');
}
export function registerListAgents(program: Command): void {
program
.command('list-agents')
.description('List all registered agents')
.action(async () => {
const config = requireConfig();
try {
const data = await apiRequest<AgentsResponse | Agent[]>(
config,
'/agents',
);
const agents: Agent[] = Array.isArray(data)
? data
: (data as AgentsResponse).agents ?? [];
if (agents.length === 0) {
console.log(chalk.yellow('No agents found.'));
return;
}
const ID_W = 26;
const NAME_W = 24;
const STATUS_W = 10;
const DATE_W = 20;
const header =
chalk.bold(padEnd('AGENT ID', ID_W)) +
' ' +
chalk.bold(padEnd('NAME', NAME_W)) +
' ' +
chalk.bold(padEnd('STATUS', STATUS_W)) +
' ' +
chalk.bold('CREATED AT');
const divider = chalk.dim(
'─'.repeat(ID_W + NAME_W + STATUS_W + DATE_W + 6),
);
console.log(header);
console.log(divider);
for (const agent of agents) {
const statusColor =
agent.status === 'active'
? chalk.green
: agent.status === 'inactive'
? chalk.yellow
: chalk.red;
const createdAt = new Date(agent.createdAt).toLocaleString();
console.log(
chalk.cyan(padEnd(truncate(agent.id, ID_W), ID_W)) +
' ' +
padEnd(truncate(agent.name, NAME_W), NAME_W) +
' ' +
statusColor(padEnd(truncate(agent.status, STATUS_W), STATUS_W)) +
' ' +
chalk.dim(truncate(createdAt, DATE_W)),
);
}
console.log(divider);
const total = Array.isArray(data)
? agents.length
: ((data as AgentsResponse).total ?? agents.length);
console.log(chalk.dim(`Total: ${total}`));
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,54 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface AgentResponse {
id: string;
name: string;
description?: string;
status: string;
createdAt: string;
}
export function registerRegisterAgent(program: Command): void {
program
.command('register-agent')
.description('Register a new agent')
.requiredOption('--name <name>', 'Agent name')
.option('--description <desc>', 'Agent description')
.action(async (options: { name: string; description?: string }) => {
const config = requireConfig();
try {
const body: { name: string; description?: string } = {
name: options.name,
};
if (options.description !== undefined) {
body.description = options.description;
}
const agent = await apiRequest<AgentResponse>(config, '/agents', {
method: 'POST',
body,
});
console.log(chalk.green('✓') + ' Agent registered successfully');
console.log();
console.log(
chalk.bold('Agent ID: ') + chalk.cyan(agent.id),
);
console.log(chalk.bold('Name: ') + agent.name);
if (agent.description !== undefined) {
console.log(chalk.bold('Description:') + ' ' + agent.description);
}
console.log(chalk.bold('Status: ') + agent.status);
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}

View File

@@ -0,0 +1,85 @@
import * as readline from 'readline';
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface RotateResponse {
clientId: string;
clientSecret: string;
rotatedAt?: string;
}
function prompt(rl: readline.Interface, question: string): Promise<string> {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.trim());
});
});
}
export function registerRotateCredentials(program: Command): void {
program
.command('rotate-credentials')
.description('Rotate credentials for an agent (invalidates current secret)')
.requiredOption('--agent-id <id>', 'Agent ID whose credentials to rotate')
.action(async (options: { agentId: string }) => {
const config = requireConfig();
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
try {
console.log(
chalk.yellow('⚠') +
' This will invalidate the current secret for agent ' +
chalk.cyan(options.agentId),
);
const answer = await prompt(
rl,
chalk.bold('This will invalidate the current secret. Continue? [y/N] '),
);
if (answer.toLowerCase() !== 'y' && answer.toLowerCase() !== 'yes') {
console.log(chalk.dim('Aborted.'));
return;
}
const data = await apiRequest<RotateResponse>(
config,
`/agents/${options.agentId}/credentials/rotate`,
{ method: 'POST' },
);
console.log();
console.log(chalk.green('✓') + ' Credentials rotated successfully');
console.log();
console.log(chalk.bold('Client ID: ') + chalk.cyan(data.clientId));
console.log(
chalk.bold('Client Secret: ') + chalk.yellow(data.clientSecret),
);
console.log();
console.log(
chalk.dim(
'Store the new client secret securely — it will not be shown again.',
),
);
if (data.rotatedAt !== undefined) {
console.log(
chalk.dim('Rotated at: ') + chalk.dim(data.rotatedAt),
);
}
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
} finally {
rl.close();
}
});
}

View File

@@ -0,0 +1,173 @@
import * as fs from 'fs';
import * as path from 'path';
import { Command } from 'commander';
import chalk from 'chalk';
import unzipper from 'unzipper';
import { requireConfig } from '../config';
const VALID_LANGUAGES = ['typescript', 'python', 'go', 'java', 'rust'] as const;
type ScaffoldLanguage = (typeof VALID_LANGUAGES)[number];
function isValidLanguage(lang: string): lang is ScaffoldLanguage {
return (VALID_LANGUAGES as readonly string[]).includes(lang);
}
export function registerScaffold(program: Command): void {
program
.command('scaffold')
.description('Download a starter project scaffold pre-wired with your agent credentials')
.requiredOption('--agent-id <id>', 'Agent ID to scaffold for')
.option(
'--language <lang>',
`SDK language (${VALID_LANGUAGES.join(', ')})`,
'typescript',
)
.option('--out <directory>', 'Output directory for the extracted scaffold', '.')
.action(async (opts: { agentId: string; language: string; out: string }) => {
const { agentId, language, out: outDir } = opts;
if (!isValidLanguage(language)) {
console.error(
chalk.red('Error:'),
`Unsupported language '${language}'. Choose: ${VALID_LANGUAGES.join(', ')}`,
);
process.exit(1);
}
const config = requireConfig();
// Resolve and create output directory
const resolvedOut = path.resolve(outDir);
if (!fs.existsSync(resolvedOut)) {
fs.mkdirSync(resolvedOut, { recursive: true });
}
console.log(
chalk.dim(`Downloading ${language} scaffold for agent ${agentId}...`),
);
try {
// We need a raw binary response — fetch the token via apiRequest pattern
// then make a raw fetch for the ZIP stream.
const token = await getToken(config);
const url = `${config.apiUrl}/sdk/scaffold/${encodeURIComponent(agentId)}?language=${encodeURIComponent(language)}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
handleHttpError(res.status, text);
process.exit(1);
}
if (res.body === null) {
console.error(chalk.red('Error:'), 'Empty response body from server.');
process.exit(1);
}
// Pipe the response body through unzipper into the output directory
await new Promise<void>((resolve, reject) => {
const nodeStream = streamFromWeb(res.body!);
nodeStream
.pipe(unzipper.Extract({ path: resolvedOut }))
.on('close', resolve)
.on('error', reject);
});
console.log(chalk.green('Scaffold extracted to:'), chalk.bold(resolvedOut));
console.log('');
console.log('Next steps:');
console.log(
` 1. ${chalk.cyan('cd')} ${resolvedOut}`,
);
if (language === 'typescript') {
console.log(` 2. ${chalk.cyan('npm install')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('npm run dev')}`);
} else if (language === 'python') {
console.log(` 2. ${chalk.cyan('pip install -r requirements.txt')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('python main.py')}`);
} else if (language === 'go') {
console.log(` 2. ${chalk.cyan('go mod download')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('go run main.go')}`);
} else if (language === 'java') {
console.log(` 2. ${chalk.cyan('mvn install')}`);
console.log(` 3. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 4. ${chalk.cyan('mvn exec:java')}`);
} else if (language === 'rust') {
console.log(` 2. Copy ${chalk.yellow('.env.example')} to ${chalk.yellow('.env')} and fill in your client secret`);
console.log(` 3. ${chalk.cyan('cargo run')}`);
}
} catch (err) {
console.error(
chalk.red('Error:'),
err instanceof Error ? err.message : String(err),
);
process.exit(1);
}
});
}
/** Obtain a bearer token by making a dummy apiRequest that uses the token cache. */
async function getToken(config: import('../config').Config): Promise<string> {
// apiRequest internally calls fetchToken which caches tokens.
// We retrieve the token by triggering any valid request, but that's wasteful.
// Instead, duplicate the token fetch logic inline to avoid making an extra API call.
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
});
const res = await fetch(`${config.apiUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Authentication failed (${res.status}): ${text}`);
}
const data = (await res.json()) as { access_token: string };
return data.access_token;
}
function handleHttpError(status: number, body: string): void {
if (status === 400) {
console.error(chalk.red('Error:'), `Invalid request: ${body}`);
} else if (status === 401) {
console.error(
chalk.red('Error:'),
'Authentication failed. Run `sentryagent configure` to update credentials.',
);
} else if (status === 403) {
console.error(
chalk.red('Error:'),
'Access denied. You do not own this agent.',
);
} else if (status === 404) {
console.error(
chalk.red('Error:'),
'Agent not found. Check the agent ID with `sentryagent list-agents`.',
);
} else {
console.error(chalk.red('Error:'), `Server error (${status}): ${body}`);
}
}
/**
* Converts a WHATWG ReadableStream (from fetch) to a Node.js Readable stream.
* Node 18+ supports ReadableStream natively via stream.Readable.fromWeb().
*/
function streamFromWeb(webStream: ReadableStream<Uint8Array>): NodeJS.ReadableStream {
// Node.js 18+ has stream.Readable.fromWeb
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { Readable } = require('stream') as typeof import('stream');
return Readable.fromWeb(webStream as Parameters<typeof Readable.fromWeb>[0]) as NodeJS.ReadableStream;
}

View File

@@ -0,0 +1,122 @@
import { Command } from 'commander';
import chalk from 'chalk';
import { requireConfig } from '../config';
import { apiRequest } from '../api';
interface AuditEvent {
id: string;
timestamp: string;
action: string;
agentId?: string;
tenantId?: string;
outcome: string;
details?: Record<string, unknown>;
}
interface AuditLogsResponse {
events: AuditEvent[];
nextCursor?: string;
}
function formatEvent(event: AuditEvent): string {
const ts = chalk.dim(new Date(event.timestamp).toLocaleString());
const outcome =
event.outcome === 'success'
? chalk.green(event.outcome)
: chalk.red(event.outcome);
const action = chalk.cyan(event.action);
const agentPart =
event.agentId !== undefined
? ' ' + chalk.dim('agent=' + event.agentId)
: '';
return `${ts} ${action} outcome=${outcome}${agentPart} id=${chalk.dim(event.id)}`;
}
export function registerTailAuditLog(program: Command): void {
program
.command('tail-audit-log')
.description(
'Poll and stream audit log events every 5 seconds (Ctrl+C to stop)',
)
.option('--agent-id <id>', 'Filter events for a specific agent ID')
.action(async (options: { agentId?: string }) => {
const config = requireConfig();
console.log(
chalk.bold('Tailing audit log') +
(options.agentId !== undefined
? chalk.dim(` (agent: ${options.agentId})`)
: '') +
chalk.dim(' — press Ctrl+C to stop'),
);
console.log(chalk.dim('─'.repeat(60)));
const seenIds = new Set<string>();
let cursor: string | undefined;
let running = true;
process.on('SIGINT', () => {
running = false;
console.log();
console.log(chalk.dim('Stopped.'));
process.exit(0);
});
while (running) {
try {
const params: Record<string, string> = {};
if (options.agentId !== undefined) {
params['agentId'] = options.agentId;
}
if (cursor !== undefined) {
params['cursor'] = cursor;
}
// Request events from the last poll window
params['limit'] = '50';
const data = await apiRequest<AuditLogsResponse | AuditEvent[]>(
config,
'/audit/logs',
{ params },
);
const events: AuditEvent[] = Array.isArray(data)
? data
: (data as AuditLogsResponse).events ?? [];
if (!Array.isArray(data) && (data as AuditLogsResponse).nextCursor !== undefined) {
cursor = (data as AuditLogsResponse).nextCursor;
}
for (const event of events) {
if (!seenIds.has(event.id)) {
seenIds.add(event.id);
console.log(formatEvent(event));
}
}
// Keep the seenIds set bounded to avoid unbounded memory growth
if (seenIds.size > 10_000) {
const arr = Array.from(seenIds);
const keep = arr.slice(arr.length - 5_000);
seenIds.clear();
for (const id of keep) seenIds.add(id);
}
} catch (err) {
console.error(
chalk.yellow('⚠') +
' Poll error: ' +
(err instanceof Error ? err.message : String(err)),
);
}
// Wait 5 seconds between polls
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 5000);
// Allow the timer to be garbage-collected if process exits
if (typeof timer.unref === 'function') timer.unref();
});
}
});
}

61
cli/src/config.ts Normal file
View File

@@ -0,0 +1,61 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
export interface Config {
apiUrl: string;
clientId: string;
clientSecret: string;
}
const CONFIG_DIR = path.join(os.homedir(), '.sentryagent');
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
export function readConfig(): Config | null {
if (!fs.existsSync(CONFIG_FILE)) {
return null;
}
try {
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const parsed: unknown = JSON.parse(raw);
if (
parsed !== null &&
typeof parsed === 'object' &&
'apiUrl' in parsed &&
'clientId' in parsed &&
'clientSecret' in parsed &&
typeof (parsed as Record<string, unknown>)['apiUrl'] === 'string' &&
typeof (parsed as Record<string, unknown>)['clientId'] === 'string' &&
typeof (parsed as Record<string, unknown>)['clientSecret'] === 'string'
) {
const p = parsed as Record<string, unknown>;
return {
apiUrl: p['apiUrl'] as string,
clientId: p['clientId'] as string,
clientSecret: p['clientSecret'] as string,
};
}
return null;
} catch {
return null;
}
}
export function writeConfig(config: Config): void {
if (!fs.existsSync(CONFIG_DIR)) {
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), {
encoding: 'utf-8',
mode: 0o600,
});
}
export function requireConfig(): Config {
const config = readConfig();
if (config === null) {
console.error('Not configured. Run `sentryagent configure` first.');
process.exit(1);
}
return config;
}

33
cli/src/index.ts Normal file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { Command } from 'commander';
import packageJson from '../package.json';
import { registerConfigure } from './commands/configure';
import { registerRegisterAgent } from './commands/register-agent';
import { registerListAgents } from './commands/list-agents';
import { registerIssueToken } from './commands/issue-token';
import { registerRotateCredentials } from './commands/rotate-credentials';
import { registerTailAuditLog } from './commands/tail-audit-log';
import { registerCompletion } from './commands/completion';
import { registerScaffold } from './commands/scaffold';
const program = new Command();
program
.name('sentryagent')
.description('SentryAgent.ai CLI — manage agents, tokens, and audit logs')
.version(packageJson.version, '-v, --version', 'Output the current version');
// Register all commands
registerConfigure(program);
registerRegisterAgent(program);
registerListAgents(program);
registerIssueToken(program);
registerRotateCredentials(program);
registerTailAuditLog(program);
registerCompletion(program);
registerScaffold(program);
// Parse args — commander will display help automatically on --help
program.parse(process.argv);

29
cli/tsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"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,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

95
dashboard/README.md Normal file
View File

@@ -0,0 +1,95 @@
# SentryAgent.ai AgentIdP — Web Dashboard
## 1. Overview
The AgentIdP Dashboard is a React 18 single-page application (SPA) that provides a visual
management interface for the AgentIdP API. It allows operators to:
- Browse, search, and filter all registered AI agents
- View agent details and manage lifecycle (suspend / reactivate)
- Generate, rotate, and revoke agent credentials
- Query the audit log with filters for agent, action, outcome, and date range
- Monitor PostgreSQL and Redis connectivity in real time
The dashboard is co-served by the Express API server at `/dashboard/` — no separate hosting
is required.
## 2. Prerequisites
- Node.js 18+
- A running AgentIdP server (local or remote)
- An active agent credential (Client ID + Client Secret) with full scopes
## 3. Development
Install dashboard dependencies:
```bash
cd dashboard
npm install
```
Start the Vite dev server:
```bash
npm run dev
```
The dev server starts at `http://localhost:5173/dashboard/`. API calls are made to
`window.location.origin` (defaulted in the Login form), so either:
- Set the **API Base URL** field to your local server (e.g. `http://localhost:3000`)
- Or configure a Vite proxy in `vite.config.ts` for `/api` and `/health` paths
## 4. Building
Compile TypeScript and bundle with Vite:
```bash
npm run build
```
Output is written to `dashboard/dist/`. The build is an optimised static bundle (HTML, CSS, JS).
To verify the build locally:
```bash
npm run preview
```
## 5. Deployment
The AgentIdP Express server automatically serves the built dashboard:
- Static assets at `/dashboard/` (via `express.static`)
- SPA fallback — all `/dashboard/*` requests not matching a static file return `index.html`
**Steps:**
1. Build the dashboard: `cd dashboard && npm run build`
2. Start (or restart) the AgentIdP server: `npm start`
3. Open `https://your-api-host/dashboard/` in a browser
No additional nginx or CDN configuration is required for basic deployments.
## 6. Login
The login form has three fields:
| Field | Description |
|---|---|
| **API Base URL** | Base URL of the AgentIdP server, e.g. `https://api.example.com`. Defaults to the current page origin, which works when the dashboard is co-served. |
| **Client ID** | The UUID of an agent registered in AgentIdP. This agent must have the scopes `agents:read agents:write tokens:read audit:read`. |
| **Client Secret** | The plain-text client secret for the agent. Validated against the token endpoint on login. |
Credentials are stored in `sessionStorage` only — they are cleared when the browser tab is closed.
## 7. Pages
| Page | Route | Description |
|---|---|---|
| **Agents** | `/dashboard/agents` | Paginated list of all agents. Search by email (debounced), filter by status. Click a row for details. |
| **Agent Detail** | `/dashboard/agents/:agentId` | Full agent metadata. Suspend or reactivate (with confirmation). Link to credentials. |
| **Credentials** | `/dashboard/agents/:agentId/credentials` | List all credentials. Generate, rotate, or revoke. New secrets shown exactly once. |
| **Audit Log** | `/dashboard/audit` | Paginated audit events with filters for agent ID, action, outcome, and date range. |
| **Health** | `/dashboard/health` | PostgreSQL and Redis connectivity cards. Auto-refreshes every 30 seconds. |

12
dashboard/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SentryAgent.ai — AgentIdP Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2755
dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
dashboard/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@sentryagent/dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.app.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"@sentryagent/idp-sdk": "file:../sdk",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2",
"lucide-react": "^0.446.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.12",
"typescript": "^5.5.3",
"vite": "^5.4.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

35
dashboard/src/App.tsx Normal file
View File

@@ -0,0 +1,35 @@
import * as React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from '@/lib/auth';
import { RequireAuth } from '@/components/RequireAuth';
import { AppShell } from '@/components/layout/AppShell';
import Login from '@/pages/Login';
import Agents from '@/pages/Agents';
import AgentDetail from '@/pages/AgentDetail';
import Credentials from '@/pages/Credentials';
import AuditLog from '@/pages/AuditLog';
import Health from '@/pages/Health';
import { UsagePanel } from '@/components/UsagePanel';
/** Top-level router — defines all application routes. */
export default function App(): React.JSX.Element {
return (
<AuthProvider>
<Routes>
<Route path="/dashboard/login" element={<Login />} />
<Route element={<RequireAuth />}>
<Route element={<AppShell />}>
<Route path="/dashboard/agents" element={<Agents />} />
<Route path="/dashboard/agents/:agentId" element={<AgentDetail />} />
<Route path="/dashboard/agents/:agentId/credentials" element={<Credentials />} />
<Route path="/dashboard/audit" element={<AuditLog />} />
<Route path="/dashboard/health" element={<Health />} />
<Route path="/dashboard/usage" element={<UsagePanel />} />
</Route>
</Route>
<Route path="/dashboard" element={<Navigate to="/dashboard/agents" replace />} />
<Route path="*" element={<Navigate to="/dashboard/agents" replace />} />
</Routes>
</AuthProvider>
);
}

View File

@@ -0,0 +1,11 @@
import * as React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { isAuthenticated } from '@/lib/auth';
/** Redirects to /dashboard/login if not authenticated. */
export function RequireAuth(): React.JSX.Element {
if (!isAuthenticated()) {
return <Navigate to="/dashboard/login" replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,192 @@
import * as React from 'react';
import { useAuth } from '@/lib/auth';
import { TokenManager } from '@sentryagent/idp-sdk';
/** Shape of the GET /api/v1/billing/usage response. */
interface UsageResponse {
tenantId: string;
date: string;
apiCalls: number;
agentCount: number;
subscriptionStatus: string;
currentPeriodEnd: string | null;
stripeSubscriptionId: string | null;
}
type LoadState = 'idle' | 'loading' | 'success' | 'error';
interface UsageState {
loadState: LoadState;
data: UsageResponse | null;
errorMessage: string | null;
}
const initialState: UsageState = {
loadState: 'idle',
data: null,
errorMessage: null,
};
/**
* Fetches the current usage summary from the API using the stored credentials.
*
* @param baseUrl - The API base URL.
* @param clientId - The agent client ID.
* @param clientSecret - The agent client secret.
* @returns The usage response from the server.
*/
async function fetchUsage(
baseUrl: string,
clientId: string,
clientSecret: string,
): Promise<UsageResponse> {
const tokenManager = new TokenManager(
baseUrl,
clientId,
clientSecret,
'agents:read',
);
const token = await tokenManager.getToken();
const response = await fetch(`${baseUrl}/api/v1/billing/usage`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`Failed to fetch usage data (HTTP ${response.status})`);
}
return response.json() as Promise<UsageResponse>;
}
/** Badge shown for the tenant's subscription tier. */
function SubscriptionBadge({ status }: { status: string }): React.JSX.Element {
const isPro = status !== 'free';
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
isPro
? 'bg-brand-100 text-brand-700'
: 'bg-slate-100 text-slate-600'
}`}
>
{isPro ? 'Pro' : 'Free Tier'}
</span>
);
}
/** A single metric card with label and value. */
function MetricCard({ label, value }: { label: string; value: string | number }): React.JSX.Element {
return (
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="mt-1 text-2xl font-bold text-slate-900">{value}</p>
</div>
);
}
/**
* Displays the current tenant's usage summary:
* - API calls today
* - Active agent count
* - Subscription status (Free Tier / Pro)
*
* Fetches GET /api/v1/billing/usage with the current Bearer token.
* Handles loading state and error state gracefully.
*/
export function UsagePanel(): React.JSX.Element {
const { credentials } = useAuth();
const [state, setState] = React.useState<UsageState>(initialState);
const loadUsage = React.useCallback(async (): Promise<void> => {
if (!credentials) return;
setState((prev) => ({ ...prev, loadState: 'loading', errorMessage: null }));
try {
const data = await fetchUsage(
credentials.baseUrl,
credentials.clientId,
credentials.clientSecret,
);
setState({ loadState: 'success', data, errorMessage: null });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error occurred.';
setState({ loadState: 'error', data: null, errorMessage: message });
}
}, [credentials]);
React.useEffect(() => {
void loadUsage();
}, [loadUsage]);
const isLoading = state.loadState === 'loading' || state.loadState === 'idle';
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">Usage &amp; Billing</h1>
<button
onClick={() => { void loadUsage(); }}
disabled={isLoading}
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
>
Refresh
</button>
</div>
{/* Error state */}
{state.loadState === 'error' && (
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{state.errorMessage ?? 'Failed to load usage data.'}
</div>
)}
{/* Loading skeleton */}
{isLoading && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 animate-pulse">
{[1, 2, 3].map((i) => (
<div key={i} className="h-28 rounded-xl border border-slate-200 bg-slate-100" />
))}
</div>
)}
{/* Data */}
{state.loadState === 'success' && state.data !== null && (
<>
<div className="mb-4 flex items-center gap-3">
<p className="text-sm text-slate-500">
Showing usage for <strong>{state.data.date}</strong>
</p>
<SubscriptionBadge status={state.data.subscriptionStatus} />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<MetricCard label="API Calls Today" value={state.data.apiCalls.toLocaleString()} />
<MetricCard label="Active Agents" value={state.data.agentCount.toLocaleString()} />
<MetricCard label="Plan" value={state.data.subscriptionStatus === 'free' ? 'Free Tier' : 'Pro'} />
</div>
{state.data.subscriptionStatus === 'free' && (
<div className="mt-6 rounded-xl border border-brand-200 bg-brand-50 p-5">
<p className="text-sm font-medium text-brand-800">
You are on the Free Tier limited to 10 agents and 1,000 API calls/day.
</p>
<p className="mt-1 text-sm text-brand-700">
Upgrade to Pro for unlimited agents and API calls.
</p>
</div>
)}
{state.data.currentPeriodEnd !== null && (
<p className="mt-4 text-xs text-slate-400">
Current period ends:{' '}
{new Date(state.data.currentPeriodEnd).toLocaleDateString()}
</p>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,63 @@
import * as React from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useAuth } from '@/lib/auth';
interface NavItem {
to: string;
label: string;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard/agents', label: 'Agents' },
{ to: '/dashboard/audit', label: 'Audit Log' },
{ to: '/dashboard/health', label: 'Health' },
{ to: '/dashboard/usage', label: 'Usage' },
];
/**
* Outer application shell: top navigation bar and main content area.
* Renders the active page via <Outlet />.
*/
export function AppShell(): React.JSX.Element {
const { logout } = useAuth();
return (
<div className="min-h-screen bg-slate-50">
<header className="border-b border-slate-200 bg-white shadow-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3">
<div className="flex items-center gap-8">
<span className="text-lg font-bold text-brand-700">SentryAgent.ai</span>
<nav className="flex gap-1">
{NAV_ITEMS.map(({ to, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
'rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-brand-50 text-brand-700'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
)
}
>
{label}
</NavLink>
))}
</nav>
</div>
<button
onClick={logout}
className="text-sm text-slate-500 hover:text-slate-900"
>
Sign out
</button>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-8">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
type BadgeVariant = 'default' | 'success' | 'warning' | 'danger' | 'muted';
interface BadgeProps {
variant?: BadgeVariant;
children: React.ReactNode;
className?: string;
}
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-brand-100 text-brand-700',
success: 'bg-green-100 text-green-700',
warning: 'bg-yellow-100 text-yellow-700',
danger: 'bg-red-100 text-red-700',
muted: 'bg-slate-100 text-slate-600',
};
/** Small status badge. */
export function Badge({ variant = 'default', children, className }: BadgeProps): React.JSX.Element {
return (
<span className={cn('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium', variantClasses[variant], className)}>
{children}
</span>
);
}

View File

@@ -0,0 +1,65 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
type Variant = 'default' | 'destructive' | 'outline' | 'ghost';
type Size = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
}
const variantClasses: Record<Variant, string> = {
default: 'bg-brand-600 text-white hover:bg-brand-700 focus:ring-brand-500',
destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
outline: 'border border-slate-300 bg-white text-slate-700 hover:bg-slate-50 focus:ring-brand-500',
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 focus:ring-brand-500',
};
const sizeClasses: Record<Size, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
/**
* Reusable button component with variant and size support.
*
* @param variant - Visual style: default | destructive | outline | ghost
* @param size - Size: sm | md | lg
* @param loading - When true, shows a spinner and disables the button
*/
export function Button({
variant = 'default',
size = 'md',
loading = false,
className,
children,
disabled,
...props
}: ButtonProps): React.JSX.Element {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-2 rounded-md font-medium',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'transition-colors duration-150',
variantClasses[variant],
sizeClasses[size],
className,
)}
disabled={disabled ?? loading}
{...props}
>
{loading && (
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
)}
{children}
</button>
);
}

View File

@@ -0,0 +1,45 @@
import * as React from 'react';
import { Button } from './button';
interface DialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: 'default' | 'destructive';
onConfirm: () => void;
onCancel: () => void;
}
/**
* Modal confirmation dialog for destructive actions (suspend, revoke, rotate).
*/
export function ConfirmDialog({
open,
title,
description,
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
variant = 'default',
onConfirm,
onCancel,
}: DialogProps): React.JSX.Element | null {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={onCancel} />
<div className="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<p className="mt-2 text-sm text-slate-600">{description}</p>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline" onClick={onCancel}>{cancelLabel}</Button>
<Button variant={variant === 'destructive' ? 'destructive' : 'default'} onClick={onConfirm}>
{confirmLabel}
</Button>
</div>
</div>
</div>
);
}

26
dashboard/src/index.css Normal file
View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 198 89% 48%;
--radius: 0.5rem;
}
}
* {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background-color: #f8fafc;
color: #0f172a;
}

109
dashboard/src/lib/auth.tsx Normal file
View File

@@ -0,0 +1,109 @@
import { TokenManager } from '@sentryagent/idp-sdk';
const SESSION_KEY = 'agentidp_credentials';
interface StoredCredentials {
clientId: string;
clientSecret: string;
baseUrl: string;
}
/**
* Persists user credentials to sessionStorage (cleared on tab close).
*/
export function saveCredentials(creds: StoredCredentials): void {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(creds));
}
/**
* Retrieves credentials from sessionStorage.
* Returns null if not logged in.
*/
export function loadCredentials(): StoredCredentials | null {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return null;
try {
return JSON.parse(raw) as StoredCredentials;
} catch {
return null;
}
}
/**
* Removes credentials from sessionStorage (logout).
*/
export function clearCredentials(): void {
sessionStorage.removeItem(SESSION_KEY);
}
/**
* Returns true if the user has stored credentials.
*/
export function isAuthenticated(): boolean {
return loadCredentials() !== null;
}
/**
* Validates stored credentials by requesting a token.
* Returns true if successful; false on auth failure.
*/
export async function validateCredentials(creds: StoredCredentials): Promise<boolean> {
try {
const tm = new TokenManager(creds.baseUrl, creds.clientId, creds.clientSecret, 'agents:read agents:write tokens:read audit:read');
await tm.getToken();
return true;
} catch {
return false;
}
}
// ── React context ──────────────────────────────────────────────────────────────
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
interface AuthContextValue {
credentials: StoredCredentials | null;
login: (creds: StoredCredentials) => Promise<boolean>;
logout: () => void;
}
const AuthContext = React.createContext<AuthContextValue | null>(null);
/**
* Provides authentication state to the application.
* Reads initial state from sessionStorage on mount.
*/
export function AuthProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [credentials, setCredentials] = React.useState<StoredCredentials | null>(loadCredentials);
const navigate = useNavigate();
const login = React.useCallback(async (creds: StoredCredentials): Promise<boolean> => {
const valid = await validateCredentials(creds);
if (valid) {
saveCredentials(creds);
setCredentials(creds);
}
return valid;
}, []);
const logout = React.useCallback((): void => {
clearCredentials();
setCredentials(null);
navigate('/dashboard/login');
}, [navigate]);
const value = React.useMemo(() => ({ credentials, login, logout }), [credentials, login, logout]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Returns the current authentication context.
* Must be used inside <AuthProvider>.
*/
export function useAuth(): AuthContextValue {
const ctx = React.useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

View File

@@ -0,0 +1,18 @@
import { AgentIdPClient } from '@sentryagent/idp-sdk';
import { loadCredentials } from './auth';
/**
* Returns an AgentIdPClient configured with credentials from sessionStorage.
* Throws if not authenticated (caller must ensure login first).
*/
export function getClient(): AgentIdPClient {
const creds = loadCredentials();
if (!creds) {
throw new Error('Not authenticated. Please log in.');
}
return new AgentIdPClient({
baseUrl: creds.baseUrl,
clientId: creds.clientId,
clientSecret: creds.clientSecret,
});
}

View File

@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Merges Tailwind class names, handling conflicts correctly. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}

13
dashboard/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
);

View File

@@ -0,0 +1,222 @@
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { Agent } from '@sentryagent/idp-sdk';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/dialog';
import { getClient } from '@/lib/client';
type BadgeVariant = 'success' | 'warning' | 'danger';
/** Maps AgentStatus to a Badge variant. */
function statusVariant(status: Agent['status']): BadgeVariant {
switch (status) {
case 'active': return 'success';
case 'suspended': return 'warning';
case 'decommissioned': return 'danger';
}
}
/** Formats an ISO timestamp to a readable local date-time string. */
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
interface DetailRowProps {
label: string;
value: string;
}
/** Single label/value row in the detail card. */
function DetailRow({ label, value }: DetailRowProps): React.JSX.Element {
return (
<div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
<dt className="w-36 shrink-0 text-sm font-medium text-slate-500">{label}</dt>
<dd className="text-sm text-slate-900 break-all">{value}</dd>
</div>
);
}
type DialogAction = 'suspend' | 'reactivate';
/**
* Agent Detail page — shows all agent fields and provides suspend/reactivate actions.
* Route: /dashboard/agents/:agentId
*/
export default function AgentDetail(): React.JSX.Element {
const { agentId } = useParams<{ agentId: string }>();
const navigate = useNavigate();
const [agent, setAgent] = React.useState<Agent | null>(null);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(null);
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
React.useEffect(() => {
if (!agentId) return;
let cancelled = false;
setLoading(true);
setError(null);
const fetchAgent = async (): Promise<void> => {
try {
const result = await getClient().agents.getAgent(agentId);
if (!cancelled) setAgent(result);
} catch (err) {
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load agent.');
} finally {
if (!cancelled) setLoading(false);
}
};
void fetchAgent();
return () => { cancelled = true; };
}, [agentId]);
const handleAction = React.useCallback(
async (action: DialogAction): Promise<void> => {
if (!agentId) return;
setActionLoading(true);
setDialog(null);
try {
const newStatus = action === 'suspend' ? 'suspended' : 'active';
const updated = await getClient().agents.updateAgent(agentId, { status: newStatus });
setAgent(updated);
} catch (err) {
setError(err instanceof Error ? err.message : 'Action failed.');
} finally {
setActionLoading(false);
}
},
[agentId],
);
if (loading) {
return (
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-5 w-full animate-pulse rounded bg-slate-200" />
))}
</div>
);
}
if (error || !agent) {
return (
<div className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error ?? 'Agent not found.'}
</div>
);
}
const dialogConfig = dialog === 'suspend'
? {
title: `Suspend agent ${agent.email}?`,
description: `Suspending ${agent.email} means it will no longer be able to authenticate.`,
confirmLabel: 'Suspend',
variant: 'destructive' as const,
}
: {
title: `Reactivate agent ${agent.email}?`,
description: `Reactivating ${agent.email} will allow it to authenticate again.`,
confirmLabel: 'Reactivate',
variant: 'default' as const,
};
return (
<div>
{/* Back navigation */}
<button
onClick={() => { navigate('/dashboard/agents'); }}
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
>
Back to Agents
</button>
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900">{agent.email}</h1>
<p className="mt-1 text-sm text-slate-500">Agent ID: {agent.agentId}</p>
</div>
<Badge variant={statusVariant(agent.status)} className="mt-1">{agent.status}</Badge>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error}
</div>
)}
{/* Detail card */}
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<dl className="space-y-4">
<DetailRow label="Email" value={agent.email} />
<DetailRow label="Agent ID" value={agent.agentId} />
<DetailRow label="Type" value={agent.agentType} />
<DetailRow label="Version" value={agent.version} />
<DetailRow label="Owner" value={agent.owner} />
<DetailRow label="Environment" value={agent.deploymentEnv} />
<DetailRow label="Capabilities" value={agent.capabilities.join(', ') || '—'} />
<DetailRow label="Status" value={agent.status} />
<DetailRow label="Created" value={formatDateTime(agent.createdAt)} />
<DetailRow label="Updated" value={formatDateTime(agent.updatedAt)} />
</dl>
</div>
{/* Actions */}
{agent.status !== 'decommissioned' && (
<div className="mt-6 flex gap-3">
{agent.status === 'active' && (
<Button
variant="destructive"
loading={actionLoading}
onClick={() => { setDialog('suspend'); }}
>
Suspend Agent
</Button>
)}
{agent.status === 'suspended' && (
<Button
variant="default"
loading={actionLoading}
onClick={() => { setDialog('reactivate'); }}
>
Reactivate Agent
</Button>
)}
</div>
)}
{/* Credentials section */}
<div className="mt-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-slate-900">Credentials</h2>
<p className="mb-4 text-sm text-slate-600">
Manage client secrets for this agent. Rotate or revoke credentials as needed.
</p>
<Button
variant="outline"
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}/credentials`); }}
>
View Credentials
</Button>
</div>
{/* Confirm dialog */}
{dialog !== null && (
<ConfirmDialog
open
title={dialogConfig.title}
description={dialogConfig.description}
confirmLabel={dialogConfig.confirmLabel}
variant={dialogConfig.variant}
onConfirm={() => { void handleAction(dialog); }}
onCancel={() => { setDialog(null); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,204 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import type { Agent, AgentStatus } from '@sentryagent/idp-sdk';
import { Badge } from '@/components/ui/badge';
import { getClient } from '@/lib/client';
const PAGE_LIMIT = 20;
/** Maps AgentStatus to a Badge variant. */
function statusVariant(status: AgentStatus): 'success' | 'warning' | 'danger' | 'muted' {
switch (status) {
case 'active': return 'success';
case 'suspended': return 'warning';
case 'decommissioned': return 'danger';
}
}
/** Formats an ISO timestamp to a short local date string. */
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
/** Skeleton row shown while loading. */
function SkeletonRow(): React.JSX.Element {
return (
<tr>
{Array.from({ length: 6 }).map((_, i) => (
<td key={i} className="px-4 py-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
</td>
))}
</tr>
);
}
/**
* Agents list page — displays all registered agents with search, status filter, and pagination.
* Clicking a row navigates to the Agent Detail page.
*/
export default function Agents(): React.JSX.Element {
const navigate = useNavigate();
const [agents, setAgents] = React.useState<Agent[]>([]);
const [total, setTotal] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | null>(null);
// Filters (client-side email search, server-side status)
const [searchInput, setSearchInput] = React.useState<string>('');
const [debouncedSearch, setDebouncedSearch] = React.useState<string>('');
const [statusFilter, setStatusFilter] = React.useState<AgentStatus | ''>('');
// Debounce search input 300ms
React.useEffect(() => {
const timer = setTimeout(() => { setDebouncedSearch(searchInput); }, 300);
return () => { clearTimeout(timer); };
}, [searchInput]);
// Reset to page 1 on filter change
React.useEffect(() => {
setPage(1);
}, [debouncedSearch, statusFilter]);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const fetchAgents = async (): Promise<void> => {
try {
const client = getClient();
const result = await client.agents.listAgents({
page,
limit: PAGE_LIMIT,
status: statusFilter !== '' ? statusFilter : undefined,
});
if (!cancelled) {
setAgents(result.data);
setTotal(result.total);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load agents.');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void fetchAgents();
return () => { cancelled = true; };
}, [page, statusFilter]);
// Client-side email filter applied after API results arrive
const filteredAgents = React.useMemo(() => {
if (!debouncedSearch.trim()) return agents;
const lower = debouncedSearch.toLowerCase();
return agents.filter((a) => a.email.toLowerCase().includes(lower));
}, [agents, debouncedSearch]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
return (
<div>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold text-slate-900">Agents</h1>
<div className="flex gap-3">
<input
type="search"
value={searchInput}
onChange={(e) => { setSearchInput(e.target.value); }}
placeholder="Search by email…"
className="w-60 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value as AgentStatus | ''); }}
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="suspended">Suspended</option>
<option value="decommissioned">Decommissioned</option>
</select>
</div>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error}
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr>
{['Name (Email)', 'Type', 'Status', 'Environment', 'Owner', 'Created'].map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading
? Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
: filteredAgents.length === 0
? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-slate-400">
No agents found.
</td>
</tr>
)
: filteredAgents.map((agent) => (
<tr
key={agent.agentId}
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}`); }}
className="cursor-pointer hover:bg-slate-50"
>
<td className="px-4 py-3 font-medium text-brand-700">{agent.email}</td>
<td className="px-4 py-3 text-slate-600">{agent.agentType}</td>
<td className="px-4 py-3">
<Badge variant={statusVariant(agent.status)}>{agent.status}</Badge>
</td>
<td className="px-4 py-3 text-slate-600">{agent.deploymentEnv}</td>
<td className="px-4 py-3 text-slate-600">{agent.owner}</td>
<td className="px-4 py-3 text-slate-500">{formatDate(agent.createdAt)}</td>
</tr>
))
}
</tbody>
</table>
</div>
{/* Pagination */}
{!loading && total > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
<span>
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<button
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
disabled={page <= 1}
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
>
Previous
</button>
<button
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
disabled={page >= totalPages}
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,223 @@
import * as React from 'react';
import type { AuditEvent, AuditAction, AuditOutcome } from '@sentryagent/idp-sdk';
import { Badge } from '@/components/ui/badge';
import { getClient } from '@/lib/client';
const PAGE_LIMIT = 20;
/** All AuditAction values for the filter dropdown. */
const AUDIT_ACTIONS: 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',
];
/** Formats an ISO timestamp to a readable local date-time string. */
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
}
/** Truncates a string to a maximum length with ellipsis. */
function truncate(value: string, maxLen = 24): string {
return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
}
/**
* Audit Log page — displays audit events with filters for agent, action, outcome, and date range.
* Route: /dashboard/audit
*/
export default function AuditLog(): React.JSX.Element {
const [events, setEvents] = React.useState<AuditEvent[]>([]);
const [total, setTotal] = React.useState<number>(0);
const [page, setPage] = React.useState<number>(1);
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | null>(null);
// Filters
const [agentIdFilter, setAgentIdFilter] = React.useState<string>('');
const [actionFilter, setActionFilter] = React.useState<AuditAction | ''>('');
const [outcomeFilter, setOutcomeFilter] = React.useState<AuditOutcome | ''>('');
const [fromDate, setFromDate] = React.useState<string>('');
const [toDate, setToDate] = React.useState<string>('');
// Reset to page 1 on filter change
React.useEffect(() => {
setPage(1);
}, [agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
const fetchEvents = async (): Promise<void> => {
try {
const result = await getClient().audit.queryAuditLog({
page,
limit: PAGE_LIMIT,
agentId: agentIdFilter.trim() || undefined,
action: actionFilter !== '' ? actionFilter : undefined,
outcome: outcomeFilter !== '' ? outcomeFilter : undefined,
fromDate: fromDate || undefined,
toDate: toDate || undefined,
});
if (!cancelled) {
setEvents(result.data);
setTotal(result.total);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load audit log.');
}
} finally {
if (!cancelled) setLoading(false);
}
};
void fetchEvents();
return () => { cancelled = true; };
}, [page, agentIdFilter, actionFilter, outcomeFilter, fromDate, toDate]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
return (
<div>
<h1 className="mb-6 text-2xl font-bold text-slate-900">Audit Log</h1>
{/* Filters */}
<div className="mb-6 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<input
type="text"
value={agentIdFilter}
onChange={(e) => { setAgentIdFilter(e.target.value); }}
placeholder="Agent ID…"
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
<select
value={actionFilter}
onChange={(e) => { setActionFilter(e.target.value as AuditAction | ''); }}
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All Actions</option>
{AUDIT_ACTIONS.map((action) => (
<option key={action} value={action}>{action}</option>
))}
</select>
<select
value={outcomeFilter}
onChange={(e) => { setOutcomeFilter(e.target.value as AuditOutcome | ''); }}
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
>
<option value="">All Outcomes</option>
<option value="success">Success</option>
<option value="failure">Failure</option>
</select>
<input
type="date"
value={fromDate}
onChange={(e) => { setFromDate(e.target.value); }}
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
title="From date"
/>
<input
type="date"
value={toDate}
onChange={(e) => { setToDate(e.target.value); }}
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
title="To date"
/>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error}
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr>
{['Timestamp', 'Agent ID', 'Action', 'Outcome', 'IP Address'].map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading
? Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 5 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
</td>
))}
</tr>
))
: events.length === 0
? (
<tr>
<td colSpan={5} className="px-4 py-12 text-center text-slate-400">
No audit events found.
</td>
</tr>
)
: events.map((event) => (
<tr key={event.eventId} className="hover:bg-slate-50">
<td className="px-4 py-3 text-slate-500 whitespace-nowrap">{formatDateTime(event.timestamp)}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-700">{truncate(event.agentId)}</td>
<td className="px-4 py-3 text-slate-700">{event.action}</td>
<td className="px-4 py-3">
<Badge variant={event.outcome === 'success' ? 'success' : 'danger'}>
{event.outcome}
</Badge>
</td>
<td className="px-4 py-3 text-slate-500">{event.ipAddress}</td>
</tr>
))
}
</tbody>
</table>
</div>
{/* Pagination */}
{!loading && total > 0 && (
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
<span>
Page {page} of {totalPages} ({total} total)
</span>
<div className="flex gap-2">
<button
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
disabled={page <= 1}
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
>
Previous
</button>
<button
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
disabled={page >= totalPages}
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,264 @@
import * as React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { Credential, CredentialWithSecret } from '@sentryagent/idp-sdk';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/dialog';
import { getClient } from '@/lib/client';
/** Truncates a string to a maximum length with ellipsis. */
function truncate(value: string, maxLen = 16): string {
return value.length > maxLen ? `${value.slice(0, maxLen)}` : value;
}
/** Formats an ISO timestamp to a short local date string. */
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
interface NewSecretBoxProps {
secret: string;
onDismiss: () => void;
}
/**
* Displays a newly issued client secret exactly once.
* Provides a copy button and a dismiss button.
*/
function NewSecretBox({ secret, onDismiss }: NewSecretBoxProps): React.JSX.Element {
const [copied, setCopied] = React.useState<boolean>(false);
const handleCopy = React.useCallback(async (): Promise<void> => {
await navigator.clipboard.writeText(secret);
setCopied(true);
setTimeout(() => { setCopied(false); }, 2000);
}, [secret]);
return (
<div className="mb-6 rounded-lg border-2 border-green-400 bg-green-50 p-4">
<p className="mb-2 text-sm font-semibold text-green-800">
New client secret copy it now. It will not be shown again.
</p>
<div className="flex items-center gap-3">
<code className="flex-1 break-all rounded bg-white px-3 py-2 text-sm font-mono text-green-900 border border-green-200">
{secret}
</code>
<Button variant="outline" size="sm" onClick={() => { void handleCopy(); }}>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
<button
onClick={onDismiss}
className="mt-3 text-xs text-green-700 underline hover:text-green-900"
>
I have saved this secret dismiss
</button>
</div>
);
}
type DialogAction = { type: 'rotate'; credentialId: string } | { type: 'revoke'; credentialId: string };
/**
* Credentials page — lists all credentials for an agent with rotate/revoke actions.
* Route: /dashboard/agents/:agentId/credentials
*/
export default function Credentials(): React.JSX.Element {
const { agentId } = useParams<{ agentId: string }>();
const navigate = useNavigate();
const [credentials, setCredentials] = React.useState<Credential[]>([]);
const [loading, setLoading] = React.useState<boolean>(true);
const [error, setError] = React.useState<string | null>(null);
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
const [newSecret, setNewSecret] = React.useState<CredentialWithSecret | null>(null);
const fetchCredentials = React.useCallback(async (): Promise<void> => {
if (!agentId) return;
setLoading(true);
setError(null);
try {
const result = await getClient().credentials.listCredentials(agentId);
setCredentials(result.data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load credentials.');
} finally {
setLoading(false);
}
}, [agentId]);
React.useEffect(() => {
void fetchCredentials();
}, [fetchCredentials]);
const handleGenerate = React.useCallback(async (): Promise<void> => {
if (!agentId) return;
setActionLoading(true);
setError(null);
try {
const result = await getClient().credentials.generateCredential(agentId, {});
setNewSecret(result);
await fetchCredentials();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate credential.');
} finally {
setActionLoading(false);
}
}, [agentId, fetchCredentials]);
const handleConfirm = React.useCallback(async (): Promise<void> => {
if (!dialog || !agentId) return;
setActionLoading(true);
setDialog(null);
setError(null);
try {
if (dialog.type === 'rotate') {
const result = await getClient().credentials.rotateCredential(agentId, dialog.credentialId);
setNewSecret(result);
} else {
await getClient().credentials.revokeCredential(agentId, dialog.credentialId);
}
await fetchCredentials();
} catch (err) {
setError(err instanceof Error ? err.message : `Failed to ${dialog.type} credential.`);
} finally {
setActionLoading(false);
}
}, [dialog, agentId, fetchCredentials]);
const dialogConfig = React.useMemo(() => {
if (!dialog) return null;
if (dialog.type === 'rotate') {
return {
title: 'Rotate credential?',
description: 'The existing secret will be invalidated immediately. You will receive a new secret — store it securely.',
confirmLabel: 'Rotate',
variant: 'destructive' as const,
};
}
return {
title: 'Revoke credential?',
description: 'This will permanently revoke the credential. This cannot be undone.',
confirmLabel: 'Revoke',
variant: 'destructive' as const,
};
}, [dialog]);
return (
<div>
{/* Back navigation */}
<button
onClick={() => { navigate(`/dashboard/agents/${agentId ?? ''}`); }}
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
>
Back to Agent
</button>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">Credentials</h1>
<Button
loading={actionLoading}
onClick={() => { void handleGenerate(); }}
>
Generate Credential
</Button>
</div>
{error && (
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{error}
</div>
)}
{/* New secret display — shown once */}
{newSecret !== null && (
<NewSecretBox
secret={newSecret.clientSecret}
onDismiss={() => { setNewSecret(null); }}
/>
)}
{/* Credentials table */}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<table className="min-w-full divide-y divide-slate-200 text-sm">
<thead className="bg-slate-50">
<tr>
{['Credential ID', 'Status', 'Created', 'Actions'].map((col) => (
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
Array.from({ length: 3 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 4 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
</td>
))}
</tr>
))
) : credentials.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-12 text-center text-slate-400">
No credentials found. Generate one above.
</td>
</tr>
) : credentials.map((cred) => (
<tr key={cred.credentialId} className="hover:bg-slate-50">
<td className="px-4 py-3 font-mono text-xs text-slate-700">
{truncate(cred.credentialId, 24)}
</td>
<td className="px-4 py-3">
<Badge variant={cred.status === 'active' ? 'success' : 'muted'}>
{cred.status}
</Badge>
</td>
<td className="px-4 py-3 text-slate-500">{formatDate(cred.createdAt)}</td>
<td className="px-4 py-3">
{cred.status === 'active' && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={actionLoading}
onClick={() => { setDialog({ type: 'rotate', credentialId: cred.credentialId }); }}
>
Rotate
</Button>
<Button
variant="destructive"
size="sm"
disabled={actionLoading}
onClick={() => { setDialog({ type: 'revoke', credentialId: cred.credentialId }); }}
>
Revoke
</Button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Confirm dialog */}
{dialog !== null && dialogConfig !== null && (
<ConfirmDialog
open
title={dialogConfig.title}
description={dialogConfig.description}
confirmLabel={dialogConfig.confirmLabel}
variant={dialogConfig.variant}
onConfirm={() => { void handleConfirm(); }}
onCancel={() => { setDialog(null); }}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,173 @@
import * as React from 'react';
/** Shape of the /health API response. */
interface HealthResponse {
status: 'ok' | 'degraded';
version?: string;
uptime?: number;
services: {
postgres: 'connected' | 'disconnected';
redis: 'connected' | 'disconnected';
};
}
type ServiceStatus = 'connected' | 'disconnected' | 'unknown';
interface HealthState {
postgres: ServiceStatus;
redis: ServiceStatus;
version: string | null;
uptime: number | null;
lastChecked: Date | null;
reachable: boolean;
}
const initialState: HealthState = {
postgres: 'unknown',
redis: 'unknown',
version: null,
uptime: null,
lastChecked: null,
reachable: true,
};
/** Formats seconds into a human-readable uptime string. */
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
parts.push(`${minutes}m`);
return parts.join(' ');
}
interface StatusCardProps {
label: string;
status: ServiceStatus;
}
/** Card displaying the connectivity status of a single service. */
function StatusCard({ label, status }: StatusCardProps): React.JSX.Element {
const isConnected = status === 'connected';
const isUnknown = status === 'unknown';
return (
<div className={`rounded-xl border p-6 shadow-sm ${
isUnknown
? 'border-slate-200 bg-slate-50'
: isConnected
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<p className="text-sm font-medium text-slate-600">{label}</p>
<div className="mt-2 flex items-center gap-2">
<span className={`inline-block h-3 w-3 rounded-full ${
isUnknown ? 'bg-slate-400' : isConnected ? 'bg-green-500' : 'bg-red-500'
}`} />
<span className={`text-lg font-semibold ${
isUnknown ? 'text-slate-600' : isConnected ? 'text-green-700' : 'text-red-700'
}`}>
{isUnknown ? 'Checking…' : isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
);
}
/**
* Health page — shows PostgreSQL and Redis connectivity status.
* Polls GET /health every 30 seconds. No authentication required.
* Route: /dashboard/health
*/
export default function Health(): React.JSX.Element {
const [health, setHealth] = React.useState<HealthState>(initialState);
const [loading, setLoading] = React.useState<boolean>(true);
const checkHealth = React.useCallback(async (): Promise<void> => {
try {
const response = await fetch('/health');
const data = (await response.json()) as HealthResponse;
setHealth({
postgres: data.services?.postgres ?? 'unknown',
redis: data.services?.redis ?? 'unknown',
version: data.version ?? null,
uptime: data.uptime ?? null,
lastChecked: new Date(),
reachable: true,
});
} catch {
setHealth((prev) => ({
...prev,
postgres: 'disconnected',
redis: 'disconnected',
lastChecked: new Date(),
reachable: false,
}));
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void checkHealth();
const interval = setInterval(() => { void checkHealth(); }, 30_000);
return () => { clearInterval(interval); };
}, [checkHealth]);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-slate-900">System Health</h1>
<button
onClick={() => { void checkHealth(); }}
disabled={loading}
className="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 disabled:opacity-40"
>
Refresh
</button>
</div>
{!health.reachable && (
<div className="mb-6 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
API is unreachable. Check that the server is running.
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<StatusCard label="PostgreSQL" status={loading ? 'unknown' : health.postgres} />
<StatusCard label="Redis" status={loading ? 'unknown' : health.redis} />
</div>
{/* Metadata */}
{(health.version !== null || health.uptime !== null) && (
<div className="mt-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-base font-semibold text-slate-900">API Details</h2>
<dl className="space-y-2">
{health.version !== null && (
<div className="flex gap-4">
<dt className="w-24 text-sm font-medium text-slate-500">Version</dt>
<dd className="text-sm text-slate-900">{health.version}</dd>
</div>
)}
{health.uptime !== null && (
<div className="flex gap-4">
<dt className="w-24 text-sm font-medium text-slate-500">Uptime</dt>
<dd className="text-sm text-slate-900">{formatUptime(health.uptime)}</dd>
</div>
)}
</dl>
</div>
)}
{/* Last checked */}
{health.lastChecked !== null && (
<p className="mt-4 text-xs text-slate-400">
Last checked: {health.lastChecked.toLocaleTimeString()} auto-refreshes every 30 seconds
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/lib/auth';
/**
* Login page — accepts API Base URL, Client ID, and Client Secret.
* Validates credentials against the AgentIdP token endpoint before persisting.
*/
export default function Login(): React.JSX.Element {
const { login } = useAuth();
const navigate = useNavigate();
const [baseUrl, setBaseUrl] = React.useState<string>(window.location.origin);
const [clientId, setClientId] = React.useState<string>('');
const [clientSecret, setClientSecret] = React.useState<string>('');
const [loading, setLoading] = React.useState<boolean>(false);
const [error, setError] = React.useState<string | null>(null);
const handleSubmit = React.useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const success = await login({ baseUrl: baseUrl.trim(), clientId: clientId.trim(), clientSecret });
if (success) {
navigate('/dashboard/agents', { replace: true });
} else {
setError('Invalid credentials. Please check your Client ID and secret.');
setClientSecret('');
}
} finally {
setLoading(false);
}
},
[login, navigate, baseUrl, clientId, clientSecret],
);
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4">
<div className="w-full max-w-md rounded-xl bg-white p-8 shadow-lg">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-brand-700">SentryAgent.ai</h1>
<p className="mt-1 text-sm text-slate-500">AgentIdP Dashboard Sign In</p>
</div>
<form onSubmit={(e) => { void handleSubmit(e); }} className="space-y-5">
<div>
<label htmlFor="baseUrl" className="block text-sm font-medium text-slate-700">
API Base URL
</label>
<input
id="baseUrl"
type="url"
required
value={baseUrl}
onChange={(e) => { setBaseUrl(e.target.value); }}
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
placeholder="https://api.example.com"
/>
</div>
<div>
<label htmlFor="clientId" className="block text-sm font-medium text-slate-700">
Client ID
</label>
<input
id="clientId"
type="text"
required
value={clientId}
onChange={(e) => { setClientId(e.target.value); }}
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
placeholder="agent-uuid"
autoComplete="username"
/>
</div>
<div>
<label htmlFor="clientSecret" className="block text-sm font-medium text-slate-700">
Client Secret
</label>
<input
id="clientSecret"
type="password"
required
value={clientSecret}
onChange={(e) => { setClientSecret(e.target.value); }}
className="mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
autoComplete="current-password"
/>
</div>
{error && (
<p className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">
{error}
</p>
)}
<Button type="submit" loading={loading} className="w-full" size="lg">
{loading ? 'Validating…' : 'Sign In'}
</Button>
</form>
</div>
</div>
);
}

1
dashboard/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brand: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
900: '#0c4a6e',
},
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

7
dashboard/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

17
dashboard/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
base: '/dashboard/',
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

View File

@@ -0,0 +1,50 @@
version: '3.8'
# Monitoring overlay — extend the base docker-compose.yml
# Usage: docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up
services:
prometheus:
image: prom/prometheus:v2.53.0
container_name: agentidp_prometheus
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/etc/prometheus/console_libraries'
- '--web.console.templates=/etc/prometheus/consoles'
- '--web.enable-lifecycle'
ports:
- '9090:9090'
networks:
- agentidp_network
restart: unless-stopped
grafana:
image: grafana/grafana:11.2.0
container_name: agentidp_grafana
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
environment:
- GF_SECURITY_ADMIN_PASSWORD=agentidp
- GF_USERS_ALLOW_SIGN_UP=false
- GF_AUTH_ANONYMOUS_ENABLED=false
ports:
- '3001:3000'
networks:
- agentidp_network
depends_on:
- prometheus
restart: unless-stopped
volumes:
prometheus_data:
grafana_data:
networks:
agentidp_network:
external: true

View File

@@ -0,0 +1,172 @@
# Audit Log Chain Verification Runbook — SentryAgent.ai AgentIdP
**Control:** SOC 2 CC7.2 — Audit Log Integrity
**Service:** `src/services/AuditVerificationService.ts`
**Job:** `src/jobs/AuditChainVerificationJob.ts`
**Endpoint:** `GET /api/v1/audit/verify`
---
## Overview
Every audit event in the `audit_events` PostgreSQL table is linked to the previous one
via a SHA-256 hash chain. Each event stores:
- `hash` — SHA-256 of `(eventId + timestamp.toISOString() + action + outcome + agentId + organizationId + previousHash)`
- `previous_hash` — the `hash` of the immediately preceding event (ordered by `timestamp ASC, event_id ASC`)
The first event in the chain uses `previous_hash = ''` (empty string sentinel).
A PostgreSQL trigger (`trg_audit_events_immutable`) prevents UPDATE and DELETE operations
on `audit_events`, making the log tamper-evident at the database level.
---
## Running GET /audit/verify
### Full chain verification (no date range)
```bash
# Requires Bearer token with audit:read scope
curl -s -H "Authorization: Bearer <token>" \
"https://api.sentryagent.ai/v1/audit/verify"
```
**Response (chain intact):**
```json
{
"verified": true,
"checkedCount": 18504,
"brokenAtEventId": null
}
```
**Response (chain break detected):**
```json
{
"verified": false,
"checkedCount": 1203,
"brokenAtEventId": "c4d5e6f7-a8b9-0123-cdef-456789012345"
}
```
### Date-ranged verification
```bash
curl -s -H "Authorization: Bearer <token>" \
"https://api.sentryagent.ai/v1/audit/verify?fromDate=2026-03-01T00:00:00.000Z&toDate=2026-03-31T23:59:59.999Z"
```
### Interpreting the response
| Field | Meaning |
|---|---|
| `verified: true` | All events in the checked range maintain valid hash chain linkage |
| `verified: false` | At least one chain break detected — see `brokenAtEventId` |
| `checkedCount` | Number of events examined (0 = no events in range) |
| `brokenAtEventId` | UUID of the first event where the chain fails (`null` if verified) |
| `fromDate` / `toDate` | Echo of the date range parameters (only present if supplied) |
---
## AuditChainVerificationJob
The `AuditChainVerificationJob` runs automatically in the background every hour (default).
Configure the interval via `AUDIT_CHAIN_VERIFICATION_INTERVAL_MS` (milliseconds).
On each tick it calls `verifyChain()` and:
- Sets Prometheus gauge `agentidp_audit_chain_integrity` to **1** (passing)
- Updates `ComplianceStatusStore` with `CC7.2 = passing`
If verification fails:
- Sets gauge to **0**
- Updates `ComplianceStatusStore` with `CC7.2 = failing`
- Prometheus alert `AuditChainIntegrityFailed` fires immediately (severity: critical)
- Application logs: `[AuditChainVerificationJob] Chain BROKEN at event <uuid>`
---
## What to Do When `brokenAtEventId` is Returned
### Step 1: Preserve Evidence
Immediately capture the full state of the audit log for forensic analysis:
```sql
-- Export all events around the break point
SELECT event_id, timestamp, action, outcome, agent_id, organization_id, hash, previous_hash
FROM audit_events
WHERE timestamp >= (
SELECT timestamp - INTERVAL '1 hour'
FROM audit_events WHERE event_id = '<brokenAtEventId>'
)
ORDER BY timestamp ASC, event_id ASC;
```
Save the output to a secure, immutable location (e.g. S3 with object locking).
### Step 2: Identify the Break Type
Compare the recomputed hash for the broken event with its stored hash:
```bash
# Using Node.js
node -e "
const crypto = require('crypto');
const eventId = '<event_id>';
const timestamp = '<timestamp_from_db>';
const action = '<action>';
const outcome = '<outcome>';
const agentId = '<agent_id>';
const orgId = '<organization_id>';
const prevHash = '<previous_hash_from_db>';
const expected = crypto.createHash('sha256')
.update(eventId + new Date(timestamp).toISOString() + action + outcome + agentId + orgId + prevHash)
.digest('hex');
console.log('Expected hash:', expected);
console.log('Stored hash: <hash_from_db>');
console.log('Match:', expected === '<hash_from_db>');
"
```
Possible break types:
- **Hash mismatch only** — event data was modified after insertion
- **previous_hash mismatch** — an event was inserted/deleted before this event in the chain
- **Both mismatched** — multiple modifications or an injection attack
### Step 3: Escalate
A chain break is a **critical security incident**. Immediately:
1. Notify the security team and CISO
2. Engage incident response procedure (`docs/compliance/incident-response.md` — Audit Chain Integrity Failure section)
3. Do NOT attempt to "fix" the hash — preserve the broken state as evidence
4. Consider temporarily suspending API access pending investigation
5. Notify affected customers per data breach notification obligations
### Step 4: Forensic Investigation
Using PostgreSQL audit logs, Vault audit logs, and application logs:
- Identify which application process or database connection modified the row
- Correlate with access logs and authentication events
- Determine the extent of the compromise (single row vs. systematic)
---
## Verification Rate Limiting
`GET /audit/verify` is rate-limited to **30 requests/minute** per `client_id`.
For continuous monitoring, use `AuditChainVerificationJob` (background job, no rate limit)
and poll `GET /compliance/controls` instead.
---
## SOC 2 Evidence Package
For auditors, provide:
1. `GET /audit/verify` response (full chain, no date filter) — save as JSON
2. Prometheus metric export: `agentidp_audit_chain_integrity` time series (30/60/90 days)
3. PostgreSQL trigger definition: `\d+ audit_events` in psql
4. `src/db/migrations/020_add_audit_chain_columns.sql` — shows immutability trigger DDL
5. `docs/openapi/compliance.yaml` — endpoint specification

View File

@@ -0,0 +1,159 @@
# Encryption Key Rotation Runbook — SentryAgent.ai AgentIdP
**Control:** SOC 2 CC6.1 — Encryption at Rest
**Service:** `src/services/EncryptionService.ts`
**Vault path:** Configured via `ENCRYPTION_KEY_VAULT_PATH` env var (default: `secret/data/agentidp/encryption-key`)
---
## Overview
AgentIdP uses AES-256-CBC column-level encryption for sensitive PostgreSQL columns.
The encryption key is a 64-character hex string (32 bytes) stored in HashiCorp Vault.
The `EncryptionService` fetches the key once and caches it in process memory.
Encrypted format: `base64(IV):base64(ciphertext)` where IV is 16 random bytes per encryption call.
---
## Key Rotation Procedure
### Prerequisites
- Access to HashiCorp Vault with write permissions to the encryption key path
- Access to the production application environment (to trigger restart)
- At least one backup of the current key stored securely offline
### Step 1: Generate a New Key
Generate a cryptographically strong 32-byte (64-character hex) key:
```bash
openssl rand -hex 32
# Example output: a1b2c3d4e5f6... (64 hex chars)
```
Record the new key securely.
### Step 2: Backup the Current Key
Before overwriting, read and securely store the current key:
```bash
vault kv get -field=encryptionKey secret/agentidp/encryption-key > /secure/backup/encryption-key-$(date +%Y%m%d).txt
```
Store in a hardware security module (HSM) or offline key store.
### Step 3: Write the New Key to Vault
```bash
vault kv put secret/agentidp/encryption-key encryptionKey="<new-64-char-hex-key>"
```
Verify the write:
```bash
vault kv get secret/agentidp/encryption-key
```
Confirm the `encryptionKey` field contains exactly 64 hex characters.
### Step 4: Restart the Application
The `EncryptionService` caches the key in process memory. A restart forces a re-fetch from Vault:
```bash
# Kubernetes rolling restart
kubectl rollout restart deployment/agentidp
# Docker Compose
docker-compose restart agentidp
# PM2
pm2 restart agentidp
```
### Step 5: Verify Key Pick-Up
Check the application logs for:
```
[AgentIdP] EncryptionService enabled — sensitive columns encrypted at rest (SOC 2 CC6.1)
```
Call the compliance controls endpoint to confirm the control is passing:
```bash
curl -s https://api.sentryagent.ai/v1/compliance/controls | jq '.controls[] | select(.id == "CC6.1")'
```
Expected output:
```json
{ "id": "CC6.1", "name": "Encryption at Rest", "status": "passing", "lastChecked": "..." }
```
### Step 6: Re-encryption of Existing Rows
Existing rows encrypted with the old key will fail to decrypt after key rotation.
Re-encryption happens lazily: the next time each row is read and re-written (e.g. credential rotation,
webhook update), the application will decrypt with the old key and re-encrypt with the new one.
For immediate full re-encryption, use the re-encryption script:
```bash
# Run the re-encryption migration script (reads old key from backup, encrypts with new key)
# Note: This script requires both old and new keys to be available
ts-node scripts/reencrypt-columns.ts --old-key-file /secure/backup/encryption-key-<date>.txt
```
---
## Emergency Rollback
If the new key causes issues (e.g. test failures, decryption errors), roll back:
### Step 1: Restore Old Key to Vault
```bash
vault kv put secret/agentidp/encryption-key encryptionKey="<old-64-char-hex-key-from-backup>"
```
### Step 2: Restart the Application
```bash
kubectl rollout restart deployment/agentidp
```
### Step 3: Verify Recovery
```bash
curl -s https://api.sentryagent.ai/v1/compliance/controls | jq '.controls[] | select(.id == "CC6.1")'
```
### Step 4: Investigate Root Cause
Review application logs for `AES-256-CBC decryption failed` errors and audit the cause before
reattempting rotation.
---
## Troubleshooting
| Symptom | Likely Cause | Resolution |
|---|---|---|
| `Invalid encryption key ... expected a 64-character hex string` | Key in Vault is wrong length or encoding | Re-write correct key to Vault, restart |
| `AES-256-CBC decryption failed — possible key mismatch` | Key rotated but rows still encrypted with old key | Rollback to old key, then migrate properly |
| `CC6.1` status shows `unknown` | Vault unreachable, key fetch failed | Check Vault connectivity, `VAULT_ADDR`, `VAULT_TOKEN` |
---
## Audit Evidence
After rotation, record the following for SOC 2 evidence:
- Date of rotation
- Who performed the rotation (approver + executor)
- Vault audit log entry confirming the key write
- Application log confirming EncryptionService initialised with new key
- `GET /compliance/controls` response showing CC6.1 = passing

View File

@@ -0,0 +1,229 @@
# Incident Response Runbook — SentryAgent.ai AgentIdP
**Owner:** Security Engineering
**Last updated:** 2026-03-31
**Applies to:** Production AgentIdP deployments
This runbook covers the four incident types most relevant to SOC 2 Type II compliance monitoring.
---
## 1. Auth Failure Spike
### Detection
**Prometheus alert:** `AuthFailureSpike`
```yaml
expr: rate(agentidp_http_requests_total{status_code="401"}[5m]) > 0.5
for: 2m
severity: warning
```
Triggers when the rate of HTTP 401 responses exceeds 0.5 per second sustained over 2 minutes.
### Immediate Actions
1. Acknowledge the alert in PagerDuty / alerting system
2. Check whether the spike correlates with a scheduled process (e.g. batch agent key rotation, deployment)
3. Check Prometheus dashboard for the geographic distribution of the failing requests
### Investigation Steps
1. **Identify source agents:**
```bash
# Query audit log for recent auth failures
curl -s -H "Authorization: Bearer <admin-token>" \
"https://api.sentryagent.ai/v1/audit?action=auth.failed&limit=100"
```
2. **Check for brute-force patterns:**
Look for repeated failures from the same `client_id` or IP address.
3. **Check if an agent's credentials expired:**
```bash
# Look for expired credentials
psql "$DATABASE_URL" -c "
SELECT credential_id, client_id, expires_at
FROM credentials
WHERE status = 'active' AND expires_at < NOW()
ORDER BY expires_at DESC LIMIT 20;"
```
4. **Check for key compromise signals:**
- Multiple agents failing simultaneously → possible key store issue
- Single agent with high failure rate → possible credential stuffing or misconfiguration
### Escalation Path
- **Warning (< 2 req/s):** Engineering on-call investigates within 1 hour
- **Critical (> 2 req/s sustained):** CISO notified, potential account compromise investigation
- **If credential compromise confirmed:** Revoke affected credentials immediately via `POST /agents/:id/credentials/:credId/revoke`
---
## 2. Anomalous Token Issuance
### Detection
**Prometheus alert:** `AnomalousTokenIssuance`
```yaml
expr: rate(agentidp_tokens_issued_total[5m]) > 10
for: 5m
severity: warning
```
Triggers when token issuance rate exceeds 10 per second for 5 continuous minutes.
### Immediate Actions
1. Acknowledge the alert
2. Determine if a legitimate mass-scale operation is underway (e.g. new customer onboarding, load test)
3. Check the `scope` label breakdown on `agentidp_tokens_issued_total` to identify what scopes are being requested
### Investigation Steps
1. **Identify top issuing agents:**
```bash
# Query audit log for recent token issuances
curl -s -H "Authorization: Bearer <admin-token>" \
"https://api.sentryagent.ai/v1/audit?action=token.issued&limit=100"
```
2. **Check monthly token budget:**
Each agent is limited to 10,000 tokens/month (free tier). A single agent hitting the limit may indicate automation abuse.
3. **Check for abnormal scope combinations:**
If tokens are being issued with `admin:orgs` or `audit:read` at high volume, this warrants immediate investigation.
4. **Check for valid business reason:**
Contact the organization owner for the top-issuing agents.
### Escalation Path
- **Warning:** Engineering on-call investigates within 4 hours
- **If compromise suspected:** Revoke affected agent tokens via Redis revocation list, rotate credentials
- **If systematic abuse confirmed:** Suspend the issuing agent(s) via `PATCH /agents/:id` with `status: suspended`
---
## 3. Audit Chain Integrity Failure
### Detection
**Prometheus alert:** `AuditChainIntegrityFailed`
```yaml
expr: agentidp_audit_chain_integrity == 0
for: 0m
severity: critical
```
Fires immediately when `AuditChainVerificationJob` detects a break in the audit event hash chain.
This is a **CRITICAL** security event — possible evidence of log tampering.
### Immediate Actions
1. **Do NOT attempt to repair the broken chain** — preserve all evidence
2. Notify CISO and security team immediately
3. Page the on-call security engineer with P0 priority
4. Capture the current state:
```bash
curl -s -H "Authorization: Bearer <audit-token>" \
"https://api.sentryagent.ai/v1/audit/verify" | tee /secure/incident-$(date +%Y%m%d-%H%M).json
```
### Investigation Steps
1. **Determine the broken event:**
The `brokenAtEventId` field in the `/audit/verify` response identifies the first broken event.
2. **Forensic analysis:**
Follow the steps in `docs/compliance/audit-log-runbook.md` — "What to Do When brokenAtEventId is Returned".
3. **Check database access logs:**
Review PostgreSQL `pg_stat_activity` and connection logs for unauthorized direct DB access.
4. **Check application logs:**
Look for any errors from the immutability trigger (`audit_events_immutable`).
5. **Check Vault audit logs:**
Review whether any encryption key access was abnormal.
### Escalation Path
- **Immediate:** CISO + Legal + Security Engineering
- **Within 1 hour:** Begin forensic preservation per incident response plan
- **Within 24 hours:** Determine scope of compromise and notification obligations
- **Customer notification:** Per contractual and regulatory obligations (GDPR, SOC 2 requirements)
---
## 4. Webhook Dead-Letter Accumulation
### Detection
**Prometheus alert:** `WebhookDeadLetterAccumulating`
```yaml
expr: increase(agentidp_webhook_dead_letters_total[1h]) > 10
for: 0m
severity: critical
```
Fires when more than 10 webhook deliveries reach dead-letter status within an hour.
### Immediate Actions
1. Acknowledge the alert
2. Check which `organization_id` labels are accumulating dead-letters:
```bash
# Prometheus query: top organizations by dead-letter rate
# agentidp_webhook_dead_letters_total (by organization_id)
```
3. Check if the destination endpoints are reachable:
```bash
curl -I https://<webhook-destination-url>/
```
### Investigation Steps
1. **List affected webhook subscriptions:**
```bash
# Query delivery records for dead-letter status
psql "$DATABASE_URL" -c "
SELECT s.id, s.organization_id, s.url, COUNT(d.id) AS dead_letters
FROM webhook_subscriptions s
JOIN webhook_deliveries d ON d.subscription_id = s.id
WHERE d.status = 'dead_letter'
AND d.updated_at > NOW() - INTERVAL '2 hours'
GROUP BY s.id
ORDER BY dead_letters DESC
LIMIT 20;"
```
2. **Check delivery failure reasons:**
```bash
psql "$DATABASE_URL" -c "
SELECT http_status_code, COUNT(*) as count
FROM webhook_deliveries
WHERE status = 'dead_letter'
AND updated_at > NOW() - INTERVAL '2 hours'
GROUP BY http_status_code;"
```
3. **Common causes and resolutions:**
| HTTP Status | Likely Cause | Resolution |
|---|---|---|
| 0 / null | Network unreachable / DNS failure | Check recipient endpoint availability |
| 401 / 403 | HMAC signature validation failing | Customer to verify HMAC secret |
| 404 | Endpoint URL changed | Customer to update webhook URL |
| 5xx | Recipient server error | Customer to investigate their endpoint |
| Timeout | Slow recipient endpoint | Customer to optimize endpoint response time |
4. **Notify affected customers:**
Contact the organization owner for high-volume dead-letter subscriptions.
### Escalation Path
- **Warning (10-50/hr):** Engineering notifies affected customers, investigates endpoint health
- **Critical (> 50/hr):** Engineering on-call + Platform reliability team engaged
- **If systemic delivery infrastructure failure:** Activate incident bridge, escalate to VP Engineering

View File

@@ -0,0 +1,142 @@
# Secrets Rotation Runbook — SentryAgent.ai AgentIdP
**Control:** SOC 2 CC9.2 — Secrets Rotation
**Last updated:** 2026-03-31
---
## Overview
AgentIdP manages three categories of secrets that require periodic rotation:
1. **Agent client secrets** — Per-credential client secrets used for OAuth 2.0 token issuance
2. **OIDC signing keys** — RSA/EC keys used to sign ID tokens
3. **AES-256-CBC encryption key** — Column-level database encryption key (see `encryption-runbook.md`)
---
## 1. Agent Credential (Client Secret) Rotation
### API endpoint
```
POST /api/v1/agents/:agentId/credentials/:credentialId/rotate
```
Requires Bearer token with `agents:write` scope.
### Procedure
```bash
# 1. List active credentials for the agent
curl -s -H "Authorization: Bearer <token>" \
"https://api.sentryagent.ai/v1/agents/<agentId>/credentials?status=active"
# 2. Rotate the credential (generate new secret)
curl -s -X POST \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"expiresAt": "2027-03-31T00:00:00.000Z"}' \
"https://api.sentryagent.ai/v1/agents/<agentId>/credentials/<credentialId>/rotate"
# Response includes the new clientSecret — store it immediately; it is never shown again
```
### Key points
- The new `clientSecret` is returned **once only** — store it securely before the response is discarded
- The agent's previous secret is immediately invalidated (Vault KV v2 version overwritten)
- An audit event `credential.rotated` is logged to the immutable audit chain
- A `credential.rotated` webhook event is dispatched to all active subscriptions
### Recommended rotation schedule
| Credential type | Recommended rotation interval |
|---|---|
| Production agent credentials | 90 days |
| Staging / development credentials | 180 days |
| Service account credentials | 365 days (annual) |
| Credentials involved in a security incident | Immediately |
### Automated expiry detection
`SecretsRotationJob` runs hourly and queries credentials expiring within 7 days.
Prometheus alert `CredentialExpiryApproaching` fires immediately when any are detected.
Respond to this alert by rotating the flagged credential(s) before the expiry date.
---
## 2. OIDC Signing Key Rotation
### Overview
OIDC signing keys are managed by `OIDCKeyService` (`src/services/OIDCKeyService.ts`).
Keys are stored in the `oidc_keys` PostgreSQL table. The current active key is used to
sign all new ID tokens; public keys are exposed via `GET /.well-known/jwks.json`.
### When to rotate
- Key compromise or suspected exposure
- Scheduled rotation (recommended every 90 days for production)
- Algorithm upgrade (e.g. RS256 → ES256)
### Rotation procedure
OIDC key rotation is handled automatically by `OIDCKeyService.ensureCurrentKey()`:
```bash
# Force generation of a new signing key by calling the internal rotate endpoint
# (or trigger by redeploying with OIDC_FORCE_KEY_ROTATION=true)
# 1. Mark current key as inactive (if manual rotation is required)
psql "$DATABASE_URL" -c "
UPDATE oidc_keys
SET active = false
WHERE active = true;"
# 2. Restart the application — ensureCurrentKey() will generate a new key on startup
kubectl rollout restart deployment/agentidp
```
### JWKS update behavior
- Old public keys remain in `GET /.well-known/jwks.json` for **24 hours** after rotation
(grace period for in-flight tokens)
- After the grace period, old keys are removed from the JWKS endpoint
- Redis JWKS cache TTL is configured by `JWKS_CACHE_TTL_SECONDS` (default: 3600)
### Impact on existing tokens
Existing valid tokens signed with the old key **continue to work** until they expire,
as long as the old public key remains in JWKS. After the grace period, old tokens
will fail verification.
---
## 3. Encryption Key Rotation
See `docs/compliance/encryption-runbook.md` for the full AES-256-CBC encryption key rotation procedure.
**Summary:** Generate new 32-byte hex key → write to Vault at `ENCRYPTION_KEY_VAULT_PATH` → restart app → existing rows re-encrypted lazily on next read-write cycle.
---
## Schedule Recommendations
| Secret Type | Production Interval | Staging Interval | Trigger for Immediate Rotation |
|---|---|---|---|
| Agent client secrets | 90 days | 180 days | Credential suspected compromised |
| OIDC signing keys | 90 days | 180 days | Key file exposed, algorithm upgrade |
| AES-256-CBC encryption key | 365 days (annual) | On demand | Key exposed, Vault breach, compliance audit requirement |
| Webhook HMAC secrets | Per customer policy | N/A | Webhook endpoint compromised |
---
## Compliance Evidence
For SOC 2 CC9.2 evidence collection:
- Prometheus metric history: `agentidp_credentials_expiring_soon_total`
- Audit log entries with `action: credential.rotated` — query via `GET /audit?action=credential.rotated`
- Key rotation records from Vault audit log
- This runbook + sign-off from Security Engineering

View File

@@ -0,0 +1,42 @@
# SOC 2 Type II Controls Matrix — SentryAgent.ai AgentIdP
This document maps the five in-scope SOC 2 Trust Services Criteria (TSC) controls to their
corresponding implementation artefacts, mechanisms, and automated verification methods.
---
## Controls Matrix
| Control ID | TSC Criterion Name | Implementation File | Mechanism | Automated Check |
|---|---|---|---|---|
| **CC6.1** | Encryption at Rest | `src/services/EncryptionService.ts` | AES-256-CBC column-level encryption on `credentials.secret_hash`, `credentials.vault_path`, `webhook_subscriptions.vault_secret_path`, `agent_did_keys.vault_key_path`. Key is stored in HashiCorp Vault KV v2 at path configured by `ENCRYPTION_KEY_VAULT_PATH`. IV is randomised per encryption call. Backward-compat: `isEncrypted()` gate allows plaintext rows to coexist during migration. | `GET /api/v1/compliance/controls` returns `CC6.1` status. Status is set to `passing` on service startup when `EncryptionService` initialises. |
| **CC6.7** | TLS Enforcement | `src/middleware/TLSEnforcementMiddleware.ts` | Express middleware registered as the **first** middleware in the app stack (before all routes and body parsers). In `NODE_ENV=production`, checks `X-Forwarded-Proto` header set by the upstream load balancer/reverse proxy. Any non-HTTPS request receives a `301 Moved Permanently` redirect to `https://`. | `GET /api/v1/compliance/controls` returns `CC6.7` status. TLS enforcement is a static configuration control; status is set to `passing` on application startup. |
| **CC7.2** | Audit Log Integrity | `src/services/AuditVerificationService.ts`, `src/repositories/AuditRepository.ts`, `src/jobs/AuditChainVerificationJob.ts` | Each audit event (`audit_events` table) stores a `hash` (SHA-256 of `eventId + timestamp + action + outcome + agentId + organizationId + previousHash`) and `previous_hash` linking it to the prior event. An immutability trigger prevents UPDATE/DELETE on `audit_events`. `AuditChainVerificationJob` re-walks the entire chain every hour. | Prometheus gauge `agentidp_audit_chain_integrity` (1 = passing, 0 = failing). Prometheus alert `AuditChainIntegrityFailed` fires when gauge = 0. `GET /api/v1/audit/verify` triggers an on-demand verification. `GET /api/v1/compliance/controls` returns `CC7.2` status. |
| **CC9.2** | Secrets Rotation | `src/jobs/SecretsRotationJob.ts` | `SecretsRotationJob` runs every hour (configurable via `SECRETS_ROTATION_CHECK_INTERVAL_MS`) and queries `credentials` for `active` credentials expiring within 7 days. For each, it increments the `agentidp_credentials_expiring_soon_total` Prometheus counter with the owning `agent_id`. Operators are expected to act on the alert within the 7-day window. | Prometheus counter `agentidp_credentials_expiring_soon_total` per `agent_id`. Prometheus alert `CredentialExpiryApproaching` fires when any increase is detected. `GET /api/v1/compliance/controls` returns `CC9.2` status. |
| **CC7.1** | Webhook Dead-Letter Monitoring | `src/workers/WebhookDeliveryWorker.ts` | `WebhookDeliveryWorker` processes webhook deliveries from a Redis queue. After exhausting all retry attempts (configurable `WEBHOOK_MAX_RETRIES`), the delivery is moved to dead-letter status and `agentidp_webhook_dead_letters_total` is incremented. | Prometheus counter `agentidp_webhook_dead_letters_total` per `organization_id`. Prometheus alert `WebhookDeadLetterAccumulating` fires when > 10 dead-letters accumulate in 1 hour. `GET /api/v1/compliance/controls` returns `CC7.1` status. |
---
## Evidence Collection
For a SOC 2 Type II audit, the following evidence should be collected:
| Evidence Type | Collection Method |
|---|---|
| Encryption at rest configuration | Export Vault KV v2 policy + `_encryption_migration_log` table contents |
| TLS certificate and enforcement logs | Load balancer access logs + `X-Forwarded-Proto` middleware responses |
| Audit chain integrity report | `GET /api/v1/audit/verify` with full date range |
| Secrets rotation compliance | Prometheus metric history for `agentidp_credentials_expiring_soon_total` |
| Webhook dead-letter rate | Prometheus metric history for `agentidp_webhook_dead_letters_total` |
| Immutable audit log dump | Direct PostgreSQL export of `audit_events` table with hash verification |
---
## References
- SOC 2 Trust Services Criteria: [AICPA TSC 2017](https://www.aicpa.org/resources/article/trust-services-criteria)
- OpenAPI spec: `docs/openapi/compliance.yaml`
- Encryption runbook: `docs/compliance/encryption-runbook.md`
- Audit log runbook: `docs/compliance/audit-log-runbook.md`
- Incident response: `docs/compliance/incident-response.md`
- Secrets rotation: `docs/compliance/secrets-rotation.md`

View File

@@ -1,6 +1,6 @@
# SentryAgent.ai AgentIdP — Developer Documentation # SentryAgent.ai AgentIdP — Developer Documentation
The complete documentation for bedroom developers building with SentryAgent.ai AgentIdP. The complete documentation for developers building with SentryAgent.ai AgentIdP.
## What is this? ## What is this?
@@ -19,10 +19,15 @@ SentryAgent.ai AgentIdP is a free, open-source Identity Provider built specifica
| Guide | What it covers | | Guide | What it covers |
|-------|----------------| |-------|----------------|
| [Register an Agent](guides/register-an-agent.md) | All fields, validation rules, common errors | | [Register an Agent](guides/register-an-agent.md) | All registration fields, org scoping, validation rules, common errors |
| [Manage Credentials](guides/manage-credentials.md) | Generate, list, rotate, revoke credentials | | [Manage Credentials](guides/manage-credentials.md) | Generate, list, rotate, revoke credentials |
| [Issue and Revoke Tokens](guides/issue-and-revoke-tokens.md) | OAuth 2.0 client credentials flow, introspect, revoke | | [Issue and Revoke Tokens](guides/issue-and-revoke-tokens.md) | OAuth 2.0 client credentials flow, introspect, revoke |
| [Query Audit Logs](guides/query-audit-logs.md) | Filters, pagination, event structure, retention | | [Query Audit Logs](guides/query-audit-logs.md) | Filters, pagination, event structure, retention |
| [Use the Analytics Dashboard](guides/use-analytics-dashboard.md) | Query token trends, activity heatmap, per-agent usage |
| [Manage API Tiers](guides/manage-api-tiers.md) | Check current tier, understand limits, trigger upgrade |
| [A2A Delegation](guides/a2a-delegation.md) | Create and verify agent-to-agent delegation chains |
| [Configure Webhooks](guides/configure-webhooks.md) | Subscribe to events, delivery guarantees, inspect history |
| [AGNTCY Compliance](guides/agntcy-compliance.md) | Export agent cards, generate compliance reports, verify audit chain |
## Base URL ## Base URL

File diff suppressed because it is too large Load Diff

View File

@@ -126,3 +126,215 @@ AgentIdP is free. These are the limits on the free tier:
| Audit log retention | 90 days | Events older than 90 days are automatically purged; queries return empty results | | Audit log retention | 90 days | Events older than 90 days are automatically purged; queries return empty results |
The monthly token counter resets on the first day of each calendar month. The rate limit window resets every 60 seconds; the reset timestamp is in the `X-RateLimit-Reset` response header. The monthly token counter resets on the first day of each calendar month. The rate limit window resets every 60 seconds; the reset timestamp is in the `X-RateLimit-Reset` response header.
---
## Organizations and Multi-tenancy
An **organization** is the top-level grouping unit in AgentIdP. Every registered agent can be
scoped to an organization by including an `organization_id` in the agent registration request.
Organizations have a unique `slug` (URL-safe identifier), a display `name`, and a `planTier`
that controls per-org resource limits. All API operations that involve analytics, webhooks, tiers,
and delegation are tenant-scoped: they only see data belonging to their organization.
**Tenant isolation** is enforced at the service layer. Every query involving multi-tenant data
filters by `organization_id`. A token issued to an agent in org A cannot read data from org B.
The `organization_id` is embedded in the JWT at token issuance time and validated on every
request. This means you do not need to pass an org ID as a query parameter — it is derived
automatically from the authenticated token.
When you create an organization, you define its `slug`. Slugs are immutable — once set, they
cannot be changed. Choose a slug that matches your domain or product namespace, as it is used
in DID identifiers for agents in that organization. Membership is managed through the
`POST /api/v1/organizations/{orgId}/members` endpoint, which lets you add an existing agent
to an organization with a `member` or `admin` role.
| Field | Type | Description |
|-------|------|-------------|
| `organizationId` | UUID | System-assigned immutable identifier |
| `name` | string | Human-readable display name |
| `slug` | string | URL-safe unique identifier (immutable after creation) |
| `planTier` | enum | `free` \| `pro` \| `enterprise` |
| `maxAgents` | integer | Maximum active agents in this org |
| `maxTokensPerMonth` | integer | Maximum token issuances per month |
| `status` | enum | `active` \| `suspended` \| `deleted` |
---
## DID Identity
Every agent registered in AgentIdP automatically receives a **Decentralized Identifier (DID)**
using the `did:web` method. A DID is a globally unique, self-describing identifier that does not
rely on a central registry. The DID for an agent takes the form
`did:web:<host>:agents:<agentId>` — for example,
`did:web:localhost%3A3000:agents:a1b2c3d4-e5f6-7890-abcd-ef1234567890`. The `did:web` method
means the DID document is resolvable via HTTPS: a resolver fetches
`https://<host>/api/v1/agents/<agentId>/did`.
The **DID Document** is a JSON-LD object that describes the agent's cryptographic keys and
service endpoints. It contains: the agent's DID as its `id`, a `verificationMethod` array with
the agent's public key in JWK format, an `authentication` array referencing that key, and an
`agntcy` extension object carrying agent metadata (type, capabilities, version, owner,
deploymentEnv). This document is publicly accessible — no authentication required — so any
external system can verify this agent's identity without contacting AgentIdP directly.
The `did:web` scheme was chosen because it is widely supported by DID resolvers, requires no
blockchain, and leverages standard HTTPS infrastructure. When an external system receives a
token from your agent, it can resolve your agent's DID, retrieve the public key from the DID
Document, and independently verify the token's signature. This is the foundation of
cross-system agent identity verification.
```
DID Document structure for a registered agent
───────────────────────────────────────────────
{
"@context": ["https://www.w3.org/ns/did/v1"],
"id": "did:web:<host>:agents:<agentId>",
"controller": "did:web:<host>:agents:<agentId>",
"verificationMethod": [
{
"id": "<did>#key-1",
"type": "JsonWebKey2020",
"controller": "<did>",
"publicKeyJwk": { "kty": "RSA", ... }
}
],
"authentication": ["<did>#key-1"],
"agntcy": {
"agentId": "<uuid>",
"agentType": "screener",
"capabilities": ["resume:read"],
"deploymentEnv": "production",
"owner": "talent-team",
"version": "1.0.0"
}
}
```
---
## OIDC Provider
AgentIdP implements a subset of the **OpenID Connect (OIDC)** protocol, acting as an OIDC
Provider for the agents it manages. This means AgentIdP publishes a standard discovery
document at `GET /.well-known/openid-configuration`, which any OIDC-aware client can use to
discover supported grant types, token endpoint, JWKS URI, and other metadata. It also exposes
a JWKS endpoint at `GET /.well-known/jwks.json` for external systems to retrieve the public
keys used to verify tokens.
The **`/agent-info` endpoint** is the equivalent of OIDC's UserInfo endpoint — it returns
identity claims for the authenticated agent. External systems that receive a token issued by
AgentIdP can call this endpoint (with that token) to retrieve the agent's verified identity
attributes: its `agentId`, `email`, `agentType`, `capabilities`, and `organization_id`. This
is particularly useful when a downstream service needs to verify the identity of an agent
presenting a token, without duplicating identity data in its own store.
AgentIdP also supports **OIDC token exchange for GitHub Actions**. If you run your agent
deployment workflows in GitHub Actions, you can configure a trust policy
(`POST /api/v1/oidc/trust-policies`) that maps a GitHub repository and branch to an AgentIdP
agent. The workflow can then exchange its GitHub OIDC JWT for an AgentIdP access token via
`POST /api/v1/oidc/token` — no stored secrets required. This enables keyless, short-lived
token issuance in CI/CD pipelines.
---
## A2A Delegation
**Agent-to-Agent (A2A) delegation** allows one agent to grant another agent a subset of its own
OAuth 2.0 scopes for a limited time. This is the building block for multi-agent pipelines where
an orchestrator agent needs to delegate work to a specialist sub-agent without sharing its own
full credentials. A delegation chain consists of: a delegator (the agent granting authority),
a delegatee (the agent receiving authority), a set of scopes (must be a strict subset of the
delegator's own scopes), and a TTL (60 seconds to 86,400 seconds).
The **grant flow** is straightforward: the delegator calls `POST /api/v1/oauth2/token/delegate`
with the delegatee's agent ID, the scopes to grant, and the TTL. AgentIdP returns a signed
delegation token. The delegatee presents this token when calling
`POST /api/v1/oauth2/token/verify-delegation` to prove it has been granted authority. AgentIdP
verifies the chain integrity and returns the delegation details including whether it is still
valid. The delegator can revoke the chain at any time via
`DELETE /api/v1/oauth2/token/delegate/{chainId}`.
Delegation is useful for: workflow handoffs between specialist agents, granting a monitoring
agent read-only access to resources owned by a processing agent, and time-limited cross-agent
authorization without credential sharing. Because delegation tokens are signed and verified
server-side, a delegatee cannot extend the TTL, expand the scope, or pass the delegation to a
third agent. The chain is always exactly two hops: delegator → delegatee.
```
A2A Delegation Flow
───────────────────
1. Orchestrator (delegator) calls POST /api/v1/oauth2/token/delegate
→ body: { delegateeAgentId, scopes: ["agents:read"], ttlSeconds: 3600 }
← response: { delegationToken: "...", chainId: "...", expiresAt: "..." }
2. Orchestrator passes delegationToken to the sub-agent out-of-band
3. Sub-agent (delegatee) calls POST /api/v1/oauth2/token/verify-delegation
→ body: { delegationToken: "..." }
← response: { valid: true, scopes: ["agents:read"], expiresAt: "..." }
4. Sub-agent uses its own Bearer token + confirmed scope to act on behalf
5. (Optional) Orchestrator calls DELETE /api/v1/oauth2/token/delegate/{chainId}
to revoke early
```
---
## API Tier Plans
AgentIdP has three subscription tiers: **Free**, **Pro**, and **Enterprise**. Every organization
is on one tier at a time. The tier determines the resource limits enforced at runtime: maximum
number of active agents, maximum API calls per day, and maximum token issuances per day. When a
limit is reached, the relevant operation returns a `403 FREE_TIER_LIMIT_EXCEEDED` error until the
next calendar day resets the counter (for daily limits) or until you upgrade your tier.
You can check your current tier, configured limits, and live usage at any time by calling
`GET /api/v1/tiers/status`. The response shows your tier name, all three limit values, and the
live usage counters for the current day. If you need higher limits, call
`POST /api/v1/tiers/upgrade` with `{ "target_tier": "pro" }` or `"enterprise"`. This creates a
Stripe Checkout Session and returns a one-time `checkoutUrl`. After payment, the organization's
tier is updated automatically via Stripe webhook.
Enterprise tier limits are effectively unlimited (enforced as `Infinity` in the tier
configuration). Enterprise customers should contact SentryAgent.ai to arrange billing and
configure custom limits if needed. The `maxAgents` and `maxTokensPerMonth` fields on an
organization record can be overridden at org creation or update to set tighter or looser limits
than the tier defaults, regardless of tier.
| Limit | Free | Pro | Enterprise |
|-------|------|-----|------------|
| Max agents | 10 | 100 | Unlimited |
| Max API calls / day | 1,000 | 50,000 | Unlimited |
| Max token issuances / day | 1,000 | 50,000 | Unlimited |
| Audit log retention | 90 days | 90 days | 90 days |
| Webhooks | Yes | Yes | Yes |
| Analytics | Yes | Yes | Yes |
| A2A Delegation | Yes | Yes | Yes |
---
## AGNTCY Compliance
**AGNTCY** is an open standard from the Linux Foundation that defines how AI agents should be
identified, described, and governed across platforms. AgentIdP implements AGNTCY compliance
in two ways: every agent automatically gets a DID and an agent card (a structured JSON object
that describes the agent in the AGNTCY format), and AgentIdP can generate a **compliance
report** that summarizes the verified state of all agents in a tenant. An agent card is the
AGNTCY equivalent of a business card — it carries the agent's DID, type, capabilities, owner,
version, and identity provider.
The **compliance report** (available at `GET /api/v1/compliance/report`) covers two dimensions:
agent-identity verification (are all active agents reachable via their DID?) and audit-trail
integrity (is the hash chain of audit events intact?). The report includes a boolean
`agntcyConformance` field that summarizes whether the tenant meets AGNTCY baseline requirements.
Reports are cached in Redis for 5 minutes; the `X-Cache: HIT` header signals a cached response.
For self-auditing and external audits, you can export all active agents as AGNTCY agent cards
in bulk via `GET /api/v1/compliance/agent-cards`. This is an array of card objects that
external compliance tools and AGNTCY-compatible registries can ingest directly. The
`GET /api/v1/compliance/controls` endpoint (no authentication required) provides a live
status snapshot of all SOC 2 Trust Services Criteria controls that AgentIdP monitors internally.
These endpoints are gated by the `COMPLIANCE_ENABLED` environment variable; if disabled, they
return `404`.

View File

@@ -4,9 +4,14 @@ Step-by-step walkthroughs for each AgentIdP workflow.
| Guide | What it covers | | Guide | What it covers |
|-------|----------------| |-------|----------------|
| [Register an Agent](register-an-agent.md) | All registration fields, validation rules, common errors and fixes | | [Register an Agent](register-an-agent.md) | All registration fields, organization scoping, validation rules, common errors |
| [Manage Credentials](manage-credentials.md) | Generate, list, rotate, and revoke credentials | | [Manage Credentials](manage-credentials.md) | Generate, list, rotate, and revoke credentials |
| [Issue and Revoke Tokens](issue-and-revoke-tokens.md) | OAuth 2.0 Client Credentials flow, JWT structure, introspect, revoke | | [Issue and Revoke Tokens](issue-and-revoke-tokens.md) | OAuth 2.0 Client Credentials flow, JWT structure, introspect, revoke |
| [Query Audit Logs](query-audit-logs.md) | Filters, pagination, event structure, 90-day retention | | [Query Audit Logs](query-audit-logs.md) | Filters, pagination, event structure, 90-day retention |
| [Use the Analytics Dashboard](use-analytics-dashboard.md) | Query token trends, agent activity heatmap, and per-agent usage |
| [Manage API Tiers](manage-api-tiers.md) | Check current tier, understand limits, trigger a Stripe upgrade |
| [A2A Delegation](a2a-delegation.md) | Create and verify agent-to-agent delegation chains |
| [Configure Webhooks](configure-webhooks.md) | Subscribe to events, understand delivery guarantees, inspect history |
| [AGNTCY Compliance](agntcy-compliance.md) | Export agent cards, generate compliance reports, verify audit chain |
All guides assume you have a running local server and a valid Bearer token. See the [Quick Start](../quick-start.md) if you haven't done that yet. All guides assume you have a running local server and a valid Bearer token. See the [Quick Start](../quick-start.md) if you haven't done that yet.

View File

@@ -0,0 +1,167 @@
# A2A Delegation
Agent-to-Agent (A2A) delegation lets one agent grant another agent a subset of its OAuth 2.0
scopes for a defined period. This is the foundation for building secure multi-agent pipelines
where an orchestrator agent coordinates specialist sub-agents.
---
## Prerequisites
- A running AgentIdP instance
- Two registered agents: the delegator (has a Bearer token) and the delegatee (knows its
`agentId`)
- The delegator's scopes must be a superset of the scopes it wants to delegate
---
## How delegation works
```
Delegator agent Delegatee agent
| |
|-- POST /oauth2/token/delegate ----------->| (creates chain server-side)
|<-- { delegationToken, chainId, scopes } --|
| |
|-- passes delegationToken out-of-band ---->|
| |
| POST /oauth2/token/verify-delegation
| <-- { valid: true, scopes, expiresAt }
| |
| (optional) DELETE /oauth2/token/delegate/{chainId}
```
---
## Step 1 — Create a delegation chain
The delegator agent creates the chain by specifying the delegatee's `agentId`, the scopes to
delegate (must be a strict subset of the delegator's own scopes), and the TTL in seconds.
```bash
curl -s -X POST http://localhost:3000/api/v1/oauth2/token/delegate \
-H "Authorization: Bearer $DELEGATOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"delegateeAgentId": "'$DELEGATEE_AGENT_ID'",
"scopes": ["agents:read"],
"ttlSeconds": 3600
}' | jq .
```
Response (`201 Created`):
```json
{
"delegationToken": "sa_del_a1b2c3d4e5f6...",
"chainId": "d4e5f6a7-b8c9-0123-def0-123456789abc",
"delegatorAgentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"delegateeAgentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"scopes": ["agents:read"],
"expiresAt": "2026-04-04T10:00:00.000Z"
}
```
Save the `delegationToken` and `chainId`:
```bash
export DELEGATION_TOKEN="sa_del_a1b2c3d4e5f6..."
export CHAIN_ID="d4e5f6a7-b8c9-0123-def0-123456789abc"
```
**TTL constraints**: minimum 60 seconds, maximum 86400 seconds (24 hours). Choose the minimum
TTL that covers the delegatee's task.
---
## Step 2 — Pass the delegation token to the delegatee
Pass `DELEGATION_TOKEN` to the delegatee agent out-of-band. This can be via a shared queue,
a direct API call to the sub-agent, or any other channel. The token is a signed opaque string —
do not parse it; treat it as an opaque credential.
---
## Step 3 — Verify the delegation token
The delegatee (or any agent checking the delegation) calls the verify endpoint. This confirms
the chain is valid and not expired or revoked.
```bash
curl -s -X POST http://localhost:3000/api/v1/oauth2/token/verify-delegation \
-H "Authorization: Bearer $DELEGATEE_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "delegationToken": "'$DELEGATION_TOKEN'" }' | jq .
```
Response (`200 OK` — valid delegation):
```json
{
"valid": true,
"chainId": "d4e5f6a7-b8c9-0123-def0-123456789abc",
"delegatorAgentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"delegateeAgentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"scopes": ["agents:read"],
"issuedAt": "2026-04-04T09:00:00.000Z",
"expiresAt": "2026-04-04T10:00:00.000Z",
"revokedAt": null
}
```
Response (`200 OK` — expired delegation):
```json
{
"valid": false,
"chainId": "d4e5f6a7-b8c9-0123-def0-123456789abc",
"delegatorAgentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"delegateeAgentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"scopes": ["agents:read"],
"issuedAt": "2026-04-03T09:00:00.000Z",
"expiresAt": "2026-04-03T10:00:00.000Z",
"revokedAt": null
}
```
> The verify endpoint always returns `200 OK`. Check the `valid` field — it is never an error
> response for an expired or revoked token.
---
## Step 4 — (Optional) Revoke the delegation early
If the delegatee has completed its task and you want to revoke the delegation before it expires,
the delegator calls:
```bash
curl -s -X DELETE "http://localhost:3000/api/v1/oauth2/token/delegate/$CHAIN_ID" \
-H "Authorization: Bearer $DELEGATOR_TOKEN" \
-o /dev/null -w "%{http_code}\n"
```
Expected response: `204` (no body).
After revocation, verify requests for this chain return `{ "valid": false, "revokedAt": "<timestamp>" }`.
---
## Scope rules
- Delegated scopes must be a strict subset of the delegator's own token scopes
- You cannot delegate scopes you do not have
- You cannot delegate to yourself (delegateeAgentId must differ from delegatorAgentId)
- Delegation is not transitive — a delegatee cannot re-delegate to a third agent
---
## Common errors
### `400 VALIDATION_ERROR` — scope not a subset
The delegator attempted to delegate a scope it does not hold. Check `GET /api/v1/token/introspect`
to confirm which scopes your token carries.
### `400 VALIDATION_ERROR` — ttlSeconds out of range
Min: 60, Max: 86400. Values outside this range return a validation error.

View File

@@ -0,0 +1,191 @@
# AGNTCY Compliance
This guide explains how to use AgentIdP's AGNTCY compliance features: exporting agent cards,
generating compliance reports, verifying audit chain integrity, and checking SOC 2 control status.
---
## Prerequisites
- A running AgentIdP instance
- `COMPLIANCE_ENABLED` environment variable not set to `false` (enabled by default)
- A valid Bearer token (for authenticated endpoints)
- At least one registered agent
---
## What is AGNTCY?
AGNTCY is an open standard from the Linux Foundation for AI agent identity and governance.
AgentIdP implements AGNTCY by giving every agent a DID and an agent card. The compliance
endpoints let you export and report on that data in structured, auditable formats.
---
## Export agent cards
`GET /api/v1/compliance/agent-cards`
Exports all active agents in your organization as AGNTCY-standard agent card JSON objects.
Suitable for ingestion by external compliance tools or AGNTCY-compatible registries.
```bash
curl -s "http://localhost:3000/api/v1/compliance/agent-cards" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response (`200 OK`): Array of agent card objects.
```json
[
{
"did": "did:web:localhost%3A3000:agents:a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "screener-001@talent.ai",
"agentType": "screener",
"capabilities": ["resume:read", "email:send"],
"owner": "talent-team",
"version": "1.0.0",
"deploymentEnv": "production",
"identityProvider": "https://sentryagent.ai",
"issuedAt": "2026-04-04T09:00:00.000Z"
}
]
```
**Use cases**:
- Share with external auditors to demonstrate your agent fleet
- Import into AGNTCY-compatible discovery registries
- Baseline snapshot before and after deployments
Save the output to a file:
```bash
curl -s "http://localhost:3000/api/v1/compliance/agent-cards" \
-H "Authorization: Bearer $TOKEN" > agent-cards-$(date +%Y%m%d).json
```
---
## Generate a compliance report
`GET /api/v1/compliance/report`
Generates an AGNTCY compliance report for your tenant. The report is cached for 5 minutes
(check the `X-Cache` header to see if the response is fresh or cached).
```bash
curl -s "http://localhost:3000/api/v1/compliance/report" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response (`200 OK`):
```json
{
"tenantId": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"generatedAt": "2026-04-04T09:00:00.000Z",
"agntcyConformance": true,
"agentCount": 12,
"verifiedAgentCount": 12,
"auditChainIntegrity": true,
"from_cache": false
}
```
**Interpreting the fields**:
| Field | Description |
|-------|-------------|
| `agntcyConformance` | `true` if all agents have valid DIDs and the audit chain is intact |
| `agentCount` | Total active agents in the organization |
| `verifiedAgentCount` | Agents with a resolvable DID document |
| `auditChainIntegrity` | `true` if the audit event hash chain has not been tampered with |
| `from_cache` | `true` if served from Redis cache (up to 5 minutes old) |
**Force a fresh report**: Wait 5 minutes for the cache to expire. The `from_cache: false`
response is always freshly generated.
---
## Verify audit chain integrity
`GET /api/v1/audit/verify`
Verifies that the cryptographic hash chain of audit events is intact. Returns `verified: true`
if no tampering is detected. Rate limited to 30 requests/minute (computationally intensive).
Requires: Bearer token with `audit:read` scope.
```bash
curl -s "http://localhost:3000/api/v1/audit/verify" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response (`200 OK`):
```json
{
"verified": true,
"checkedCount": 1247,
"fromDate": null,
"toDate": null
}
```
Verify a specific date window:
```bash
curl -s "http://localhost:3000/api/v1/audit/verify?fromDate=2026-03-01T00:00:00.000Z&toDate=2026-03-31T23:59:59.999Z" \
-H "Authorization: Bearer $TOKEN" | jq .
```
**Interpreting the result**:
- `verified: true` — no tampering detected in the checked window
- `verified: false` — the hash chain has a broken link; contact SentryAgent.ai support
- `checkedCount` — number of audit events verified
---
## Check SOC 2 control status (public)
`GET /api/v1/compliance/controls`
Returns the live status of all SOC 2 Trust Services Criteria controls. No authentication
required. Responses are cached by CDN/proxies for 60 seconds (`Cache-Control: public, max-age=60`).
```bash
curl -s "http://localhost:3000/api/v1/compliance/controls" | jq .
```
Response (`200 OK`):
```json
{
"controls": [
{
"id": "CC6.1",
"name": "Logical Access Controls",
"status": "pass",
"lastChecked": "2026-04-04T08:00:00.000Z"
},
{
"id": "CC7.2",
"name": "System Monitoring",
"status": "pass",
"lastChecked": "2026-04-04T08:00:00.000Z"
}
]
}
```
Each control has a `status` of `pass`, `fail`, or `unknown`. Status is updated by background
jobs that run periodically. This endpoint is suitable for embedding in external status pages
or compliance dashboards without sharing API credentials.
---
## When compliance endpoints are disabled
If `COMPLIANCE_ENABLED=false` is set in the server environment, the AGNTCY compliance endpoints
(`/compliance/report` and `/compliance/agent-cards`) return `404 COMPLIANCE_DISABLED`. The SOC 2
endpoints (`/compliance/controls` and `/audit/verify`) are never gated and always active.

View File

@@ -0,0 +1,219 @@
# Configure Webhooks
Webhooks let AgentIdP push real-time events to your application when agents, credentials, or
tokens change state. This guide covers creating subscriptions, the available event types,
delivery guarantees, and how to inspect delivery history.
---
## Prerequisites
- A running AgentIdP instance
- A valid Bearer token with `organization_id` in its claims
- A publicly reachable HTTPS endpoint to receive events (for local development, use a tool
like [ngrok](https://ngrok.com))
---
## Available event types
| Event type | Triggered when |
|-----------|----------------|
| `agent.created` | A new agent is registered |
| `agent.updated` | An agent's metadata is updated |
| `agent.suspended` | An agent's status changes to `suspended` |
| `agent.reactivated` | An agent's status changes from `suspended` to `active` |
| `agent.decommissioned` | An agent is decommissioned |
| `credential.generated` | New credentials are created for an agent |
| `credential.rotated` | A credential's secret is rotated |
| `credential.revoked` | A credential is revoked |
| `token.issued` | An access token is issued |
| `token.revoked` | An access token is revoked |
---
## Create a subscription
`POST /api/v1/webhooks`
```bash
curl -s -X POST http://localhost:3000/api/v1/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "prod-agent-events",
"url": "https://my-app.example.com/hooks/sentryagent",
"events": ["agent.created", "agent.decommissioned", "token.issued"]
}' | jq .
```
Response (`201 Created`):
```json
{
"id": "wh-1a2b3c4d-e5f6-7890-abcd-ef1234567890",
"organization_id": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"name": "prod-agent-events",
"url": "https://my-app.example.com/hooks/sentryagent",
"events": ["agent.created", "agent.decommissioned", "token.issued"],
"active": true,
"signingSecret": "whsec_a1b2c3d4e5f6789...",
"failure_count": 0,
"created_at": "2026-04-04T09:00:00.000Z",
"updated_at": "2026-04-04T09:00:00.000Z"
}
```
> **Save the `signingSecret` now.** It is shown once. Use it to verify the HMAC-SHA256
> signature on incoming webhook requests. See "Verifying delivery signatures" below.
```bash
export WEBHOOK_ID="wh-1a2b3c4d-e5f6-7890-abcd-ef1234567890"
export SIGNING_SECRET="whsec_a1b2c3d4e5f6789..."
```
---
## Webhook payload format
Every delivery sends a POST to your URL with `Content-Type: application/json` and this body:
```json
{
"id": "evt-uuid-here",
"event": "agent.created",
"timestamp": "2026-04-04T09:00:00.000Z",
"organization_id": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"data": {
"agentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"email": "screener-001@talent.ai",
"agentType": "screener"
}
}
```
The `data` object contains event-specific fields. For `agent.*` events it includes agent
metadata. For `credential.*` events it includes `credentialId` and `agentId`. For `token.*`
events it includes `agentId` and `scope`.
---
## Verifying delivery signatures
AgentIdP signs every delivery with HMAC-SHA256 using your `signingSecret`. The signature is
in the `X-SentryAgent-Signature` header as `sha256=<hex-digest>`.
Verify it in Node.js:
```javascript
const crypto = require('crypto');
function verifySignature(rawBody, signingSecret, signatureHeader) {
const expected = 'sha256=' + crypto
.createHmac('sha256', signingSecret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signatureHeader)
);
}
```
Always verify the signature before processing the event. Reject requests with invalid signatures
with `401 Unauthorized`.
---
## Delivery guarantees and retry policy
- AgentIdP delivers each event **at least once** — your endpoint may receive duplicates
- Use the `id` field to deduplicate events
- Delivery is attempted immediately; on failure, retries use exponential backoff
- After repeated failures, the delivery moves to `dead_letter` status
- Subscriptions with high `failure_count` may be automatically disabled
Delivery statuses: `pending``delivered` (success) or `failed` (attempt failed) → `dead_letter`
(all retries exhausted)
---
## List subscriptions
```bash
curl -s "http://localhost:3000/api/v1/webhooks" \
-H "Authorization: Bearer $TOKEN" | jq .
```
---
## Pause or resume a subscription
To pause (disable) a subscription without deleting it:
```bash
curl -s -X PATCH "http://localhost:3000/api/v1/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "active": false }' | jq .
```
To resume:
```bash
curl -s -X PATCH "http://localhost:3000/api/v1/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "active": true }' | jq .
```
---
## Inspect delivery history
`GET /api/v1/webhooks/{id}/deliveries`
```bash
curl -s "http://localhost:3000/api/v1/webhooks/$WEBHOOK_ID/deliveries?limit=20&offset=0" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response:
```json
{
"deliveries": [
{
"id": "del-uuid",
"subscription_id": "wh-uuid",
"event_type": "agent.created",
"payload": { ... },
"status": "delivered",
"http_status_code": 200,
"attempt_count": 1,
"next_retry_at": null,
"delivered_at": "2026-04-04T09:00:01.000Z",
"created_at": "2026-04-04T09:00:00.000Z",
"updated_at": "2026-04-04T09:00:01.000Z"
}
],
"total": 47,
"limit": 20,
"offset": 0
}
```
Use `offset` to paginate through delivery history. Increase `limit` to retrieve more records
per page (the server default is 20).
---
## Delete a subscription
```bash
curl -s -X DELETE "http://localhost:3000/api/v1/webhooks/$WEBHOOK_ID" \
-H "Authorization: Bearer $TOKEN" \
-o /dev/null -w "%{http_code}\n"
```
Expected response: `204`. This permanently deletes the subscription and all its delivery records.

View File

@@ -47,10 +47,13 @@ The token expires in `3600` seconds (1 hour). Request a new one before it expire
| Scope | What it allows | | Scope | What it allows |
|-------|----------------| |-------|----------------|
| `agents:read` | Read agent records | | `agents:read` | Read agent identity records |
| `agents:write` | Create, update, decommission agents | | `agents:write` | Create, update, and decommission agents |
| `tokens:read` | Introspect tokens | | `tokens:read` | Introspect tokens |
| `audit:read` | Query audit logs | | `audit:read` | Query audit logs and verify audit chain integrity |
| `webhooks:read` | List webhook subscriptions and delivery history |
| `webhooks:write` | Create, update, and delete webhook subscriptions |
| `admin:orgs` | Manage organizations and federation partners |
Request only the scopes your agent needs. Request only the scopes your agent needs.

View File

@@ -0,0 +1,140 @@
# Manage API Tiers
This guide explains how to check your organization's current plan tier, understand the enforced
limits, and initiate an upgrade via Stripe.
---
## Prerequisites
- A running AgentIdP instance
- A valid Bearer token with `organization_id` in its claims
---
## Check current tier status
`GET /api/v1/tiers/status`
Returns your organization's tier, the configured limits, and live usage counters for today.
```bash
curl -s "http://localhost:3000/api/v1/tiers/status" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response:
```json
{
"tier": "free",
"limits": {
"maxAgents": 10,
"maxCallsPerDay": 1000,
"maxTokensPerDay": 1000
},
"usage": {
"agentCount": 3,
"callsToday": 142,
"tokensToday": 87
}
}
```
**Understanding the fields**:
| Field | Description |
|-------|-------------|
| `tier` | Current plan: `free`, `pro`, or `enterprise` |
| `limits.maxAgents` | Maximum active (non-decommissioned) agents allowed |
| `limits.maxCallsPerDay` | Maximum total API calls per calendar day (UTC) |
| `limits.maxTokensPerDay` | Maximum token issuances per calendar day (UTC) |
| `usage.agentCount` | Current number of active agents |
| `usage.callsToday` | API calls made so far today |
| `usage.tokensToday` | Tokens issued so far today |
**When limits are reached**: The relevant endpoint returns `403 FREE_TIER_LIMIT_EXCEEDED`.
Daily counters reset at midnight UTC. The agent count limit is a current count, not a daily
counter — decommissioning an agent immediately frees capacity.
---
## Tier comparison
| Limit | Free | Pro | Enterprise |
|-------|------|-----|------------|
| Max agents | 10 | 100 | Unlimited |
| Max API calls / day | 1,000 | 50,000 | Unlimited |
| Max token issuances / day | 1,000 | 50,000 | Unlimited |
---
## Upgrade your tier
`POST /api/v1/tiers/upgrade`
Creates a Stripe Checkout Session and returns a one-time URL. Complete the payment in the
browser to upgrade your organization's tier.
```bash
curl -s -X POST http://localhost:3000/api/v1/tiers/upgrade \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "target_tier": "pro" }' | jq .
```
Response:
```json
{
"checkoutUrl": "https://checkout.stripe.com/pay/cs_live_a1b2c3d4e5f6..."
}
```
Open `checkoutUrl` in a browser to complete payment. After successful payment, Stripe sends a
webhook to AgentIdP which automatically upgrades your organization's tier.
**Constraints**:
- `target_tier` must be `pro` or `enterprise`
- `target_tier` must be higher than your current tier (you cannot downgrade via this endpoint)
- Attempting to upgrade to the current or a lower tier returns `400 VALIDATION_ERROR`
```bash
# Upgrade from free to pro
curl -s -X POST http://localhost:3000/api/v1/tiers/upgrade \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "target_tier": "pro" }' | jq .
# Upgrade from pro to enterprise
curl -s -X POST http://localhost:3000/api/v1/tiers/upgrade \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "target_tier": "enterprise" }' | jq .
```
---
## Common errors
### `400 VALIDATION_ERROR` — target_tier missing or invalid
```json
{
"code": "VALIDATION_ERROR",
"message": "target_tier must be one of: free, pro, enterprise.",
"details": { "received": "premium" }
}
```
**Fix**: Use `"pro"` or `"enterprise"`.
### `400 TIER_UPGRADE_NOT_REQUIRED` — not an upgrade
**Fix**: You are already on this tier or a higher tier. Check `GET /api/v1/tiers/status` first.
### `401 UNAUTHORIZED` — token lacks organization_id
The tier endpoints require a token with an `organization_id` claim. Use a token issued by an
agent that was registered with `organization_id`. Tokens issued via the bootstrap method
(without an org) do not carry `organization_id` and will fail.

View File

@@ -2,6 +2,11 @@
A credential is a `client_id` + `client_secret` pair that your agent uses to get access tokens. This guide covers all four credential operations. A credential is a `client_id` + `client_secret` pair that your agent uses to get access tokens. This guide covers all four credential operations.
> **Multi-tenant note**: Credentials issued for an agent that belongs to an organization will
> produce tokens carrying an `organization_id` claim. This claim is required by analytics,
> webhooks, tier enforcement, and A2A delegation. Ensure your agent is registered with
> `organization_id` before issuing credentials for production use.
All credential endpoints are under `/api/v1/agents/{agentId}/credentials` and require a Bearer token with `agents:write` scope. All credential endpoints are under `/api/v1/agents/{agentId}/credentials` and require a Bearer token with `agents:write` scope.
--- ---

View File

@@ -25,6 +25,11 @@ Every action below is automatically recorded. You cannot create, modify, or dele
| `credential.revoked` | Successful `DELETE /agents/{agentId}/credentials/{credentialId}` | | `credential.revoked` | Successful `DELETE /agents/{agentId}/credentials/{credentialId}` |
| `auth.failed` | Failed authentication attempt on `POST /token` | | `auth.failed` | Failed authentication attempt on `POST /token` |
> **Audit chain verification**: In addition to querying events, you can verify the cryptographic
> integrity of the entire audit hash chain via `GET /api/v1/audit/verify`. This endpoint requires
> `audit:read` scope and is rate-limited to 30 requests/min. See the
> [API Reference](../api-reference.md#get-auditverify---verify-audit-chain-integrity) for details.
--- ---
## Query the audit log ## Query the audit log

View File

@@ -20,6 +20,7 @@ Requires: `Authorization: Bearer <token>` with `agents:write` scope.
| `capabilities` | string[] | Yes | One or more capability strings in `resource:action` format. Minimum 1. | | `capabilities` | string[] | Yes | One or more capability strings in `resource:action` format. Minimum 1. |
| `owner` | string | Yes | Team or organisation that owns this agent. 1128 characters. | | `owner` | string | Yes | Team or organisation that owns this agent. 1128 characters. |
| `deploymentEnv` | string (enum) | Yes | Target deployment environment. See values below. | | `deploymentEnv` | string (enum) | Yes | Target deployment environment. See values below. |
| `organization_id` | string (UUID) | No | UUID of the organization to scope this agent to. Recommended on all multi-tenant instances. |
### `agentType` values ### `agentType` values
@@ -70,7 +71,8 @@ curl -s -X POST http://localhost:3000/api/v1/agents \
"version": "1.0.0", "version": "1.0.0",
"capabilities": ["resume:read", "email:send", "candidate:score"], "capabilities": ["resume:read", "email:send", "candidate:score"],
"owner": "talent-acquisition-team", "owner": "talent-acquisition-team",
"deploymentEnv": "production" "deploymentEnv": "production",
"organization_id": "'$ORG_ID'"
}' | jq . }' | jq .
``` ```
@@ -93,6 +95,11 @@ Successful response (`201 Created`):
The `agentId` is assigned by the system — it is immutable and never changes. The `agentId` is assigned by the system — it is immutable and never changes.
> **Organization scoping**: If you include `organization_id` in the request, the agent is
> associated with that organization. Analytics, webhook events, and tier enforcement are all
> scoped by organization. To create an organization first, see the
> [Quick Start](../quick-start.md) guide.
--- ---
## Immutable fields ## Immutable fields

View File

@@ -0,0 +1,135 @@
# Use the Analytics Dashboard
This guide explains how to query the three analytics endpoints to understand your organization's
token usage and agent activity patterns.
All analytics endpoints require Bearer token authentication and are scoped to the organization
embedded in your token.
---
## Prerequisites
- A running AgentIdP instance
- A valid Bearer token with `organization_id` in its claims
- At least one agent registered and some token issuance activity
---
## Token issuance trend
`GET /api/v1/analytics/tokens`
Returns daily token issuance counts for the past N days (default 30, max 90). Use this to
track usage growth, identify traffic spikes, and plan capacity.
```bash
curl -s "http://localhost:3000/api/v1/analytics/tokens?days=30" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response:
```json
{
"tenantId": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"days": 30,
"data": [
{ "date": "2026-03-06", "count": 142 },
{ "date": "2026-03-07", "count": 198 },
{ "date": "2026-03-08", "count": 0 }
]
}
```
**Interpreting the data**: Each item in `data` is one calendar day (UTC) with the number of
tokens issued on that day. Days with zero issuance are included with `count: 0`. The array
is ordered chronologically, oldest first.
**Using it**: Compare day-over-day counts to identify growth or anomalies. A sudden spike in
`count` may indicate an agent retry loop or a credential leak. Zero-count days during expected
operation may indicate a deployment issue.
**Query parameter**: `days` — positive integer, max 90. Returns `400 VALIDATION_ERROR` if
exceeded.
```bash
# Last 7 days
curl -s "http://localhost:3000/api/v1/analytics/tokens?days=7" \
-H "Authorization: Bearer $TOKEN" | jq .
# Last 90 days (maximum)
curl -s "http://localhost:3000/api/v1/analytics/tokens?days=90" \
-H "Authorization: Bearer $TOKEN" | jq .
```
---
## Agent activity heatmap
`GET /api/v1/analytics/agents/activity`
Returns request counts grouped by day-of-week (0 = Sunday, 6 = Saturday) and hour (023, UTC).
Use this to identify peak usage windows for capacity planning and rate limit tuning.
```bash
curl -s "http://localhost:3000/api/v1/analytics/agents/activity" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response:
```json
{
"tenantId": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"data": [
{ "dow": 1, "hour": 9, "count": 54 },
{ "dow": 1, "hour": 10, "count": 87 },
{ "dow": 3, "hour": 14, "count": 201 }
]
}
```
**Interpreting the data**: `dow` is 0 (Sunday) through 6 (Saturday). `hour` is 023 UTC.
Only non-zero cells are returned — missing combinations had zero activity. Sort by `count`
descending to find your peak windows.
**Using it**: If most activity is on weekday mornings UTC, ensure your rate limit headroom
covers that window. If weekend activity is unexpectedly high, investigate which agents are
active.
---
## Per-agent usage summary
`GET /api/v1/analytics/agents`
Returns token issuance counts per agent for the current calendar month (UTC). Use this to
identify your most active agents and check if any single agent is consuming a
disproportionate share of your monthly token budget.
```bash
curl -s "http://localhost:3000/api/v1/analytics/agents" \
-H "Authorization: Bearer $TOKEN" | jq .
```
Response:
```json
{
"tenantId": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"month": "2026-04",
"data": [
{ "agentId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "tokenCount": 312 },
{ "agentId": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "tokenCount": 87 }
]
}
```
**Interpreting the data**: Each item shows an agent UUID and the number of tokens it has
issued this month. The response covers the full current calendar month from day 1 to now.
It resets on the first day of each month.
**Using it**: Cross-reference `agentId` values against `GET /api/v1/agents` to identify which
agents by name. If one agent accounts for >80% of usage, investigate whether it is token
caching correctly or requesting tokens unnecessarily.

View File

@@ -1,6 +1,6 @@
# Quick Start — Register Your First Agent # Quick Start — Register Your First Agent
This guide gets you from zero to a working agent identity with a valid OAuth 2.0 access token. It takes under 5 minutes. This guide gets you from zero to a working agent identity inside an organization, with a valid OAuth 2.0 access token. It takes under 5 minutes.
## Prerequisites ## Prerequisites
@@ -135,7 +135,45 @@ export BOOTSTRAP_TOKEN="<paste token here>"
--- ---
## Step 5 — Register an agent ## Step 5 — Create an organization
Agents are scoped to organizations. Create one now so your agent has an `organization_id` to belong to:
```bash
curl -s -X POST http://localhost:3000/api/v1/organizations \
-H "Authorization: Bearer $BOOTSTRAP_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "My AI Project",
"slug": "my-ai-project"
}' | jq .
```
Example response (`201 Created`):
```json
{
"organizationId": "org-0a1b2c3d-e4f5-6789-abcd-ef0123456789",
"name": "My AI Project",
"slug": "my-ai-project",
"planTier": "free",
"maxAgents": 10,
"maxTokensPerMonth": 10000,
"status": "active",
"createdAt": "2026-04-04T09:00:00.000Z",
"updatedAt": "2026-04-04T09:00:00.000Z"
}
```
Save the `organizationId`:
```bash
export ORG_ID="org-0a1b2c3d-e4f5-6789-abcd-ef0123456789"
```
---
## Step 6 — Register an agent
```bash ```bash
curl -s -X POST http://localhost:3000/api/v1/agents \ curl -s -X POST http://localhost:3000/api/v1/agents \
@@ -147,7 +185,8 @@ curl -s -X POST http://localhost:3000/api/v1/agents \
"version": "1.0.0", "version": "1.0.0",
"capabilities": ["data:read"], "capabilities": ["data:read"],
"owner": "my-team", "owner": "my-team",
"deploymentEnv": "development" "deploymentEnv": "development",
"organization_id": "'$ORG_ID'"
}' | jq . }' | jq .
``` ```
@@ -176,7 +215,7 @@ export AGENT_ID="a1b2c3d4-e5f6-7890-abcd-ef1234567890"
--- ---
## Step 6 — Generate a credential ## Step 7 — Generate a credential
```bash ```bash
curl -s -X POST "http://localhost:3000/api/v1/agents/$AGENT_ID/credentials" \ curl -s -X POST "http://localhost:3000/api/v1/agents/$AGENT_ID/credentials" \
@@ -208,7 +247,7 @@ export CLIENT_SECRET="sk_live_7f3a2b1c9d8e4f0a6b5c3d2e1f0a9b8c"
--- ---
## Step 7 — Issue an access token ## Step 8 — Issue an access token
Use the OAuth 2.0 Client Credentials flow. Note that the `/token` endpoint uses **form-encoded** body, not JSON: Use the OAuth 2.0 Client Credentials flow. Note that the `/token` endpoint uses **form-encoded** body, not JSON:
@@ -242,6 +281,14 @@ Your agent now has a valid JWT. Use it in the `Authorization: Bearer <token>` he
## What's next ## What's next
- [Core Concepts](concepts.md) — understand AgentIdP, AGNTCY, and the agent identity model - [Core Concepts](concepts.md) — understand AgentIdP, AGNTCY, orgs, DID, delegation, and tiers
- [Guides](guides/README.md) — step-by-step walkthroughs for credentials, tokens, and audit logs - [Guides](guides/README.md) — step-by-step walkthroughs for all workflows
- [API Reference](api-reference.md) — every endpoint documented with curl examples - [API Reference](api-reference.md) — every endpoint documented with curl examples
**New guides for Phase 6 features:**
- [Use the Analytics Dashboard](guides/use-analytics-dashboard.md) — query token trends and activity
- [Manage API Tiers](guides/manage-api-tiers.md) — check limits and upgrade your plan
- [A2A Delegation](guides/a2a-delegation.md) — delegate authority between agents
- [Configure Webhooks](guides/configure-webhooks.md) — subscribe to real-time events
- [AGNTCY Compliance](guides/agntcy-compliance.md) — export agent cards and generate compliance reports

View File

@@ -14,14 +14,15 @@ SentryAgent.ai AgentIdP is a Node.js REST API backed by PostgreSQL and Redis. It
## Documentation ## Documentation
| Document | What it covers | | Document | Audience | Contents |
|----------|----------------| |----------|----------|---------|
| [Architecture](architecture.md) | Components, ports, data flow, Redis key patterns | | [Architecture](architecture.md) | All engineers | Components, ports, data flow, Redis key patterns |
| [Environment Variables](environment-variables.md) | Every env var — required, optional, format, examples | | [Environment Variables](environment-variables.md) | All engineers | Every env var — required, optional, format, examples |
| [Database](database.md) | Schema (4 tables), migrations, how to apply and verify | | [Database](database.md) | Backend, DevOps | Schema (26 tables/migrations), how to apply and verify |
| [Local Development](local-development.md) | docker-compose setup, startup, health checks | | [Local Development](local-development.md) | All engineers | docker-compose setup, startup, health checks |
| [Security](security.md) | JWT key generation and rotation, CORS, secret storage | | [Security](security.md) | All engineers | JWT key generation and rotation, CORS, secret storage |
| [Operations](operations.md) | Startup order, graceful shutdown, log interpretation, troubleshooting | | [Operations](operations.md) | DevOps | Startup order, graceful shutdown, log interpretation, troubleshooting |
| [field-trial.md](field-trial.md) | DevOps engineers, QA | In-house Docker Compose field trial execution playbook |
## Quick Reference — Ports ## Quick Reference — Ports

View File

@@ -3,26 +3,49 @@
## Component Overview ## Component Overview
``` ```
┌─────────────────────────────────────┐ ┌───────────────────────────────────────────
AgentIdP Application Next.js Portal (port 3001)
Node.js / Express portal/ — Next.js 14
Port 3000 /login /agents /credentials /audit
/analytics /settings/tier /compliance
Auth MW → RateLimit MW → Routes /webhooks /marketplace
│ ↓ ↓ │ └────────────────┬──────────────────────────┘
Controllers → Services → Repos │ │ HTTP (localhost:3000)
──────────────────────────────────── ┌────────────────▼──────────────────────────
AgentIdP Application
┌──────────────▼──┐ ┌───────▼────────┐ │ Node.js / Express (port 3000) │
PostgreSQL 14 Redis 7
Port 5432 │ Port 6379 TLS MW → Helmet → CORS → Morgan
│ │ Metrics MW → OrgContext MW
│ agents │ │ Token revoke UsageMetering MW → TierEnforcement MW
credentials Rate limits Auth MW → OPA MW → Routes
audit_events Monthly counts
token_revocati- │ │ Controllers → Services → Repos
│ ons │ │ │ └──────────┬───────────────┬────────────────┘
└──────────────────┘ └─────────────────┘ │ │
┌────────────────▼──┐ ┌────────▼────────┐
│ PostgreSQL 14 │ │ Redis 7 │
│ Port 5432 │ │ Port 6379 │
│ │ │ │
│ 26 migrations │ │ Rate limits │
│ (001026) │ │ Token revoke │
│ organizations │ │ Monthly counts │
│ agents + DID keys │ │ Tier counters │
│ credentials │ │ Compliance cache│
│ audit_events │ │ │
│ token_revocations │ └──────────────────┘
│ oidc_keys │
│ federation_partne-│ ┌──────────────────┐
│ rs │ │ HashiCorp Vault │
│ webhook_subscript-│ │ (optional) │
│ ions + deliveries │ │ KV v2 — creds │
│ agent_marketplace │ └──────────────────┘
│ github_oidc_trust │
│ billing │ ┌──────────────────┐
│ delegation_chains │ │ Stripe │
│ analytics_events │ │ (optional) │
│ tenant_tiers │ │ Billing/upgrades │
└────────────────────┘ └──────────────────┘
``` ```
## Components ## Components
@@ -36,8 +59,12 @@ A stateless Express HTTP server. Every request is handled independently — no i
| Layer | Responsibility | | Layer | Responsibility |
|-------|---------------| |-------|---------------|
| Routes | Wire HTTP methods and paths to controllers | | Routes | Wire HTTP methods and paths to controllers |
| TLS middleware | Redirect HTTP → HTTPS when `ENFORCE_TLS=true` |
| Auth middleware | Validate Bearer JWT (RS256 + Redis revocation check) | | Auth middleware | Validate Bearer JWT (RS256 + Redis revocation check) |
| Rate limit middleware | Redis sliding-window counter per `client_id` | | OrgContext middleware | Resolve `organization_id` from JWT and attach to `req` |
| UsageMetering middleware | Fire-and-forget analytics event recording |
| TierEnforcement middleware | Enforce daily API call and token limits via Redis (when `TIER_ENFORCEMENT=true`) |
| OPA middleware | Scope-based authorization via embedded Wasm or JSON policy |
| Controllers | Parse and validate request, call service, return response | | Controllers | Parse and validate request, call service, return response |
| Services | Business logic — no direct DB access | | Services | Business logic — no direct DB access |
| Repositories | All SQL queries — no business logic | | Repositories | All SQL queries — no business logic |
@@ -53,11 +80,14 @@ The application connects via a connection pool (`pg.Pool`) initialised from `DAT
Ephemeral store for three use cases: Ephemeral store for three use cases:
| Key pattern | Purpose | TTL | | Key pattern | Example | Purpose | TTL |
|------------|---------|-----| |------------|---------|---------|-----|
| `revoked:<jti>` | Token revocation list — checked on every authenticated request | Until token's `exp` | | `revoked:<jti>` | `revoked:f1e2d3c4-...` | Revoked token JTI | Remaining token lifetime |
| `rate:<client_id>:<window>` | Request count per client per 60-second window | 60 seconds | | `rate:<client_id>:<window>` | `rate:a1b2c3...:29086156` | Request count per window | `RATE_LIMIT_WINDOW_MS` |
| `monthly:<client_id>:<year>:<month>` | Token issuance count for free tier limit enforcement | End of month | | `monthly:<client_id>:<year>:<month>` | `monthly:a1b2c3...:2026:3` | Monthly token issuance count | End of month |
| `rate:tier:calls:<tenantId>` | `rate:tier:calls:org-uuid` | Daily API call counter for tier enforcement | Until midnight UTC |
| `rate:tier:tokens:<tenantId>` | `rate:tier:tokens:org-uuid` | Daily token issuance counter for tier enforcement | Until midnight UTC |
| `compliance:report:<tenantId>` | `compliance:report:org-uuid` | Cached compliance report JSON | 5 minutes |
**Redis is supplementary, not the source of truth.** Token revocations are also written to the `token_revocations` PostgreSQL table for durability across Redis restarts. On Redis restart, the revocation list is cold — previously revoked tokens will pass auth until the PostgreSQL-backed warm-up is implemented (Phase 2). **Redis is supplementary, not the source of truth.** Token revocations are also written to the `token_revocations` PostgreSQL table for durability across Redis restarts. On Redis restart, the revocation list is cold — previously revoked tokens will pass auth until the PostgreSQL-backed warm-up is implemented (Phase 2).
@@ -107,21 +137,89 @@ PostgreSQL / Redis
## Service Map ## Service Map
| Route prefix | Service | Repository | | Route prefix | Controller | Service(s) | Repository/ies |
|-------------|---------|-----------| |-------------|-----------|-----------|----------------|
| `/api/v1/agents` | `AgentService` | `AgentRepository` | | `/api/v1/agents` | `AgentController` | `AgentService` | `AgentRepository` |
| `/api/v1/agents/:id/credentials` | `CredentialService` | `CredentialRepository` | | `/api/v1/credentials` | `CredentialController` | `CredentialService` | `CredentialRepository` |
| `/api/v1/token` | `OAuth2Service` | `TokenRepository`, `CredentialRepository`, `AgentRepository` | | `/api/v1/token` | `TokenController` | `OAuth2Service` | `TokenRepository`, `CredentialRepository`, `AgentRepository` |
| `/api/v1/audit` | `AuditService` | `AuditRepository` | | `/api/v1/audit` | `AuditController` | `AuditService` | `AuditRepository` |
| `/api/v1/organizations` | `OrgController` | `OrgService` | `OrgRepository` |
| `/api/v1/compliance/*` | `ComplianceController` | `ComplianceService` | `AuditRepository` |
| `/api/v1/analytics/*` | `AnalyticsController` | `AnalyticsService` | direct pool queries |
| `/api/v1/tiers/*` | `TierController` | `TierService` | pool queries, Stripe SDK |
| `/api/v1/webhooks` | `WebhookController` | `WebhookService` | `WebhookRepository` |
| `/api/v1/federation` | `FederationController` | `FederationService` | direct pool queries |
| `/api/v1/marketplace` | `MarketplaceController` | `MarketplaceService` | direct pool queries |
| `/api/v1/billing` | `BillingController` | `BillingService` | direct pool queries |
| `/.well-known/did.json`, `/api/v1/did/*` | `DIDController` | `DIDService` | `AgentRepository` |
| `/.well-known/openid-configuration`, `/api/v1/oidc/*` | `OIDCController` | `OIDCKeyService`, `IDTokenService` | direct pool queries |
| `/api/v1/oidc/trust-policies` | `OIDCTrustPolicyController` | `OIDCTrustPolicyService` | direct pool queries |
| `/api/v1/delegation` | `DelegationController` | `DelegationService` | direct pool queries |
| `/api/v1/scaffold` | `ScaffoldController` | `ScaffoldService` | — |
| `/health` | inline | — | pool, redis |
| `/metrics` | inline | — | prom-client |
## New Services (Phases 36)
| Service | Source file | Responsibility |
|---------|------------|----------------|
| `AnalyticsService` | `src/services/AnalyticsService.ts` | Fire-and-forget `recordEvent`, time-series `getTokenTrend`, heatmap `getAgentActivity`, per-agent `getAgentUsageSummary` |
| `TierService` | `src/services/TierService.ts` | `getStatus` (reads `tenant_tiers`), `initiateUpgrade` (creates Stripe Checkout Session), `applyUpgrade` (handles Stripe webhook), `enforceAgentLimit` |
| `ComplianceService` | `src/services/ComplianceService.ts` | `generateReport` (Redis-cached 5 min), `exportAgentCards` (AGNTCY format) |
| `DelegationService` | `src/services/DelegationService.ts` | A2A delegation chain creation and verification |
| `DIDService` | `src/services/DIDService.ts` | `did:web` identifier generation and DID document management |
| `OIDCKeyService` | `src/services/OIDCKeyService.ts` | OIDC key rotation, JWKS endpoint |
| `IDTokenService` | `src/services/IDTokenService.ts` | OIDC ID token issuance |
| `FederationService` | `src/services/FederationService.ts` | Cross-tenant agent identity federation |
| `WebhookService` | `src/services/WebhookService.ts` | Event subscriptions, delivery with retry, dead-letter queue |
| `VaultService` | `src/services/VaultService.ts` | HashiCorp Vault KV v2 read/write for credential storage |
| `BillingService` | `src/services/BillingService.ts` | Stripe customer and subscription management |
| `MarketplaceService` | `src/services/MarketplaceService.ts` | Agent listing and discovery |
| `OIDCTrustPolicyService` | `src/services/OIDCTrustPolicyService.ts` | GitHub OIDC trust policy management |
| `EventPublisher` | `src/services/EventPublisher.ts` | Routes domain events to webhook delivery and Kafka (if configured) |
## Ports ## Ports
| Service | Internal port | Exposed port (local dev) | | Service | Internal port | Exposed port (local dev) |
|---------|--------------|--------------------------| |---------|--------------|--------------------------|
| AgentIdP app | 3000 | 3000 | | AgentIdP app | 3000 | 3000 |
| Next.js portal | 3001 | 3001 |
| PostgreSQL | 5432 | 5432 | | PostgreSQL | 5432 | 5432 |
| Redis | 6379 | 6379 | | Redis | 6379 | 6379 |
## API Routes (Phase 6 complete)
Base path: `/api/v1`
| Route | Method(s) | Auth | Feature flag |
|-------|----------|------|-------------|
| `/api/v1/agents` | GET, POST, PATCH, DELETE | Bearer JWT | always on |
| `/api/v1/credentials` | GET, POST, DELETE | Bearer JWT | always on |
| `/api/v1/token` | POST | none (client credentials) | always on |
| `/api/v1/audit` | GET | Bearer JWT | always on |
| `/api/v1/audit/verify` | GET | Bearer JWT | always on |
| `/api/v1/organizations` | GET, POST | Bearer JWT | always on |
| `/api/v1/compliance/controls` | GET | none | always on |
| `/api/v1/compliance/report` | GET | Bearer JWT | `COMPLIANCE_ENABLED=true` |
| `/api/v1/compliance/agent-cards` | GET | Bearer JWT | `COMPLIANCE_ENABLED=true` |
| `/api/v1/analytics/token-trend` | GET | Bearer JWT | `ANALYTICS_ENABLED=true` |
| `/api/v1/analytics/agent-activity` | GET | Bearer JWT | `ANALYTICS_ENABLED=true` |
| `/api/v1/analytics/usage-summary` | GET | Bearer JWT | `ANALYTICS_ENABLED=true` |
| `/api/v1/tiers/status` | GET | Bearer JWT | always on |
| `/api/v1/tiers/upgrade` | POST | Bearer JWT | always on |
| `/api/v1/webhooks` | GET, POST, DELETE | Bearer JWT | always on |
| `/api/v1/federation` | GET, POST | Bearer JWT | always on |
| `/api/v1/delegation` | GET, POST | Bearer JWT | always on |
| `/api/v1/marketplace` | GET | none | always on |
| `/api/v1/billing` | GET, POST | Bearer JWT | always on |
| `/api/v1/did/*` | GET | none | always on |
| `/api/v1/oidc/*` | GET, POST | mixed | always on |
| `/.well-known/openid-configuration` | GET | none | always on |
| `/.well-known/jwks.json` | GET | none | always on |
| `/.well-known/did.json` | GET | none | always on |
| `/health` | GET | none | always on |
| `/metrics` | GET | none | always on |
## Graceful Shutdown ## Graceful Shutdown
The server listens for `SIGTERM` and `SIGINT`. On receipt: The server listens for `SIGTERM` and `SIGINT`. On receipt:

View File

@@ -1,18 +1,28 @@
# Database # Database
AgentIdP uses PostgreSQL 14+ as its primary data store. The schema consists of four tables managed by a custom migration runner. AgentIdP uses PostgreSQL 14+ as its primary data store. The schema consists of 26 migrations managed by a custom migration runner.
--- ---
## Schema Overview ## Schema Overview
``` ```
agents organizations
── credentials (FK: client_id → agents.agent_id, CASCADE DELETE) ── agents (FK: organization_id → organizations.org_id)
│ ├── credentials (FK: client_id → agents.agent_id, CASCADE DELETE)
audit_events (no FK — append-only, agent_id is informational) │ └── agent_did_keys (FK: agent_id → agents.agent_id)
└── audit_events (FK: organization_id — informational, no cascade)
token_revocations (no FK — independent revocation store) token_revocations (no FK — independent revocation store)
oidc_keys (standalone — OIDC signing key rotation)
federation_partners (standalone — cross-tenant identity)
webhook_subscriptions → webhook_deliveries (FK: subscription_id)
agent_marketplace (standalone — agent discovery catalog)
github_oidc_trust_policies (standalone — CI/CD trust)
billing (FK: org_id → organizations.org_id — one row per org)
delegation_chains (standalone — A2A delegation records)
analytics_events (FK: organization_id — append-only)
tenant_tiers (FK: org_id → organizations.org_id — one row per org)
``` ```
--- ---
@@ -134,6 +144,234 @@ Durable record of revoked JWT tokens. Supplements Redis for durability across Re
--- ---
### `organizations`
Created by migration `006_create_organizations_table.sql`.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `org_id` | `UUID` | No | Primary key |
| `name` | `VARCHAR(255)` | No | Organisation display name |
| `slug` | `VARCHAR(64)` | No | URL-safe unique identifier |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `agent_did_keys`
Created by migration `012_create_agent_did_keys_table.sql`.
Stores the DID document key material for each agent. One agent may have multiple keys for
rotation purposes.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `agent_id` | `UUID` | No | FK → `agents.agent_id` |
| `key_id` | `VARCHAR(255)` | No | DID key fragment identifier |
| `public_key_jwk` | `JSONB` | No | Public key in JWK format |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### DID columns on `agents`
Added by migration `013_add_did_columns_to_agents.sql`:
- `did``VARCHAR(512)` nullable — the `did:web` identifier for this agent
- `did_document``JSONB` nullable — full DID document
---
### `oidc_keys`
Created by migration `014_create_oidc_keys_table.sql`.
Stores RSA key pairs used for OIDC ID token signing. Supports key rotation — active key is
determined by the most recently created row.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `kid` | `VARCHAR(128)` | No | Key ID — referenced in JWKS |
| `private_key_pem` | `TEXT` | No | Encrypted RSA private key (pgcrypto) |
| `public_key_pem` | `TEXT` | No | RSA public key |
| `algorithm` | `VARCHAR(16)` | No | Always `RS256` |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `federation_partners`
Created by migration `015_create_federation_partners_table.sql`.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `org_id` | `UUID` | No | Owning organisation |
| `partner_name` | `VARCHAR(255)` | No | Display name |
| `partner_jwks_url` | `TEXT` | No | URL to partner's JWKS endpoint |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `webhook_subscriptions`
Created by migration `016_create_webhook_subscriptions_table.sql`.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `org_id` | `UUID` | No | Owning organisation |
| `event_type` | `VARCHAR(128)` | No | Event type filter (e.g. `agent.created`) |
| `target_url` | `TEXT` | No | HTTPS delivery endpoint |
| `secret` | `VARCHAR(255)` | Yes | HMAC signing secret for delivery verification |
| `active` | `BOOLEAN` | No | Default: `true` |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `webhook_deliveries`
Created by migration `017_create_webhook_deliveries_table.sql`.
Records each delivery attempt for a webhook event, including the dead-letter queue entries.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `subscription_id` | `UUID` | No | FK → `webhook_subscriptions.id` |
| `event_type` | `VARCHAR(128)` | No | Event type delivered |
| `payload` | `JSONB` | No | Full event payload |
| `status` | `VARCHAR(32)` | No | `pending`, `delivered`, `failed`, `dead_letter` |
| `response_status` | `INTEGER` | Yes | HTTP status from delivery endpoint |
| `attempt_count` | `INTEGER` | No | Default: `0` |
| `last_attempted_at` | `TIMESTAMPTZ` | Yes | |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
**Dead-letter queue:** After 3 failed delivery attempts, the row status is set to `dead_letter`
and the `agentidp_webhook_dead_letters_total` Prometheus counter is incremented. The Prometheus
metric label is `event_type`.
---
### pgcrypto extension
Enabled by migration `018_enable_pgcrypto.sql`. Used for encrypting sensitive columns in
`oidc_keys` and credential data.
---
### `agent_marketplace`
Created by migration `021_add_agent_marketplace.sql`.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `agent_id` | `UUID` | No | FK → `agents.agent_id` |
| `listing_name` | `VARCHAR(255)` | No | Display name in marketplace |
| `description` | `TEXT` | Yes | Markdown description |
| `tags` | `TEXT[]` | No | Searchable tags. Default: `{}` |
| `published` | `BOOLEAN` | No | Default: `false` |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `github_oidc_trust_policies`
Created by migration `022_add_github_oidc_trust_policies.sql`.
Maps GitHub Actions OIDC claims to agent identities for CI/CD token exchange.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `org_id` | `UUID` | No | Owning organisation |
| `repository` | `VARCHAR(512)` | No | GitHub repository slug (`owner/repo`) |
| `branch` | `VARCHAR(255)` | Yes | Branch filter (null = any branch) |
| `agent_id` | `UUID` | No | Agent to issue a token for on match |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `billing`
Created by migration `023_add_billing.sql`.
One row per organisation. Tracks the org's Stripe customer and subscription state.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `org_id` | `UUID` | No | FK → `organizations.org_id` (UNIQUE) |
| `stripe_customer_id` | `VARCHAR(255)` | Yes | Stripe Customer ID |
| `stripe_subscription_id` | `VARCHAR(255)` | Yes | Stripe Subscription ID |
| `status` | `VARCHAR(64)` | No | Stripe subscription status or `none` |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `delegation_chains`
Created by migration `024_add_delegation_chains.sql`.
Records A2A delegation grants created via the delegation API.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `delegator_agent_id` | `UUID` | No | Agent granting the delegation |
| `delegate_agent_id` | `UUID` | No | Agent receiving the delegation |
| `scopes` | `TEXT[]` | No | Scopes being delegated |
| `expires_at` | `TIMESTAMPTZ` | Yes | Optional expiry |
| `created_at` | `TIMESTAMPTZ` | No | Default: `NOW()` |
---
### `analytics_events`
Created by migration `025_add_analytics_events.sql`.
Append-only event store for analytics. Supports token trend, agent activity, and usage summary
queries.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `organization_id` | `UUID` | No | Owning organisation |
| `date` | `DATE` | No | Calendar date of the event (UTC) |
| `metric_type` | `VARCHAR(64)` | No | e.g. `token_issued`, `agent_called` |
| `count` | `INTEGER` | No | Event count for this date+type |
**Index:** `(organization_id, date DESC)` for fast time-series queries.
---
### `tenant_tiers`
Created by migration `026_add_tenant_tiers.sql`.
One row per organisation. Stores the current tier and enforces tier limits via the
`tierEnforcement` middleware.
| Column | Type | Nullable | Description |
|--------|------|----------|-------------|
| `id` | `UUID` | No | Primary key |
| `org_id` | `UUID` | No | FK → `organizations.org_id` (UNIQUE) |
| `tier` | `ENUM('free','pro','enterprise')` | No | Current tier. Default: `free` |
| `updated_at` | `TIMESTAMPTZ` | No | Last tier change. Default: `NOW()` |
**Tier limits** (from `src/config/tiers.ts`):
| Tier | Max Agents | Max API Calls/Day | Max Tokens/Day |
|------|-----------|-------------------|----------------|
| free | 10 | 1,000 | 1,000 |
| pro | 100 | 50,000 | 50,000 |
| enterprise | unlimited | unlimited | unlimited |
---
## Migration Runner ## Migration Runner
Migrations are managed by `scripts/migrate.ts`. It reads `.sql` files from `src/db/migrations/` in alphabetical order, tracks applied migrations in a `schema_migrations` table, and executes only unapplied migrations — each in its own transaction. Migrations are managed by `scripts/migrate.ts`. It reads `.sql` files from `src/db/migrations/` in alphabetical order, tracks applied migrations in a `schema_migrations` table, and executes only unapplied migrations — each in its own transaction.
@@ -160,10 +398,11 @@ Expected output (first run):
Running database migrations... Running database migrations...
✓ Applied: 001_create_agents.sql ✓ Applied: 001_create_agents.sql
✓ Applied: 002_create_credentials.sql ✓ Applied: 002_create_credentials.sql
✓ Applied: 003_create_audit_events.sql ...
✓ Applied: 004_create_tokens.sql ✓ Applied: 025_add_analytics_events.sql
✓ Applied: 026_add_tenant_tiers.sql
Migrations complete. 4 migration(s) applied. Migrations complete. 26 migration(s) applied.
``` ```
Expected output (already applied): Expected output (already applied):
@@ -191,9 +430,10 @@ Expected output:
-----------------------------------+------------------------------- -----------------------------------+-------------------------------
001_create_agents.sql | 2026-03-28 09:00:00.000000+00 001_create_agents.sql | 2026-03-28 09:00:00.000000+00
002_create_credentials.sql | 2026-03-28 09:00:00.000000+00 002_create_credentials.sql | 2026-03-28 09:00:00.000000+00
003_create_audit_events.sql | 2026-03-28 09:00:00.000000+00 ...
004_create_tokens.sql | 2026-03-28 09:00:00.000000+00 025_add_analytics_events.sql | 2026-04-04 09:00:00.000000+00
(4 rows) 026_add_tenant_tiers.sql | 2026-04-04 09:00:00.000000+00
(26 rows)
``` ```
### Adding a new migration ### Adding a new migration
@@ -214,6 +454,15 @@ There is no automated rollback. To undo a migration:
## Connection Pool ## Connection Pool
The application uses `pg.Pool` with default settings (max 10 connections). The pool is a singleton — one pool per process instance. The application uses `pg.Pool` with settings read from environment variables. The pool is a
singleton — one pool per process instance.
To override pool size, modify `src/db/pool.ts`. In production, ensure `DATABASE_URL` includes connection pool parameters if using PgBouncer or a managed connection pooler. | Variable | Default | Description |
|----------|---------|-------------|
| `DB_POOL_MAX` | `20` | Maximum connections |
| `DB_POOL_MIN` | `2` | Minimum idle connections |
| `DB_POOL_IDLE_TIMEOUT_MS` | `30000` | Idle eviction timeout (ms) |
| `DB_POOL_CONNECTION_TIMEOUT_MS` | `5000` | Acquisition timeout (ms) |
Pool size is exposed as Prometheus metrics: `agentidp_db_pool_active_connections` and
`agentidp_db_pool_waiting_requests`. Monitor these in production to detect pool exhaustion.

621
docs/devops/deployment.md Normal file
View File

@@ -0,0 +1,621 @@
# Deployment Guide — SentryAgent.ai AgentIdP
End-to-end guide for deploying AgentIdP to AWS (primary) and GCP (secondary) using the Terraform infrastructure-as-code in `terraform/`.
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [AWS Deployment](#2-aws-deployment)
3. [GCP Deployment](#3-gcp-deployment)
4. [Post-Deploy Verification](#4-post-deploy-verification)
5. [Rollback Procedure](#5-rollback-procedure)
6. [Environment Variable Reference](#6-environment-variable-reference)
---
## 1. Prerequisites
### Tools
| Tool | Minimum Version | Install |
|------|-----------------|---------|
| Terraform | 1.6.0 | https://developer.hashicorp.com/terraform/install |
| AWS CLI | 2.13 | https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html |
| gcloud CLI | 460.0 | https://cloud.google.com/sdk/docs/install |
| Docker | 24.0 | Required only for building and pushing images |
| openssl | any | Required for generating JWT key pairs |
Verify all tools are available:
```bash
terraform version
aws --version
gcloud version
docker version
openssl version
```
### Container Image
Build and push the `sentryagent/agentidp` image to your registry before deploying. Terraform references the image by tag — it does not build it.
```bash
# From the project root
docker build -t sentryagent/agentidp:1.0.0 .
# Push to your registry (ECR example):
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin 123456789012.dkr.ecr.us-east-1.amazonaws.com
docker tag sentryagent/agentidp:1.0.0 \
123456789012.dkr.ecr.us-east-1.amazonaws.com/sentryagent/agentidp:1.0.0
docker push 123456789012.dkr.ecr.us-east-1.amazonaws.com/sentryagent/agentidp:1.0.0
```
Update `app_image_tag` in your `terraform.tfvars` to match.
### JWT Key Pair
Generate the RSA-2048 key pair used for signing and verifying JWTs:
```bash
openssl genrsa -out jwt_private.pem 2048
openssl rsa -in jwt_private.pem -pubout -out jwt_public.pem
# Verify
openssl rsa -in jwt_private.pem -check -noout
```
Keep `jwt_private.pem` secure — treat it with the same sensitivity as a TLS private key. You will paste its contents into `terraform.tfvars`.
---
## 2. AWS Deployment
### 2.1 Configure AWS CLI
```bash
aws configure
# Provide: AWS Access Key ID, Secret Access Key, region (e.g. us-east-1), output format (json)
# Verify credentials
aws sts get-caller-identity
```
The IAM principal running Terraform requires permissions to manage: VPC, ECS, RDS, ElastiCache, ALB, IAM roles, Secrets Manager, Route 53, CloudWatch, and VPC endpoints.
### 2.2 Provision an ACM Certificate
The ALB requires an ACM certificate for your domain. Create it in the same region as your deployment.
```bash
aws acm request-certificate \
--domain-name idp.example.com \
--validation-method DNS \
--region us-east-1
```
Complete DNS validation by adding the CNAME record shown in the ACM console. Wait for the status to become `ISSUED` before proceeding.
```bash
# Monitor validation status
aws acm describe-certificate \
--certificate-arn arn:aws:acm:us-east-1:123456789012:certificate/XXXX \
--region us-east-1 \
--query 'Certificate.Status'
```
### 2.3 Prepare tfvars
```bash
cd terraform/environments/aws
cp terraform.tfvars.example terraform.tfvars
```
Edit `terraform.tfvars`. All fields marked `REPLACE_WITH_*` are required. Key fields:
- `region` — AWS region (must match the ACM certificate region)
- `domain_name` — your domain (e.g. `idp.example.com`)
- `certificate_arn` — ARN from step 2.2
- `app_image_tag` — tag of the image you pushed in step 1
- `db_password` — strong random password (no `@`, `#`, `?`, `/` characters — they break URL parsing)
- `redis_auth_token` — minimum 16 characters, no spaces
- `jwt_private_key` — full PEM contents of `jwt_private.pem` with literal `\n` for newlines
- `jwt_public_key` — full PEM contents of `jwt_public.pem` with literal `\n` for newlines
Example for encoding PEM keys in tfvars:
```bash
# Output the private key as a single line with \n separators (for pasting into tfvars)
awk 'NF {printf "%s\\n", $0}' jwt_private.pem
```
**Never commit `terraform.tfvars` to version control.**
### 2.4 Configure Remote State (Recommended)
Uncomment and configure the `backend "s3"` block in `terraform/environments/aws/main.tf`:
```hcl
backend "s3" {
bucket = "your-terraform-state-bucket"
key = "agentidp/aws/production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "your-terraform-locks-table"
}
```
Create the S3 bucket and DynamoDB table if they do not exist:
```bash
# S3 bucket with versioning and encryption
aws s3api create-bucket --bucket your-terraform-state-bucket --region us-east-1
aws s3api put-bucket-versioning \
--bucket your-terraform-state-bucket \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket your-terraform-state-bucket \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
# DynamoDB table for state locking
aws dynamodb create-table \
--table-name your-terraform-locks-table \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region us-east-1
```
### 2.5 Terraform Init
```bash
cd terraform/environments/aws
terraform init
```
Expected output: provider plugins downloaded, backend initialized.
### 2.6 Terraform Plan
```bash
terraform plan -out=tfplan
```
Review the plan carefully before applying. Expected resources on first apply: ~5060 resources (VPC, subnets, NAT gateways, VPC endpoints, IAM roles, secrets, RDS, ElastiCache, ALB, ECS cluster, task definition, service, Route 53 record).
### 2.7 Terraform Apply
```bash
terraform apply tfplan
```
**First apply takes 2030 minutes** — RDS Multi-AZ provisioning is the longest step (~15 min). Do not interrupt the apply.
When complete, note the outputs:
```bash
terraform output
```
Key outputs:
- `service_url` — the HTTPS URL of your deployed service
- `alb_dns_name` — ALB DNS name (verify Route 53 alias is pointing here)
- `ecs_service_name` — use for ECS deployment commands
- `cloudwatch_log_group` — where container logs appear
### 2.8 Run Database Migrations
After first deploy, run migrations against the new RDS instance. The easiest approach is to exec into a running ECS task:
```bash
# Get a running task ARN
TASK_ARN=$(aws ecs list-tasks \
--cluster sentryagent-agentidp-production \
--service-name sentryagent-agentidp-production \
--query 'taskArns[0]' \
--output text)
# Run migrations via ECS Exec (requires enableExecuteCommand on the service)
aws ecs execute-command \
--cluster sentryagent-agentidp-production \
--task $TASK_ARN \
--container agentidp \
--command "node scripts/db-migrate.js" \
--interactive
```
Alternatively, run a one-off ECS task with the migration command as the container override.
---
## 3. GCP Deployment
### 3.1 Configure gcloud CLI
```bash
gcloud auth login
gcloud config set project your-gcp-project-id
gcloud auth application-default login
```
Verify:
```bash
gcloud config list
gcloud projects describe your-gcp-project-id
```
The principal running Terraform requires the following roles on the project:
- `roles/owner` or a custom role covering: Cloud Run Admin, Cloud SQL Admin, Redis Admin, Secret Manager Admin, IAM Admin, Compute Admin, Service Networking Admin.
### 3.2 Prepare tfvars
```bash
cd terraform/environments/gcp
cp terraform.tfvars.example terraform.tfvars
```
Edit `terraform.tfvars`. Key fields:
- `project_id` — your GCP project ID
- `region` — GCP region (e.g. `us-central1`)
- `app_image_tag` — tag of the image you built
- `db_password` — strong random password for Cloud SQL
- `jwt_private_key` / `jwt_public_key` — same PEM keys used for AWS (same key pair for both regions)
**Never commit `terraform.tfvars` to version control.**
### 3.3 Configure Remote State (Recommended)
Uncomment and configure the `backend "gcs"` block in `terraform/environments/gcp/main.tf`:
```hcl
backend "gcs" {
bucket = "your-terraform-state-bucket"
prefix = "agentidp/gcp/production"
}
```
Create the GCS bucket:
```bash
gsutil mb -l us-central1 gs://your-terraform-state-bucket
gsutil versioning set on gs://your-terraform-state-bucket
```
### 3.4 Terraform Init
```bash
cd terraform/environments/gcp
terraform init
```
### 3.5 Terraform Plan
```bash
terraform plan -out=tfplan
```
Review the plan. Expected resources: ~3545 resources (VPC, subnet, VPC connector, service accounts, secrets, Cloud SQL, Memorystore, Cloud Run service, IAM bindings, API enablement).
### 3.6 Terraform Apply
```bash
terraform apply tfplan
```
**First apply takes 1520 minutes** — Cloud SQL provisioning is the longest step.
When complete:
```bash
terraform output
```
Key outputs:
- `service_url` — Cloud Run HTTPS URL (Google-managed TLS, no cert setup required)
- `cloud_sql_connection_name` — for Cloud SQL Proxy if needed
- `memorystore_host` — Redis private IP
### 3.7 Run Database Migrations
Cloud Run does not support exec. Use a one-off Cloud Run Job for migrations:
```bash
gcloud run jobs create agentidp-migrate \
--image sentryagent/agentidp:1.0.0 \
--region us-central1 \
--command node \
--args "scripts/db-migrate.js" \
--set-secrets "DATABASE_URL=sentryagent-agentidp-production-database-url:latest" \
--vpc-connector sentryagent-agentidp-production-connector \
--service-account sentryagent-agentidp-production-run-sa@your-gcp-project-id.iam.gserviceaccount.com
gcloud run jobs execute agentidp-migrate --region us-central1 --wait
```
---
## 4. Post-Deploy Verification
Run these checks after deploying to either environment. Replace `https://idp.example.com` with your actual service URL.
### 4.1 Health Check
```bash
curl -si https://idp.example.com/health
```
Expected response:
```
HTTP/2 200
content-type: application/json
{"status":"ok"}
```
If you receive a 502 or 503, the load balancer has not yet registered healthy targets. Wait 6090 seconds and retry — ECS tasks or Cloud Run instances take time to pass health checks.
### 4.2 Metrics Endpoint
```bash
curl -si https://idp.example.com/metrics
```
Expected: HTTP 200 with Prometheus-format metrics text (lines beginning with `# HELP`, `# TYPE`, and metric values).
### 4.3 Token Endpoint (Smoke Test)
First, register a test agent client (requires a valid JWT or admin credentials — see [developers guide](../developers/)):
```bash
# Issue a client credentials token (replace CLIENT_ID and CLIENT_SECRET with real values)
curl -s -X POST https://idp.example.com/api/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=test-client&client_secret=test-secret&scope=read"
```
Expected response (abbreviated):
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read"
}
```
### 4.4 JWKS Endpoint
```bash
curl -si https://idp.example.com/.well-known/jwks.json
```
Expected: HTTP 200 with a JSON object containing a `keys` array with at least one RSA public key entry.
### 4.5 TLS Verification
```bash
# Verify TLS certificate is valid and matches your domain
curl -vI https://idp.example.com 2>&1 | grep -E "(SSL|TLS|certificate|issuer|subject)"
```
Expected: TLS 1.2 or 1.3, certificate issued by a trusted CA, subject matching your domain.
### 4.6 AWS-Specific: ECS Service Status
```bash
aws ecs describe-services \
--cluster sentryagent-agentidp-production \
--services sentryagent-agentidp-production \
--query 'services[0].{desired:desiredCount,running:runningCount,pending:pendingCount,status:status}'
```
Expected: `running` equals `desired`, `status` is `ACTIVE`.
### 4.7 GCP-Specific: Cloud Run Service Status
```bash
gcloud run services describe sentryagent-agentidp-production \
--region us-central1 \
--format='value(status.conditions[0].type,status.conditions[0].status)'
```
Expected: `Ready True`.
---
## 5. Rollback Procedure
### 5.1 Image Rollback (Recommended — fastest)
To roll back to a previous image tag without modifying infrastructure:
**AWS:**
```bash
# Find the previous task definition revision
aws ecs list-task-definitions \
--family-prefix sentryagent-agentidp-production \
--sort DESC \
--query 'taskDefinitionArns[:5]'
# Update the service to use the previous task definition
aws ecs update-service \
--cluster sentryagent-agentidp-production \
--service sentryagent-agentidp-production \
--task-definition sentryagent-agentidp-production:PREVIOUS_REVISION \
--force-new-deployment
# Monitor the rollout
aws ecs wait services-stable \
--cluster sentryagent-agentidp-production \
--services sentryagent-agentidp-production
```
**GCP:**
```bash
# Deploy the previous image tag directly
gcloud run services update sentryagent-agentidp-production \
--region us-central1 \
--image sentryagent/agentidp:PREVIOUS_TAG
# Or route 100% of traffic to a specific revision
gcloud run services update-traffic sentryagent-agentidp-production \
--region us-central1 \
--to-revisions PREVIOUS_REVISION_NAME=100
```
### 5.2 Infrastructure Rollback via Terraform
If an infrastructure change (not an image update) caused the problem:
```bash
# Check the state and plan to understand what changed
terraform show
terraform plan
# If you have a previous state file (S3/GCS versioning), restore it:
# AWS:
aws s3 cp s3://your-state-bucket/agentidp/aws/production/terraform.tfstate.PREVIOUS ./terraform.tfstate
terraform apply -target=<affected_resource>
# GCP:
gsutil cp gs://your-state-bucket/agentidp/gcp/production/PREVIOUS_VERSION ./terraform.tfstate
terraform apply -target=<affected_resource>
```
**Never run `terraform destroy` in production without CEO approval.**
### 5.3 Database Rollback
RDS (AWS) and Cloud SQL (GCP) both support point-in-time restore. Use this only as a last resort — it creates a new DB instance and requires updating the `DATABASE_URL` secret.
**AWS:**
```bash
# Restore to a point before the problematic deployment
aws rds restore-db-instance-to-point-in-time \
--source-db-instance-identifier sentryagent-agentidp-production \
--target-db-instance-identifier sentryagent-agentidp-production-restored \
--restore-time 2026-01-01T12:00:00Z
```
**GCP:**
```bash
# List available backups
gcloud sql backups list --instance sentryagent-agentidp-production-pg14
# Restore from a backup
gcloud sql backups restore BACKUP_ID \
--restore-instance sentryagent-agentidp-production-pg14
```
---
## 6. Environment Variable Reference
All environment variables injected into the AgentIdP container are documented in full at:
**[docs/devops/environment-variables.md](./environment-variables.md)**
### Quick Reference
| Variable | Required | Source (AWS) | Source (GCP) |
|----------|----------|--------------|--------------|
| `DATABASE_URL` | Yes | Secrets Manager: `/<project>/<env>/database-url` | Secret Manager: `<name-prefix>-database-url` |
| `REDIS_URL` | Yes | Secrets Manager: `/<project>/<env>/redis-url` | Secret Manager: `<name-prefix>-redis-url` |
| `JWT_PRIVATE_KEY` | Yes | Secrets Manager: `/<project>/<env>/jwt-private-key` | Secret Manager: `<name-prefix>-jwt-private-key` |
| `JWT_PUBLIC_KEY` | Yes | Secrets Manager: `/<project>/<env>/jwt-public-key` | Secret Manager: `<name-prefix>-jwt-public-key` |
| `PORT` | No | Task definition env var (default: 3000) | Cloud Run env var (default: 3000) |
| `NODE_ENV` | No | Task definition env var (`production`) | Cloud Run env var (`production`) |
| `CORS_ORIGIN` | No | Task definition env var | Cloud Run env var |
| `POLICY_DIR` | No | Task definition env var (`/app/policies`) | Cloud Run env var (`/app/policies`) |
| `VAULT_ADDR` | No | Task definition env var | Cloud Run env var |
| `VAULT_TOKEN` | No | Secrets Manager: `/<project>/<env>/vault-token` | Secret Manager: `<name-prefix>-vault-token` |
| `VAULT_MOUNT` | No | Task definition env var (default: `secret`) | Cloud Run env var (default: `secret`) |
| `BILLING_ENABLED` | No | Task definition env var | Cloud Run env var |
| `STRIPE_SECRET_KEY` | Only if billing enabled | Secrets Manager: `/<project>/<env>/stripe-secret-key` | Secret Manager: `<name-prefix>-stripe-secret-key` |
| `STRIPE_WEBHOOK_SECRET` | Only if billing enabled | Secrets Manager: `/<project>/<env>/stripe-webhook-secret` | Secret Manager: `<name-prefix>-stripe-webhook-secret` |
| `STRIPE_PRICE_ID` | Only if billing enabled | Task definition env var | Cloud Run env var |
| `ANALYTICS_ENABLED` | No | Task definition env var (default: `true`) | Cloud Run env var |
| `TIER_ENFORCEMENT` | No | Task definition env var (default: `true`) | Cloud Run env var |
| `COMPLIANCE_ENABLED` | No | Task definition env var (default: `true`) | Cloud Run env var |
| `REDIS_RATE_LIMIT_ENABLED` | No | Task definition env var | Cloud Run env var |
| `RATE_LIMIT_WINDOW_MS` | No | Task definition env var (default: `60000`) | Cloud Run env var |
| `RATE_LIMIT_MAX_REQUESTS` | No | Task definition env var (default: `100`) | Cloud Run env var |
| `DB_POOL_MAX` | No | Task definition env var (default: `20`) | Cloud Run env var |
| `DB_POOL_MIN` | No | Task definition env var (default: `2`) | Cloud Run env var |
| `DB_POOL_IDLE_TIMEOUT_MS` | No | Task definition env var (default: `30000`) | Cloud Run env var |
| `DB_POOL_CONNECTION_TIMEOUT_MS` | No | Task definition env var (default: `5000`) | Cloud Run env var |
| `KAFKA_BROKERS` | No | Task definition env var | Cloud Run env var |
| `ENFORCE_TLS` | No | Task definition env var | Cloud Run env var |
| `OPA_URL` | No | Task definition env var | Cloud Run env var |
| `VAULT_KV_MOUNT` | No | Task definition env var (default: `secret`) | Cloud Run env var |
### Updating a Secret
**AWS:**
```bash
# Update a secret value (e.g. rotate JWT keys)
aws secretsmanager put-secret-value \
--secret-id /sentryagent-agentidp/production/jwt-private-key \
--secret-string "$(cat new_jwt_private.pem)"
# Force new ECS deployment to pick up the new secret value
aws ecs update-service \
--cluster sentryagent-agentidp-production \
--service sentryagent-agentidp-production \
--force-new-deployment
```
**GCP:**
```bash
# Add a new version of a secret
gcloud secrets versions add sentryagent-agentidp-production-jwt-private-key \
--data-file=new_jwt_private.pem
# Deploy a new Cloud Run revision to pick up the latest secret version
gcloud run services update sentryagent-agentidp-production \
--region us-central1 \
--image sentryagent/agentidp:CURRENT_TAG
```
---
## Architecture Summary
### AWS
```
Route 53 (A alias)
└── ALB (public subnets, HTTPS/443, ACM cert, HTTP→HTTPS redirect)
└── Target Group
└── ECS Fargate Service (private subnets, 2+ tasks)
├── Secrets Manager (DATABASE_URL, REDIS_URL, JWT keys)
├── RDS PostgreSQL 14 (private subnets, Multi-AZ, encrypted)
└── ElastiCache Redis 7 (private subnets, primary+replica, TLS)
```
### GCP
```
Internet → Cloud Run Service (Google-managed TLS, auto-scaling)
├── Secret Manager (DATABASE_URL, REDIS_URL, JWT keys)
├── Serverless VPC Connector
│ ├── Cloud SQL PostgreSQL 14 (private IP, REGIONAL HA)
│ └── Memorystore Redis 7 (STANDARD_HA, TLS)
```
Both environments share the same Docker image (`sentryagent/agentidp`) and the same JWT key pair — tokens issued in one region are verifiable in the other.

View File

@@ -20,7 +20,7 @@ PostgreSQL connection string.
| **Format** | `postgresql://<user>:<password>@<host>:<port>/<database>` | | **Format** | `postgresql://<user>:<password>@<host>:<port>/<database>` |
| **Example** | `postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp` | | **Example** | `postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp` |
The application uses `pg.Pool` with this connection string. Connection pool size uses the `pg` default (10 connections). The application uses `pg.Pool` with this connection string. Pool sizing is controlled by the optional `DB_POOL_*` variables documented below.
--- ---
@@ -72,6 +72,10 @@ Every authenticated request verifies the JWT signature using this key. If this k
--- ---
> **Note on Billing:** `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `STRIPE_PRICE_ID` are
> required when `BILLING_ENABLED=true`. For local development, set `BILLING_ENABLED=false` and
> use placeholder values.
## Optional Variables ## Optional Variables
These variables have defaults and do not need to be set for local development. These variables have defaults and do not need to be set for local development.
@@ -117,6 +121,272 @@ KV v2 secrets engine mount path.
--- ---
### `BILLING_ENABLED`
| | |
|-|-|
| **Required** | No |
| **Default** | `false` |
| **Values** | `true`, `false` |
| **Example** | `BILLING_ENABLED=false` |
Gates Stripe billing integration and free-tier agent limit enforcement. When `false`, no Stripe
API calls are made and all tier limits are unenforced. Set to `false` for in-house testing.
---
### `STRIPE_SECRET_KEY`
| | |
|-|-|
| **Required** | Only when `BILLING_ENABLED=true` |
| **Format** | Stripe secret key string (`sk_live_*` or `sk_test_*`) |
| **Example** | `STRIPE_SECRET_KEY=sk_test_placeholder` |
Stripe API key used to create Checkout Sessions for tier upgrades. Never use a live key in
development.
---
### `STRIPE_WEBHOOK_SECRET`
| | |
|-|-|
| **Required** | Only when `BILLING_ENABLED=true` |
| **Format** | Stripe webhook signing secret (`whsec_*`) |
| **Example** | `STRIPE_WEBHOOK_SECRET=whsec_placeholder` |
Used to verify the HMAC signature on incoming Stripe webhook events. Without this, the billing
webhook endpoint will reject all events.
---
### `STRIPE_PRICE_ID`
| | |
|-|-|
| **Required** | Only when `BILLING_ENABLED=true` |
| **Format** | Stripe Price ID string (`price_*`) |
| **Example** | `STRIPE_PRICE_ID=price_placeholder` |
The Stripe Price object used when creating a Checkout Session for the Pro tier upgrade.
---
### `ANALYTICS_ENABLED`
| | |
|-|-|
| **Required** | No |
| **Default** | `true` |
| **Values** | `true`, `false` |
| **Example** | `ANALYTICS_ENABLED=true` |
Feature flag that gates the `/api/v1/analytics/*` routes. When `false`, the analytics router is
not mounted and all analytics endpoints return 404. Events are still recorded internally
regardless of this flag.
---
### `TIER_ENFORCEMENT`
| | |
|-|-|
| **Required** | No |
| **Default** | `true` |
| **Values** | `true`, `false` |
| **Example** | `TIER_ENFORCEMENT=true` |
Enables Redis-backed tier limit enforcement per tenant. When `true`, the `tierEnforcement`
middleware checks daily API call and token counts against per-tier limits defined in
`src/config/tiers.ts`. Enterprise tenants with `maxCallsPerDay: Infinity` bypass enforcement.
When `false`, no tier limits are enforced.
---
### `COMPLIANCE_ENABLED`
| | |
|-|-|
| **Required** | No |
| **Default** | `true` |
| **Values** | `true`, `false` |
| **Example** | `COMPLIANCE_ENABLED=true` |
Feature flag that gates the report and agent-card export endpoints under
`/api/v1/compliance/*`. When `false`, those endpoints return 404. The SOC2 controls endpoint
(`/api/v1/compliance/controls`) and audit chain verification (`/api/v1/audit/verify`) are
always enabled regardless of this flag.
---
### `REDIS_RATE_LIMIT_ENABLED`
| | |
|-|-|
| **Required** | No |
| **Default** | `false` |
| **Values** | `true`, `false` |
| **Example** | `REDIS_RATE_LIMIT_ENABLED=true` |
When `true`, rate limiting uses a Redis-backed sliding-window counter per `client_id`. When
`false`, rate limiting uses an in-process `RateLimiterMemory` store (does not share state
across multiple app instances).
---
### `RATE_LIMIT_WINDOW_MS`
| | |
|-|-|
| **Required** | No |
| **Default** | `60000` |
| **Format** | Integer (milliseconds) |
| **Example** | `RATE_LIMIT_WINDOW_MS=60000` |
Duration of the sliding-window rate limit period in milliseconds. Only effective when
`REDIS_RATE_LIMIT_ENABLED=true`.
---
### `RATE_LIMIT_MAX_REQUESTS`
| | |
|-|-|
| **Required** | No |
| **Default** | `100` |
| **Format** | Integer |
| **Example** | `RATE_LIMIT_MAX_REQUESTS=100` |
Maximum number of requests allowed per `client_id` within `RATE_LIMIT_WINDOW_MS`. Requests
exceeding this limit receive `429 RATE_LIMIT_EXCEEDED`.
---
### `DB_POOL_MAX`
| | |
|-|-|
| **Required** | No |
| **Default** | `20` |
| **Format** | Integer |
| **Example** | `DB_POOL_MAX=20` |
Maximum number of PostgreSQL connections in the pool. Increase for high-throughput production
deployments. Ensure your PostgreSQL instance's `max_connections` is set to at least
`DB_POOL_MAX × number_of_app_instances + 5`.
---
### `DB_POOL_MIN`
| | |
|-|-|
| **Required** | No |
| **Default** | `2` |
| **Format** | Integer |
| **Example** | `DB_POOL_MIN=2` |
Minimum number of idle connections kept alive in the pool.
---
### `DB_POOL_IDLE_TIMEOUT_MS`
| | |
|-|-|
| **Required** | No |
| **Default** | `30000` |
| **Format** | Integer (milliseconds) |
| **Example** | `DB_POOL_IDLE_TIMEOUT_MS=30000` |
Milliseconds a connection can sit idle before being evicted from the pool.
---
### `DB_POOL_CONNECTION_TIMEOUT_MS`
| | |
|-|-|
| **Required** | No |
| **Default** | `5000` |
| **Format** | Integer (milliseconds) |
| **Example** | `DB_POOL_CONNECTION_TIMEOUT_MS=5000` |
Milliseconds the pool waits for a connection to become available before throwing a connection
timeout error.
---
### `VAULT_KV_MOUNT`
| | |
|-|-|
| **Required** | No |
| **Default** | `secret` |
| **Format** | String (no leading or trailing slash) |
| **Example** | `VAULT_KV_MOUNT=agentidp` |
KV v2 secrets engine mount path used by `VaultService`. Equivalent to the existing `VAULT_MOUNT`
variable — note that `.env.example` uses `VAULT_KV_MOUNT`; the underlying service reads either.
---
### `OPA_URL`
| | |
|-|-|
| **Required** | No |
| **Format** | URL string |
| **Example** | `OPA_URL=http://localhost:8181` |
URL of a running OPA server for external policy evaluation. When unset, the application falls
back to the embedded Wasm or JSON policy in `POLICY_DIR`. Used for health check reporting.
---
### `KAFKA_BROKERS`
| | |
|-|-|
| **Required** | No |
| **Format** | Comma-separated broker addresses |
| **Example** | `KAFKA_BROKERS=localhost:9092` |
When set, the `KafkaAdapter` publishes domain events to Kafka. When unset, Kafka publishing is
disabled and events are only delivered via the `WebhookService`.
---
### `ENFORCE_TLS`
| | |
|-|-|
| **Required** | No |
| **Default** | `false` |
| **Values** | `true`, `false` |
| **Example** | `ENFORCE_TLS=true` |
When `true`, the `tlsEnforcementMiddleware` redirects all HTTP requests to HTTPS. Enable in
production deployments where TLS termination is handled at the application layer.
---
### `POLICY_DIR`
Directory containing OPA policy files (`authz.rego`, `authz.wasm`, `data/scopes.json`).
| | |
|-|-|
| **Required** | No |
| **Default** | `<cwd>/policies` |
| **Format** | Absolute or relative directory path |
| **Example** | `POLICY_DIR=/etc/sentryagent/policies` |
At startup the OPA authorization middleware loads `${POLICY_DIR}/authz.wasm` (Wasm mode) if present; otherwise it loads `${POLICY_DIR}/data/scopes.json` (fallback mode). Send `SIGHUP` to the process to hot-reload the policy files without a restart.
---
### `PORT` ### `PORT`
HTTP port the Express server listens on. HTTP port the Express server listens on.
@@ -163,30 +433,53 @@ In production, set this to the specific origin(s) that should be permitted to ca
## Complete `.env` Example ## Complete `.env` Example
``` ```
# Database # ── Server ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp
# Redis
REDIS_URL=redis://localhost:6379
# Application
PORT=3000
NODE_ENV=development NODE_ENV=development
CORS_ORIGIN=* PORT=3000
CORS_ORIGIN=http://localhost:3001
# JWT Keys (generate with openssl — see docs/devops/security.md) # ── Database ─────────────────────────────────────────────────────────────────
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp
MIIEowIBAAKCAQEA... DB_POOL_MAX=20
-----END RSA PRIVATE KEY-----" DB_POOL_MIN=2
DB_POOL_IDLE_TIMEOUT_MS=30000
DB_POOL_CONNECTION_TIMEOUT_MS=5000
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY----- # ── Redis ────────────────────────────────────────────────────────────────────
MIIBIjANBgkq... REDIS_URL=redis://localhost:6379
-----END PUBLIC KEY-----" REDIS_RATE_LIMIT_ENABLED=true
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_REQUESTS=100
# HashiCorp Vault (Phase 2 — optional, omit to use bcrypt mode) # ── JWT Keys (generate with openssl — see docs/devops/security.md) ──────────
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEow...\n-----END RSA PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMIIBIj...\n-----END PUBLIC KEY-----"
# ── Billing (Stripe) — set BILLING_ENABLED=false for local/in-house testing ─
BILLING_ENABLED=false
STRIPE_SECRET_KEY=sk_test_placeholder
STRIPE_WEBHOOK_SECRET=whsec_placeholder
STRIPE_PRICE_ID=price_placeholder
# ── Phase 6 Feature Flags ─────────────────────────────────────────────────────
ANALYTICS_ENABLED=true
TIER_ENFORCEMENT=true
COMPLIANCE_ENABLED=true
# ── HashiCorp Vault (optional) ────────────────────────────────────────────────
# VAULT_ADDR=http://127.0.0.1:8200 # VAULT_ADDR=http://127.0.0.1:8200
# VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX # VAULT_TOKEN=hvs.XXXXXXXXXXXXXXXXXXXXXX
# VAULT_MOUNT=secret # VAULT_KV_MOUNT=secret
# ── OPA (optional) ───────────────────────────────────────────────────────────
# POLICY_DIR=/etc/sentryagent/policies
# OPA_URL=http://localhost:8181
# ── Kafka (optional) ─────────────────────────────────────────────────────────
# KAFKA_BROKERS=localhost:9092
# ── TLS ──────────────────────────────────────────────────────────────────────
# ENFORCE_TLS=true
``` ```
> Do not commit `.env` to version control. Add it to `.gitignore`. > Do not commit `.env` to version control. Add it to `.gitignore`.
@@ -202,3 +495,8 @@ The application validates required variables at startup in this order:
3. `REDIS_URL` — checked when `getRedisClient()` is first called (during `createApp()`) 3. `REDIS_URL` — checked when `getRedisClient()` is first called (during `createApp()`)
If any required variable is missing, the process exits with an error before binding to any port. If any required variable is missing, the process exits with an error before binding to any port.
> **Feature flags** (`BILLING_ENABLED`, `ANALYTICS_ENABLED`, `TIER_ENFORCEMENT`,
> `COMPLIANCE_ENABLED`) are read at startup. `ANALYTICS_ENABLED` and `COMPLIANCE_ENABLED`
> determine whether their respective routers are mounted — changing these values requires a
> process restart.

946
docs/devops/field-trial.md Normal file
View File

@@ -0,0 +1,946 @@
# SentryAgent.ai AgentIdP — In-House Field Trial Guide
This guide is the execution playbook for in-house Docker Compose field trials of SentryAgent.ai
AgentIdP. Follow each phase in order. All commands are exact — copy and paste them directly.
Estimated time to complete all phases: 4560 minutes.
Prerequisites must be satisfied before Section 0.
## Prerequisites
**Docker 24+ and Docker Compose 2.20+**
```bash
docker --version
# Expected: Docker version 24.x.x or higher
docker compose version
# Expected: Docker Compose version v2.20.x or higher
```
**Node.js 18+ via nvm**
```bash
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
node --version
# Expected: v18.x.x or higher
```
**openssl**
```bash
openssl version
# Expected: OpenSSL 1.1.x or higher (any version)
```
**Git repo cloned**
```bash
git clone https://git.sentryagent.ai/vijay_admin/sentryagent-idp.git
cd sentryagent-idp
```
**Ports free**
The following ports must be free on the machine before starting:
| Port | Service |
|------|---------|
| 3000 | AgentIdP backend |
| 3001 | Next.js portal |
| 5432 | PostgreSQL |
| 6379 | Redis |
Check all ports:
```bash
lsof -i :3000 -i :3001 -i :5432 -i :6379
# Expected: no output (all ports free)
```
If any port is in use, kill the occupying process:
```bash
lsof -ti:<port> | xargs kill
```
---
## Section 0 — Environment Setup
This section guides the engineer through creating a valid `.env` file for field trial use.
**Step 0.1 — Copy `.env.example`**
```bash
cp .env.example .env
```
**Step 0.2 — Generate RSA-2048 keypair**
Generate the JWT signing keys:
```bash
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
```
Verify the keys are valid:
```bash
openssl rsa -in private.pem -check -noout
# Expected: RSA key ok
openssl rsa -in public.pem -pubin -noout -text 2>&1 | head -3
# Expected: Public-Key: (2048 bit)
```
**Step 0.3 — Write keys into `.env`**
Write the private key as a single-line PEM with `\n` separators:
```bash
PRIVATE_KEY_LINE=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem)
sed -i "s|JWT_PRIVATE_KEY=.*|JWT_PRIVATE_KEY=\"${PRIVATE_KEY_LINE}\"|" .env
```
Write the public key:
```bash
PUBLIC_KEY_LINE=$(awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem)
sed -i "s|JWT_PUBLIC_KEY=.*|JWT_PUBLIC_KEY=\"${PUBLIC_KEY_LINE}\"|" .env
```
Verify both keys are present and non-empty:
```bash
grep -c "BEGIN RSA PRIVATE KEY" .env
# Expected: 1
grep -c "BEGIN PUBLIC KEY" .env
# Expected: 1
```
**Step 0.4 — Configure field trial values**
Set the following values in `.env`. These are the correct values for an in-house field trial
(no real Stripe, no Kafka, no Vault):
```bash
# Disable real Stripe billing for field trial
sed -i "s|BILLING_ENABLED=.*|BILLING_ENABLED=false|" .env
sed -i "s|STRIPE_SECRET_KEY=.*|STRIPE_SECRET_KEY=sk_test_placeholder|" .env
sed -i "s|STRIPE_WEBHOOK_SECRET=.*|STRIPE_WEBHOOK_SECRET=whsec_placeholder|" .env
sed -i "s|STRIPE_PRICE_ID=.*|STRIPE_PRICE_ID=price_placeholder|" .env
# Keep feature flags at defaults
sed -i "s|ANALYTICS_ENABLED=.*|ANALYTICS_ENABLED=true|" .env
sed -i "s|TIER_ENFORCEMENT=.*|TIER_ENFORCEMENT=true|" .env
sed -i "s|COMPLIANCE_ENABLED=.*|COMPLIANCE_ENABLED=true|" .env
# Allow portal CORS
sed -i "s|CORS_ORIGIN=.*|CORS_ORIGIN=http://localhost:3001|" .env
```
**Step 0.5 — Verify final `.env`**
```bash
grep -E "^(DATABASE_URL|REDIS_URL|JWT_PRIVATE_KEY|JWT_PUBLIC_KEY|BILLING_ENABLED|ANALYTICS_ENABLED|TIER_ENFORCEMENT|COMPLIANCE_ENABLED|CORS_ORIGIN)=" .env
```
Expected output (values abbreviated):
```
DATABASE_URL=postgresql://agentidp:password@localhost:5432/agentidp
REDIS_URL=redis://localhost:6379
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...
BILLING_ENABLED=false
ANALYTICS_ENABLED=true
TIER_ENFORCEMENT=true
COMPLIANCE_ENABLED=true
CORS_ORIGIN=http://localhost:3001
```
---
## Phase A — Stack Startup
**Step A.1 — Build and start the full stack**
```bash
docker compose up --build -d
```
This builds the `app` container image and starts all three services. The `app` service waits
for `postgres` and `redis` to pass their health checks before starting.
**Step A.2 — Verify all services are healthy**
```bash
docker compose ps
```
Expected output — all three services must show `healthy`:
```
NAME IMAGE STATUS
sentryagent-idp-app-1 sentryagent-idp-app running (healthy)
sentryagent-idp-postgres-1 postgres:14-alpine running (healthy)
sentryagent-idp-redis-1 redis:7-alpine running (healthy)
```
If any service shows `starting` or `unhealthy`, wait 15 seconds and run `docker compose ps`
again. If a service remains unhealthy after 60 seconds, see Troubleshooting.
**Step A.3 — Run database migrations**
```bash
docker compose exec app npm run db:migrate
```
Expected output:
```
Running database migrations...
✓ Applied: 001_create_agents.sql
✓ Applied: 002_create_credentials.sql
...
✓ Applied: 025_add_analytics_events.sql
✓ Applied: 026_add_tenant_tiers.sql
Migrations complete. 26 migration(s) applied.
```
All 26 migrations must apply without error before proceeding.
**Step A.4 — Verify application health**
```bash
curl -s http://localhost:3000/health | jq .
```
Expected response:
```json
{"status":"ok"}
```
**Step A.5 — Verify Prometheus metrics**
```bash
curl -s http://localhost:3000/metrics | head -20
```
Expected: Prometheus text output beginning with `# HELP` lines. Verify these specific metrics
are present:
```bash
curl -s http://localhost:3000/metrics | grep -E "^# HELP agentidp_"
```
Expected: at least 19 lines matching `# HELP agentidp_*`.
---
## Phase B — Core Product Journeys
This phase tests the end-to-end agent identity lifecycle. Run each step in order. Each step
depends on the output of the previous step.
> **Note on tokens:** The steps below use shell variables to pass values between commands. Run
> all commands in the same terminal session.
**Step B.1 — Create an organisation**
```bash
ORG_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/organizations \
-H "Content-Type: application/json" \
-d '{"name":"Field Trial Org","slug":"field-trial"}')
echo $ORG_RESPONSE | jq .
ORG_ID=$(echo $ORG_RESPONSE | jq -r '.org_id')
echo "ORG_ID: $ORG_ID"
```
Expected: HTTP 201 response body containing an `org_id` UUID. `ORG_ID` must be a non-empty UUID.
**Step B.2 — Register an agent**
```bash
AGENT_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/agents \
-H "Content-Type: application/json" \
-d "{
\"email\": \"trial-agent@field-trial.sentryagent.ai\",
\"agent_type\": \"classifier\",
\"version\": \"1.0.0\",
\"capabilities\": [\"documents:read\", \"documents:classify\"],
\"owner\": \"field-trial-team\",
\"deployment_env\": \"development\",
\"organization_id\": \"$ORG_ID\"
}")
echo $AGENT_RESPONSE | jq .
AGENT_ID=$(echo $AGENT_RESPONSE | jq -r '.agent_id')
echo "AGENT_ID: $AGENT_ID"
```
Expected: HTTP 201 response body containing an `agent_id` UUID.
**Step B.3 — Generate credentials**
```bash
CRED_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/credentials \
-H "Content-Type: application/json" \
-d "{\"agent_id\": \"$AGENT_ID\"}")
echo $CRED_RESPONSE | jq .
CLIENT_ID=$(echo $CRED_RESPONSE | jq -r '.client_id')
CLIENT_SECRET=$(echo $CRED_RESPONSE | jq -r '.client_secret')
echo "CLIENT_ID: $CLIENT_ID"
echo "CLIENT_SECRET: $CLIENT_SECRET"
```
Expected: HTTP 201 response body containing `client_id` and `client_secret`. The `client_secret`
is only returned once — save it now.
**Step B.4 — Issue an OAuth 2.0 access token**
```bash
TOKEN_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&scope=read")
echo $TOKEN_RESPONSE | jq .
ACCESS_TOKEN=$(echo $TOKEN_RESPONSE | jq -r '.access_token')
echo "ACCESS_TOKEN obtained: ${ACCESS_TOKEN:0:30}..."
```
Expected: HTTP 200 response body with `access_token`, `token_type: "Bearer"`, `expires_in: 3600`,
`scope: "read"`.
**Step B.5 — Use the token on a protected endpoint**
```bash
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents | jq .
```
Expected: HTTP 200 with a JSON array of agents including the agent registered in Step B.2.
**Step B.6 — Inspect JWT claims**
Decode and inspect the access token structure (without verifying signature):
```bash
echo $ACCESS_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
```
Expected claims:
```json
{
"sub": "<client_id>",
"iss": "https://sentryagent.ai",
"aud": "sentryagent-api",
"scope": "read",
"agent_id": "<agent_id>",
"organization_id": "<org_id>",
"iat": "<issued-at-timestamp>",
"exp": "<expiry-timestamp>",
"jti": "<unique-jwt-id>"
}
```
Verify `exp - iat = 3600` (1 hour TTL).
**Step B.7 — Rotate credentials and verify old token is rejected**
Rotate the credentials (generates a new client_secret, revokes the old one):
```bash
ROTATE_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/credentials \
-H "Content-Type: application/json" \
-d "{\"agent_id\": \"$AGENT_ID\"}")
NEW_CLIENT_ID=$(echo $ROTATE_RESPONSE | jq -r '.client_id')
NEW_CLIENT_SECRET=$(echo $ROTATE_RESPONSE | jq -r '.client_secret')
echo "New credential: $NEW_CLIENT_ID"
```
Attempt to use the old token (must be rejected):
```bash
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents
# Expected: 401
```
Issue a new token with the new credentials:
```bash
NEW_TOKEN_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=$NEW_CLIENT_ID&client_secret=$NEW_CLIENT_SECRET&scope=read")
NEW_ACCESS_TOKEN=$(echo $NEW_TOKEN_RESPONSE | jq -r '.access_token')
echo "New token obtained."
```
Verify the new token works:
```bash
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents
# Expected: 200
```
**Step B.8 — Check audit log**
```bash
curl -s -H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
"http://localhost:3000/api/v1/audit?limit=10" | jq .
```
Expected: JSON array of audit events. Verify these action types are present from Steps B.1B.7:
`agent.created`, `credential.generated`, `token.issued`, `credential.rotated`, `token.revoked`.
---
## Phase C — Guardrails
This phase tests security boundaries. Each test case must be run with the exact command shown
and must produce the specified HTTP status code.
> **Setup:** Ensure `$NEW_ACCESS_TOKEN` is still set from Phase B. Use `export NEW_ACCESS_TOKEN`
> if switching terminals.
**Test C.1 — No Authorization header → 401**
```bash
curl -s -o /dev/null -w "%{http_code}" \
http://localhost:3000/api/v1/agents
```
Expected HTTP status: `401`
**Test C.2 — Malformed JWT → 401**
```bash
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer notavalidjwt" \
http://localhost:3000/api/v1/agents
```
Expected HTTP status: `401`
**Test C.3 — Expired JWT → 401**
Use a known-expired token. Generate one with a 1-second TTL (requires a test helper or
manually craft an expired JWT). For field trial purposes, use this pre-constructed expired token
(signed with a different key — will fail signature verification and return 401):
```bash
EXPIRED_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0IiwiZXhwIjoxfQ.invalid"
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $EXPIRED_TOKEN" \
http://localhost:3000/api/v1/agents
```
Expected HTTP status: `401`
**Test C.4 — Valid JWT, wrong scope → 403**
Issue a token with scope `read`, then attempt to access an endpoint requiring scope `write`:
```bash
# The NEW_ACCESS_TOKEN has scope "read"
# Attempt an action requiring "write" scope (create agent)
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-X POST http://localhost:3000/api/v1/agents \
-d '{"email":"scope-test@example.com","agent_type":"custom","version":"1.0.0","capabilities":[],"owner":"test","deployment_env":"development"}'
```
Expected HTTP status: `403`
**Test C.5 — Rate limit: 101 requests → 429 on the 101st**
Send 101 requests in rapid succession. The 101st must return 429.
```bash
for i in $(seq 1 101); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents)
if [ "$STATUS" = "429" ]; then
echo "Request $i returned 429 (PASS)"
break
fi
done
```
Expected: Output shows `Request 101 returned 429 (PASS)` (or earlier if previous requests in
the session have already counted toward the window).
After this test, wait 60 seconds for the rate limit window to reset, or use a fresh
`client_id` for subsequent tests.
**Test C.6 — Tier limit: exceed free-tier API call limit → 429 with `tier_limit_exceeded`**
The free tier allows 1,000 API calls per day. For field trial, manually set the counter to the
limit value to trigger the guard without making 1,000 real requests:
```bash
# Get the org_id from the token
ORG_ID=$(echo $NEW_ACCESS_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.organization_id')
# Force the counter to the limit via Redis CLI
docker compose exec redis redis-cli SET "rate:tier:calls:$ORG_ID" 1001 EX 86400
# The next API call must be rejected
TIER_RESPONSE=$(curl -s -w "\n%{http_code}" \
-H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents)
echo "$TIER_RESPONSE"
```
Expected: HTTP status `429`. Response body must contain `"code":"tier_limit_exceeded"`.
Reset the counter after this test:
```bash
docker compose exec redis redis-cli DEL "rate:tier:calls:$ORG_ID"
```
**Test C.7 — Tenant isolation: Org A token cannot access Org B agents → 403**
Create a second organisation and agent:
```bash
ORG_B_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/organizations \
-H "Content-Type: application/json" \
-d '{"name":"Org B","slug":"org-b"}')
ORG_B_ID=$(echo $ORG_B_RESPONSE | jq -r '.org_id')
echo "ORG_B_ID: $ORG_B_ID"
AGENT_B_RESPONSE=$(curl -s -X POST http://localhost:3000/api/v1/agents \
-H "Content-Type: application/json" \
-d "{
\"email\": \"org-b-agent@org-b.sentryagent.ai\",
\"agent_type\": \"monitor\",
\"version\": \"1.0.0\",
\"capabilities\": [],
\"owner\": \"org-b\",
\"deployment_env\": \"development\",
\"organization_id\": \"$ORG_B_ID\"
}")
AGENT_B_ID=$(echo $AGENT_B_RESPONSE | jq -r '.agent_id')
echo "AGENT_B_ID: $AGENT_B_ID"
```
Attempt to access Org B's agent using Org A's token:
```bash
curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents/$AGENT_B_ID
```
Expected HTTP status: `403`
---
## Phase D — Portal
**Step D.1 — Install portal dependencies**
```bash
cd portal && npm install && cd ..
```
**Step D.2 — Start the portal development server**
```bash
cd portal && npm run dev &
```
Wait 5 seconds for Next.js to compile, then verify it is listening:
```bash
curl -s -o /dev/null -w "%{http_code}" http://localhost:3001
# Expected: 200 or 307 (redirect to /login)
```
**Step D.3 — Verify each portal route loads**
Open a browser and navigate to each of the following URLs. Each must load without a JavaScript
error in the browser console:
| URL | Expected |
|-----|---------|
| `http://localhost:3001/login` | Login page renders |
| `http://localhost:3001/agents` | Agent list renders (may be empty or show auth redirect) |
| `http://localhost:3001/credentials` | Credentials page renders |
| `http://localhost:3001/audit` | Audit log page renders |
| `http://localhost:3001/analytics` | Analytics dashboard renders |
| `http://localhost:3001/settings/tier` | Tier status page renders |
| `http://localhost:3001/compliance` | Compliance report page renders |
| `http://localhost:3001/webhooks` | Webhooks page renders |
| `http://localhost:3001/marketplace` | Marketplace page renders |
All 9 routes must load without a blank page or unhandled error.
**Step D.4 — Verify analytics charts render**
Navigate to `http://localhost:3001/analytics`.
Verify both of the following chart components are present in the page DOM:
```bash
curl -s http://localhost:3001/analytics | grep -c "recharts"
# Expected: 1 or more (recharts is used for TokenTrendChart and AgentHeatmap)
```
**Step D.5 — Verify tier status page**
Navigate to `http://localhost:3001/settings/tier`.
The page must display the current tier (expected: `free` for a new organisation).
**Step D.6 — Stop the portal**
```bash
kill $(lsof -ti:3001)
```
---
## Phase E — AGNTCY Conformance
**Step E.1 — Activate nvm**
```bash
export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
```
**Step E.2 — Run the AGNTCY conformance suite**
```bash
npm run test:agntcy-conformance
```
**Step E.3 — Expected output**
```
AGNTCY Conformance Suite
Agent Card Export
✓ exports valid AGNTCY agent card format
✓ agent card contains required identity fields
Compliance Report
✓ generates SOC2-aligned compliance report
✓ compliance report includes all required control domains
4 passing (Xs)
```
All 4 tests must pass. A failure indicates a regression in AGNTCY conformance.
**What each test validates:**
| Test | What it validates |
|------|------------------|
| `exports valid AGNTCY agent card format` | The `/api/v1/compliance/agent-cards` endpoint returns an array where each card has `id`, `name`, `version`, `capabilities`, `did` fields in AGNTCY format |
| `agent card contains required identity fields` | Each agent card's `identity` block includes `agent_id`, `organization_id`, `did`, and `deployment_env` |
| `generates SOC2-aligned compliance report` | The `/api/v1/compliance/report` endpoint returns a report with `generated_at`, `controls`, `summary` top-level keys |
| `compliance report includes all required control domains` | The `controls` array in the report includes entries for `access_control`, `audit_logging`, `credential_management`, and `tenant_isolation` |
---
## Phase F — Performance Baseline
> **Prerequisite:** Apache Bench (`ab`) must be installed. On Ubuntu: `sudo apt install apache2-utils`.
> Verify: `ab -V`
**Step F.1 — Create a token payload file**
```bash
cat > /tmp/token_payload.json << 'EOF'
grant_type=client_credentials&client_id=REPLACE_CLIENT_ID&client_secret=REPLACE_CLIENT_SECRET&scope=read
EOF
```
Replace `REPLACE_CLIENT_ID` and `REPLACE_CLIENT_SECRET` with `$NEW_CLIENT_ID` and
`$NEW_CLIENT_SECRET` from Phase B:
```bash
cat > /tmp/token_payload.txt << EOF
grant_type=client_credentials&client_id=${NEW_CLIENT_ID}&client_secret=${NEW_CLIENT_SECRET}&scope=read
EOF
```
**Step F.2 — Benchmark token endpoint**
```bash
ab -n 100 -c 10 \
-p /tmp/token_payload.txt \
-T "application/x-www-form-urlencoded" \
http://localhost:3000/api/v1/token
```
**Pass criteria for token endpoint:**
- `Requests per second` > 10
- `Time per request (mean)` < 100 ms
- p95 (95th percentile, shown as `95%` in the `Percentage of requests` table) < 100 ms
- Zero non-2xx responses
**Step F.3 — Benchmark agent list endpoint**
Ensure `$NEW_ACCESS_TOKEN` is still set and valid. Issue a fresh token if needed:
```bash
NEW_ACCESS_TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=${NEW_CLIENT_ID}&client_secret=${NEW_CLIENT_SECRET}&scope=read" \
| jq -r '.access_token')
```
Run the benchmark:
```bash
ab -n 100 -c 10 \
-H "Authorization: Bearer $NEW_ACCESS_TOKEN" \
http://localhost:3000/api/v1/agents
```
**Pass criteria for agent list endpoint:**
- `Time per request (mean)` < 200 ms
- p95 (`95%` row in the `Percentage of requests` table) < 200 ms
- Zero non-2xx responses
**Step F.4 — Record results**
Record the following values from each `ab` output for the field trial report:
| Endpoint | Metric | Value |
|----------|--------|-------|
| `/api/v1/token` | Requests per second | |
| `/api/v1/token` | Mean time per request (ms) | |
| `/api/v1/token` | p95 (ms) | |
| `/api/v1/agents` | Requests per second | |
| `/api/v1/agents` | Mean time per request (ms) | |
| `/api/v1/agents` | p95 (ms) | |
A field trial passes Phase F if all p95 values are within the pass criteria above.
---
## Troubleshooting
Each entry follows the pattern: **Symptom****Cause****Fix** with exact commands.
---
**Port already in use**
Symptom:
```
Error response from daemon: driver failed programming external connectivity on endpoint
sentryagent-idp-app-1: Bind for 0.0.0.0:3000 failed: port is already allocated
```
Fix: Kill the process occupying the port, then restart:
```bash
lsof -ti:3000 | xargs kill
lsof -ti:5432 | xargs kill
lsof -ti:6379 | xargs kill
docker compose up --build -d
```
---
**Container shows `unhealthy`**
Symptom: `docker compose ps` shows `unhealthy` for a service.
Fix: Check logs for the unhealthy service:
```bash
docker compose logs postgres
docker compose logs redis
docker compose logs app
```
Common causes:
| Service | Cause | Fix |
|---------|-------|-----|
| `postgres` | Wrong database credentials | Verify `DATABASE_URL` in `.env` matches `docker-compose.yml` credentials |
| `redis` | Port conflict | Check `lsof -ti:6379` and kill occupying process |
| `app` | Missing env var | Check `docker compose logs app` for `Failed to start server` message |
---
**Migration fails — connection refused**
Symptom:
```
Migration failed: Error: connect ECONNREFUSED 127.0.0.1:5432
```
Cause: Running `npm run db:migrate` directly on the host (not inside the container) while
PostgreSQL is running inside Docker.
Fix: Always run migrations inside the container during a field trial:
```bash
docker compose exec app npm run db:migrate
```
---
**Migration fails — relation already exists**
Symptom:
```
Migration failed: Error: relation "agents" already exists
```
Cause: A previous partial migration run left the database in an inconsistent state.
Fix: Check which migrations have been applied:
```bash
docker compose exec postgres psql -U agentidp -d agentidp \
-c "SELECT name FROM schema_migrations ORDER BY name;"
```
If the database state cannot be repaired, reset it:
```bash
docker compose down -v
docker compose up --build -d
docker compose exec app npm run db:migrate
```
> `docker compose down -v` destroys all data. Use only when a clean slate is acceptable.
---
**JWT error — invalid signature or key format**
Symptom:
```
Failed to start server: Error: JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required
```
Or: All tokens return `401 Token signature is invalid`.
Cause: JWT keys in `.env` have incorrect PEM format — literal newlines instead of `\n`
sequences, or trailing whitespace.
Fix: Regenerate the keys and re-write them using the exact commands from Step 0.2 and 0.3.
Verify the key format in `.env`:
```bash
grep "JWT_PRIVATE_KEY" .env | head -c 100
# Expected: JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMII...
# NOT: JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
# MII...
```
The entire key must be on a single line with `\n` as literal backslash-n characters, not
actual newlines.
---
**Portal CORS error**
Symptom: Browser console shows:
```
Access to XMLHttpRequest at 'http://localhost:3000/api/v1/...' from origin 'http://localhost:3001'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
```
Cause: `CORS_ORIGIN` in `.env` does not include `http://localhost:3001`, or is set to a
different value.
Fix:
```bash
sed -i "s|CORS_ORIGIN=.*|CORS_ORIGIN=http://localhost:3001|" .env
docker compose up --build -d
```
Wait for the `app` container to become healthy before retrying.
---
**Tier counter not resetting**
Symptom: All API calls return 429 `tier_limit_exceeded` even after waiting.
Cause: The Redis tier counter was manually set in Test C.6 and not deleted.
Fix:
```bash
# Get your org_id from the token
ORG_ID=$(echo $NEW_ACCESS_TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq -r '.organization_id')
docker compose exec redis redis-cli DEL "rate:tier:calls:$ORG_ID"
docker compose exec redis redis-cli DEL "rate:tier:tokens:$ORG_ID"
```
---
**`ab` not found**
Symptom: `ab: command not found`
Fix:
```bash
sudo apt-get update && sudo apt-get install -y apache2-utils
# or on macOS:
brew install httpd
```
---
**AGNTCY conformance test fails**
Symptom: One or more tests in `npm run test:agntcy-conformance` fail.
Diagnosis steps:
1. Ensure the backend is running and healthy: `curl -s http://localhost:3000/health`
2. Ensure `COMPLIANCE_ENABLED=true` in `.env` (check with `grep COMPLIANCE_ENABLED .env`)
3. Ensure at least one agent has been registered (Phase B must have been completed)
4. Check the test output for the specific assertion that failed
5. Check `docker compose logs app` for errors around compliance report generation
If the issue is a Redis cache hit returning stale data:
```bash
docker compose exec redis redis-cli KEYS "compliance:*" | xargs docker compose exec redis redis-cli DEL
```
Then re-run the conformance suite.

View File

@@ -6,9 +6,12 @@ Complete setup guide for running AgentIdP locally.
| Tool | Minimum version | Purpose | | Tool | Minimum version | Purpose |
|------|----------------|---------| |------|----------------|---------|
| Docker + Docker Compose | 24+ | Run PostgreSQL and Redis | | Docker | 24+ | Container runtime |
| Node.js | 18.0.0 | Run the application and migrations | | Docker Compose | 2.20+ | Multi-container orchestration |
| Node.js | 18.0.0 | Run the application, portal, and migrations |
| npm | 9+ | Package management and scripts | | npm | 9+ | Package management and scripts |
| nvm | any | Recommended for managing Node.js versions |
| openssl | any | RSA key generation |
Verify versions: Verify versions:
@@ -19,6 +22,11 @@ node --version
npm --version npm --version
``` ```
> **nvm activation:** If using nvm, activate it before running any Node.js commands:
> ```bash
> export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh"
> ```
--- ---
## Step 1 — Clone and install dependencies ## Step 1 — Clone and install dependencies
@@ -27,6 +35,9 @@ npm --version
git clone https://git.sentryagent.ai/vijay_admin/sentryagent-idp.git git clone https://git.sentryagent.ai/vijay_admin/sentryagent-idp.git
cd sentryagent-idp cd sentryagent-idp
npm install npm install
# Install portal dependencies
cd portal && npm install && cd ..
``` ```
--- ---
@@ -127,11 +138,10 @@ Expected output:
``` ```
Running database migrations... Running database migrations...
✓ Applied: 001_create_agents.sql ✓ Applied: 001_create_agents.sql
✓ Applied: 002_create_credentials.sql ...
✓ Applied: 003_create_audit_events.sql ✓ Applied: 026_add_tenant_tiers.sql
✓ Applied: 004_create_tokens.sql
Migrations complete. 4 migration(s) applied. Migrations complete. 26 migration(s) applied.
``` ```
See [database.md](database.md) for full migration documentation. See [database.md](database.md) for full migration documentation.
@@ -165,9 +175,52 @@ The compiled output is written to `dist/`. `npm start` runs `node dist/server.js
--- ---
## Step 7 — Start the Next.js portal (optional)
The portal is a Next.js 14 application in the `portal/` directory. It communicates with the
AgentIdP backend at `http://localhost:3000`.
Start the portal development server:
```bash
cd portal && npm run dev
```
The portal starts on port 3001 by default. Open http://localhost:3001.
Available routes:
| Route | Description |
|-------|-------------|
| `/login` | OAuth 2.0 login page |
| `/agents` | Agent registry |
| `/credentials` | Credential management |
| `/audit` | Audit log viewer |
| `/analytics` | Token trend and agent activity charts |
| `/settings/tier` | Tier status and upgrade |
| `/compliance` | AGNTCY compliance report |
| `/webhooks` | Webhook subscription management |
| `/marketplace` | Agent marketplace |
Build the portal for production:
```bash
cd portal && npm run build
cd portal && npm start # serves the production build
```
Ensure `CORS_ORIGIN` in your `.env` includes `http://localhost:3001`:
```
CORS_ORIGIN=http://localhost:3001
```
---
## Full Docker Compose Stack ## Full Docker Compose Stack
> **Note:** The `app` service in `docker-compose.yml` requires a `Dockerfile` which has not been written yet. This is a **Phase 1 P1 pending item**. The commands below will work once the Dockerfile exists. > The full Docker Compose stack (including the `app` container) is available for field trial
> deployments — see the [field trial guide](field-trial.md). For day-to-day development, start
> only the infrastructure services and run the application directly.
When the Dockerfile is available, the entire stack (infrastructure + application) can be started with: When the Dockerfile is available, the entire stack (infrastructure + application) can be started with:

View File

@@ -18,21 +18,22 @@ Always start services in this order. Starting the application before PostgreSQL
### Startup checklist ### Startup checklist
```bash ```bash
# 1. Start PostgreSQL and Redis # 1. Start the full stack
docker-compose up -d postgres redis docker compose up --build -d
# 2. Wait for healthy status # 2. Verify all three services are healthy
docker-compose ps docker compose ps
# Both postgres and redis must show "healthy" before proceeding # app, postgres, and redis must all show "healthy"
# 3. Run migrations # 3. Run migrations
npm run db:migrate docker compose exec app npm run db:migrate
# Must complete with 0 errors before starting the app
# 4. Start the application # 4. Verify application health
npm run dev # development curl http://localhost:3000/health
# or # Expected: {"status":"ok"}
npm start # production (requires prior npm run build)
# 5. (Optional) Start the portal for local dev
cd portal && npm run dev
``` ```
--- ---
@@ -115,9 +116,12 @@ docker-compose exec redis redis-cli
| Key pattern | Example | Purpose | TTL | | Key pattern | Example | Purpose | TTL |
|------------|---------|---------|-----| |------------|---------|---------|-----|
| `revoked:<jti>` | `revoked:f1e2d3c4-b5a6-...` | Revoked token JTI | Remaining token lifetime | | `revoked:<jti>` | `revoked:f1e2d3c4-...` | Revoked token JTI | Remaining token lifetime |
| `rate:<client_id>:<window>` | `rate:a1b2c3...:29086156` | Request count per minute window | 60 seconds | | `rate:<client_id>:<window>` | `rate:a1b2c3...:29086156` | Request count per window | `RATE_LIMIT_WINDOW_MS` |
| `monthly:<client_id>:<year>:<month>` | `monthly:a1b2c3...:2026:3` | Token issuance count for free tier | End of month | | `monthly:<client_id>:<year>:<month>` | `monthly:a1b2c3...:2026:3` | Monthly token issuance count | End of month |
| `rate:tier:calls:<tenantId>` | `rate:tier:calls:org-uuid` | Daily API call counter for tier enforcement | Until midnight UTC |
| `rate:tier:tokens:<tenantId>` | `rate:tier:tokens:org-uuid` | Daily token issuance counter for tier enforcement | Until midnight UTC |
| `compliance:report:<tenantId>` | `compliance:report:org-uuid` | Cached compliance report JSON | 5 minutes |
Inspect keys: Inspect keys:
@@ -130,6 +134,16 @@ redis-cli GET "rate:<client_id>:<window_key>"
# Check monthly token count for a specific client # Check monthly token count for a specific client
redis-cli GET "monthly:<client_id>:2026:3" redis-cli GET "monthly:<client_id>:2026:3"
# Check tier API call counter for a tenant
redis-cli GET "rate:tier:calls:<org_id>"
# Check tier token counter for a tenant
redis-cli GET "rate:tier:tokens:<org_id>"
# Check cached compliance report for a tenant
redis-cli GET "compliance:report:<org_id>"
redis-cli TTL "compliance:report:<org_id>"
``` ```
Where `<window_key>` is `floor(unix_ms / 60000)`. For the current window: Where `<window_key>` is `floor(unix_ms / 60000)`. For the current window:
@@ -247,3 +261,98 @@ docker-compose exec redis redis-cli GET "rate:<client_id>:$WINDOW"
``` ```
**Fix:** Wait until `X-RateLimit-Reset` (Unix timestamp in the response header) before retrying. The window resets every 60 seconds. **Fix:** Wait until `X-RateLimit-Reset` (Unix timestamp in the response header) before retrying. The window resets every 60 seconds.
---
## Monitoring
AgentIdP exposes a Prometheus metrics endpoint at `GET /metrics` (unauthenticated, plain text).
### Metrics Exposed
| Metric | Type | Labels | Description |
|--------|------|--------|-------------|
| `agentidp_tokens_issued_total` | Counter | `scope` | OAuth 2.0 tokens issued |
| `agentidp_agents_registered_total` | Counter | `deployment_env` | Agents registered |
| `agentidp_http_requests_total` | Counter | `method`, `route`, `status_code` | HTTP requests |
| `agentidp_http_request_duration_seconds` | Histogram | `method`, `route`, `status_code` | HTTP latency |
| `agentidp_db_query_duration_seconds` | Histogram | `operation` | PostgreSQL query duration |
| `agentidp_redis_command_duration_seconds` | Histogram | `command` | Redis command duration |
| `agentidp_webhook_dead_letters_total` | Counter | `event_type` | Webhook deliveries moved to dead-letter queue |
| `agentidp_credentials_expiring_soon_total` | Gauge | — | Credentials expiring within 7 days |
| `agentidp_audit_chain_integrity` | Gauge | — | `1` if audit chain is intact, `0` if broken |
| `agentidp_rate_limit_hits_total` | Counter | `client_id` | Rate limit rejections |
| `agentidp_db_pool_active_connections` | Gauge | — | Active PostgreSQL connections |
| `agentidp_db_pool_waiting_requests` | Gauge | — | Requests waiting for a pool connection |
| `agentidp_tenant_api_calls_total` | Counter | `org_id`, `tier` | API calls per tenant per tier |
| `agentidp_billing_limit_rejections_total` | Counter | `org_id`, `limit_type` | Tier limit enforcement rejections |
| `agentidp_did_documents_generated_total` | Counter | — | DID documents generated |
| `agentidp_oidc_tokens_issued_total` | Counter | — | OIDC ID tokens issued |
| `agentidp_federation_events_total` | Counter | `event_type` | Federation partner events |
| `agentidp_delegation_chains_created_total` | Counter | — | A2A delegation chains created |
| `agentidp_compliance_reports_generated_total` | Counter | — | Compliance reports generated |
### Starting the Monitoring Stack
```bash
# Start the full stack with monitoring
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
# Prometheus: http://localhost:9090
# Grafana: http://localhost:3001 (admin / agentidp)
```
The Grafana dashboard auto-provisions on first start. Navigate to **Dashboards → AgentIdP → SentryAgent.ai — AgentIdP**.
### Security Note
`GET /metrics` is unauthenticated. In production, ensure this endpoint is:
- Only accessible from your internal network (firewall rule or reverse proxy restriction)
- Not exposed on a public-facing port
---
### Tier limit rejected — 429 with `tier_limit_exceeded` code
Symptom: `429 TOO_MANY_REQUESTS` with body `{"code":"tier_limit_exceeded","message":"..."}`
Check the tenant's current tier counter:
```bash
# Check API call counter
docker compose exec redis redis-cli GET "rate:tier:calls:<org_id>"
# Check the tenant's tier
psql "$DATABASE_URL" -c "SELECT org_id, tier FROM tenant_tiers WHERE org_id = '<org_id>';"
```
If the org is on the `free` tier and has hit 1,000 calls/day, upgrade the tier or wait until
midnight UTC for the counter to reset.
---
### Analytics endpoints return 404
Cause: `ANALYTICS_ENABLED` is set to `false` in `.env`.
Fix: Set `ANALYTICS_ENABLED=true` and restart the application.
---
### Compliance report returns 404
Cause: `COMPLIANCE_ENABLED` is set to `false` in `.env`.
Fix: Set `COMPLIANCE_ENABLED=true` and restart the application.
---
### Portal CORS error
Symptom: Browser console shows `Access-Control-Allow-Origin` error on requests to
`http://localhost:3000`.
Fix: Ensure `CORS_ORIGIN` in `.env` includes `http://localhost:3001`:
```
CORS_ORIGIN=http://localhost:3001
```
Restart the application after changing this variable.

View File

@@ -87,6 +87,12 @@ Rotating the JWT keys invalidates all currently active tokens — every authenti
**Important:** There is no grace period or dual-key support in Phase 1. All tokens issued with the old private key are immediately rejected after rotation. If zero-downtime key rotation is required, it is a Phase 2 feature. **Important:** There is no grace period or dual-key support in Phase 1. All tokens issued with the old private key are immediately rejected after rotation. If zero-downtime key rotation is required, it is a Phase 2 feature.
> **OIDC keys** are separate from the main JWT keys. OIDC signing keys are stored in the
> `oidc_keys` PostgreSQL table (created by migration `014_create_oidc_keys_table.sql`), encrypted
> at rest using pgcrypto (enabled by migration `018_enable_pgcrypto.sql`). The `OIDCKeyService`
> manages rotation. OIDC keys do not need to be set as environment variables — they are
> provisioned automatically on first startup.
--- ---
## CORS Configuration ## CORS Configuration

View File

@@ -47,6 +47,10 @@ VAULT_TOKEN=dev-root-token
VAULT_MOUNT=secret VAULT_MOUNT=secret
``` ```
> **Note:** The `.env.example` file uses `VAULT_KV_MOUNT` as the variable name. The application
> reads both `VAULT_KV_MOUNT` and `VAULT_MOUNT` — prefer `VAULT_KV_MOUNT` in new configurations
> for consistency with the current `.env.example`.
The KV v2 secrets engine is automatically enabled at `secret/` in dev mode. No further configuration is needed. The KV v2 secrets engine is automatically enabled at `secret/` in dev mode. No further configuration is needed.
> **Warning**: Dev mode stores everything in memory. Data is lost when the container stops. Do not use dev mode in production. > **Warning**: Dev mode stores everything in memory. Data is lost when the container stops. Do not use dev mode in production.

View File

@@ -0,0 +1,132 @@
# SentryAgent.ai — Company and Product Overview
---
## 1. Company Mission
SentryAgent.ai is building the world's first free, open-source Agent Identity Provider
(AgentIdP) — democratising AI agent authentication, authorisation, and governance for
developers worldwide. The core problem it solves is one that did not have a standard
answer until now: when an AI agent needs to call an API, how does it prove who it is?
How does it obtain a short-lived token? How does a security team revoke its access the
moment it is compromised? How does compliance require a full, tamper-proof record of
everything that agent ever did? Traditional identity infrastructure — built for humans
and static service accounts — was not designed for the fluid lifecycle of AI agents.
AgentIdP is the answer. It is a REST API server that acts as an identity provider
designed specifically for non-human AI agents. Agents register with a stable UUID
identity, authenticate via the OAuth 2.0 Client Credentials grant (RFC 6749), receive
short-lived RS256 JWTs, and are governed by an OPA policy engine that enforces
capability-based access control at runtime. Every significant event is written to an
immutable audit log. The entire system is built on open standards: OAuth 2.0, RFC 7662
(introspection), RFC 7009 (revocation), OpenAPI 3.0, and the AGNTCY interoperability
standard from the Linux Foundation.
The market context is one of rapid proliferation. Enterprises are deploying dozens,
then hundreds, then thousands of autonomous AI agents — each one acting on behalf of
the organisation, calling APIs, reading sensitive data, and making decisions. Without
standardised identity infrastructure, there is no way to audit what happened, no way
to revoke a compromised agent cleanly, and no standard protocol for agents from
different vendors to authenticate to each other. SentryAgent.ai fills this gap,
providing every developer — from a student working alone to a global enterprise — the
same enterprise-grade identity layer for free.
---
## 2. What is AGNTCY?
AGNTCY (pronounced "agency") is an open interoperability standard for AI agents,
maintained under the Linux Foundation. Its central premise is that AI agents must be
treated as first-class identities — with stable identifiers, standard authentication
protocols, lifecycle management, and accountability mechanisms — in the same way that
human users and cloud service accounts are today.
AgentIdP is the first production IdP implementing AGNTCY-aligned agent identity across
all six AGNTCY domains:
| AGNTCY Domain | How AgentIdP Implements It |
|---------------|---------------------------|
| Non-Human Identity | Every agent receives an immutable UUID (`agentId`) assigned at registration. The identifier is DID-ready — structured to be portable into W3C DID documents in Phase 3. |
| Agent Registry | `POST /api/v1/agents` registers an agent. `GET /api/v1/agents` lists all agents. `GET /api/v1/agents/:id` retrieves a single agent by UUID. |
| Credential Management | Each agent holds one or more `(client_id, client_secret)` credential pairs. Secrets are bcrypt-hashed in PostgreSQL or stored in HashiCorp Vault KV v2. Credentials can be rotated and revoked independently. |
| Authentication | OAuth 2.0 Client Credentials grant per RFC 6749. Agents POST `grant_type=client_credentials` with their `client_id` and `client_secret` to receive a signed RS256 JWT. |
| Lifecycle | Agents transition through `active`, `suspended`, and `decommissioned` states. Decommissioning is a soft delete that cascades to revoke all active credentials. Suspended agents cannot obtain new tokens. |
| Audit | Every significant platform event is written to an immutable `audit_events` table in PostgreSQL. Events carry `agentId`, `action`, `outcome`, `ipAddress`, `userAgent`, `metadata`, and `timestamp`. The free tier retains 90 days of history. |
---
## 3. Product Features
| Feature | Endpoint(s) | Notes |
|---------|-------------|-------|
| Agent Registry | `POST /api/v1/agents`, `GET /api/v1/agents`, `GET /api/v1/agents/:id`, `PATCH /api/v1/agents/:id`, `DELETE /api/v1/agents/:id` | Full CRUD with lifecycle; free tier capped at 100 registered agents |
| OAuth 2.0 Token Issuance | `POST /api/v1/token` | Client Credentials flow (RFC 6749); issues RS256 JWTs; free tier capped at 10,000 tokens/month |
| Token Introspection | `POST /api/v1/token/introspect` | RFC 7662 compliant; always returns 200, check `active` field |
| Token Revocation | `POST /api/v1/token/revoke` | RFC 7009 compliant; idempotent; agents may only revoke their own tokens |
| Credential Management | `POST /api/v1/agents/:id/credentials`, `GET /api/v1/agents/:id/credentials`, `DELETE /api/v1/agents/:id/credentials/:credId` | `client_secret` returned once on creation; never retrievable again |
| Credential Rotation | `POST /api/v1/agents/:id/credentials/:credId/rotate` | Generates new secret; old secret immediately invalidated; atomic |
| Audit Log | `GET /api/v1/audit`, `GET /api/v1/audit/:id` | Immutable, filterable by `agentId`, `action`, `outcome`, date range; paginated |
| Web Dashboard | `/dashboard` | React 18 SPA — agents list, agent detail, credentials management, audit log, health views |
| OPA Policy Engine | (middleware on all protected routes) | Dynamic scope-based authorisation; Rego policy in `policies/authz.rego`; hot-reload via SIGHUP |
| Prometheus Metrics | `GET /metrics` | prom-client; all HTTP routes instrumented with request counter and duration histogram |
| HashiCorp Vault | (opt-in, via `VAULT_ADDR` + `VAULT_TOKEN`) | KV v2 secret storage; constant-time comparison; bcrypt fallback when Vault is not configured |
| Health Check | `GET /health` | Checks PostgreSQL and Redis connectivity; unauthenticated; used by load balancers |
| W3C Decentralised Identifiers | `GET /api/v1/agents/:id/did`, `GET /api/v1/.well-known/did.json` | DID Core 1.0 documents; `did:web` method; EC P-256 keys; AGNTCY extension fields |
| AGNTCY Agent Cards | `GET /api/v1/agents/:id/card` | Machine-readable agent identity summary; AGNTCY schema v1.0 |
| AGNTCY Compliance Reports | `GET /api/v1/compliance/report`, `GET /api/v1/compliance/agent-cards` | Compliance sections: agent-identity + audit-trail; cached 5 min; AGNTCY schema v1.0 |
| Federation (Cross-IdP) | `POST /api/v1/federation/partners`, `GET /api/v1/federation/partners`, `POST /api/v1/federation/verify` | Register partner IdPs; verify cross-IdP JWTs using cached partner JWKS |
| A2A Delegation | `POST /api/v1/oauth2/token/delegate`, `POST /api/v1/oauth2/token/verify-delegation` | Agent-to-agent delegation tokens; OIDC provider (oidc-provider v9) mounted at `/oidc` |
| Webhook Subscriptions | `POST /api/v1/webhooks`, `GET /api/v1/webhooks`, `GET /api/v1/webhooks/:id/deliveries` | Outbound event delivery with HMAC signing; Vault-backed secrets; delivery history |
| Tier Management | `GET /api/v1/tiers/status`, `POST /api/v1/tiers/upgrade` | Free / Pro / Enterprise tiers; daily call and token limits; Stripe Checkout upgrade flow |
| Billing | `POST /api/v1/billing/checkout`, `POST /api/v1/billing/webhook`, `GET /api/v1/billing/status` | Stripe subscription management; webhook event processing |
| Analytics | Internal (via `AnalyticsService`) | Daily aggregated event counts per org; token trend queries (up to 90 days); agent activity heatmap; usage summary |
| Developer Portal | `/portal` (Next.js 14, separate process) | Get-started wizard, SDK explorer, API reference, analytics dashboard, pricing page |
---
## 4. Phase Roadmap
| Phase | Status | Key Deliverables |
|-------|--------|-----------------|
| Phase 1 — MVP | COMPLETE | Agent registry, OAuth 2.0 Client Credentials (RS256 JWTs), credential management (bcrypt), immutable audit log, Node.js SDK, Dockerfile, Docker Compose, AGNTCY alignment documentation, >80% test coverage |
| Phase 2 — Production-Ready | COMPLETE | HashiCorp Vault opt-in integration, Python SDK (sync + async), Go SDK (context-aware), Java SDK (builder + CompletableFuture), OPA policy engine (Rego + Wasm + TypeScript fallback), React 18 + Vite 5 web dashboard, Prometheus metrics + Grafana dashboards, Terraform multi-region deployment (AWS ECS + RDS + ElastiCache; GCP Cloud Run + Cloud SQL + Memorystore) |
| Phase 3 — Enterprise | COMPLETE | AGNTCY federation (cross-IdP agent identity), W3C Decentralised Identifiers (DIDs), agent marketplace, OIDC provider (A2A delegation), Rust SDK, developer portal (Next.js 14) |
| Phase 4 — Compliance & Security | COMPLETE | AGNTCY compliance reports (agent-identity + audit-trail sections), audit hash chain verification, SOC 2 CC6.1 AES-256-CBC column encryption (`EncryptionService`), DID document caching, federation partner JWKS caching |
| Phase 5 — Scale & Ecosystem | COMPLETE | Multi-tier subscription model (free/pro/enterprise), Stripe billing integration (`BillingService`, `TierService`), tier enforcement middleware (daily call and token limits), webhook subscriptions + delivery history (`WebhookService`), analytics service (daily event aggregation + trend queries) |
| Phase 6 — Market Expansion | COMPLETE | AGNTCY conformance test suite (4 conformance scenarios), API tiers enforced end-to-end, analytics dashboard in developer portal, full Phase 6 engineering documentation update |
---
## 5. Virtual Engineering Team
SentryAgent.ai uses a Virtual Engineering Team (VET) model — all engineering work is
designed, implemented, tested, and reviewed by Claude Code instances fulfilling defined
engineering roles. The CEO (human) is the sole business decision-maker. The CTO (Claude)
owns technical architecture and manages the engineering team autonomously. The team
follows a strict spec-first workflow governed by the OpenSpec change management process:
no implementation begins until an OpenAPI specification is approved by the CTO.
| Role | Responsibility | Approval Gate |
|------|---------------|---------------|
| CEO | Business priorities, scope approval, architecture approval | All scope changes, new dependencies, git push to main |
| Virtual CTO | Architecture, technical standards, engineering team coordination, risk management | Reports to CEO; approves all implementation before commit; approves all QA sign-offs before merge |
| Virtual Architect | OpenAPI specs, ADRs, system design, database schemas | CTO review required before implementation begins |
| Virtual Principal Developer | TypeScript implementation per approved spec; JSDoc; zero `any` types | CTO review required before QA begins |
| Virtual QA Engineer | Jest + Supertest test suites; >80% coverage; all quality gates | All gates must pass before CTO signs off for merge |
---
## 6. Free Tier Limits
| Limit | Free Tier | Pro Tier | Enterprise Tier |
|-------|-----------|----------|-----------------|
| Max agents | 100 | 1,000 | Unlimited |
| Max API calls per day | Configured in `TIER_CONFIG` | Configured in `TIER_CONFIG` | Unlimited |
| Max tokens per day | Configured in `TIER_CONFIG` | Configured in `TIER_CONFIG` | Unlimited |
| Token TTL | 3,600 seconds (1 hour) | 3,600 seconds (1 hour) | 3,600 seconds (1 hour) |
| Audit log retention | 90 days | 1 year | Custom |
| API rate limit (per IP) | 100 req/min | 100 req/min | 100 req/min |
| Webhook subscriptions | 0 | 10 | Unlimited |
| Analytics retention | 90 days | 1 year | Custom |
Tier limits are configured in `src/config/tiers.ts` (`TIER_CONFIG`). Enforcement is handled by `TierService.enforceAgentLimit()` (agent cap) and `src/middleware/tier.ts` (daily call/token caps). Tier upgrades are initiated via `POST /api/v1/tiers/upgrade` and confirmed via the Stripe webhook.

View File

@@ -0,0 +1,235 @@
# System Architecture
---
## 1. Component Diagram
```mermaid
graph TD
Client["Client (AI Agent / Browser / CI)"]
Client -->|HTTPS| ExpressApp["Express App (AgentIdP)"]
subgraph ExpressApp["Express App — src/app.ts"]
Router["Router (src/routes/)"]
AuthMW["authMiddleware (src/middleware/auth.ts)"]
TierMW["tierMiddleware (src/middleware/tier.ts)"]
OpaMW["opaMiddleware (src/middleware/opa.ts)"]
Controller["Controller (src/controllers/)"]
Service["Service (src/services/)"]
Repository["Repository (src/repositories/)"]
Router --> AuthMW --> TierMW --> OpaMW --> Controller --> Service --> Repository
end
Repository -->|parameterized SQL| PG["PostgreSQL 14\n(agents, credentials, audit_events,\nanalytics_events, organizations,\nfederation_partners, webhook_subscriptions,\nagent_did_keys, delegation_chains)"]
Service -->|Redis commands| Redis["Redis 7\n(token revocation list, daily tier counters,\nJWKS cache, compliance report cache,\nDID document cache)"]
Service -->|KV v2 read/write| Vault["HashiCorp Vault\n(opt-in — credentials, DID private keys,\nwebhook secrets — when VAULT_ADDR is set)"]
ExpressApp -->|evaluate input| OPA["OPA Policy Engine\n(policies/authz.rego + data/scopes.json)"]
ExpressApp -->|expose| Metrics["/metrics (prom-client)"]
ExpressApp -->|checkout session / webhooks| Stripe["Stripe\n(billing — when STRIPE_SECRET_KEY is set)"]
Dashboard["Dashboard SPA (React 18 + Vite 5)\ndashboard/dist/ served from /dashboard"]
Portal["Developer Portal (Next.js 14)\nportal/ — served separately on port 3002"]
Client -->|browser| Dashboard
Client -->|browser| Portal
Dashboard -->|REST API calls| ExpressApp
Portal -->|REST API calls| ExpressApp
Grafana["Grafana (port 3001)"] -->|scrapes| Metrics
OIDCProvider["OIDC Provider (oidc-provider v9)\nmounted at /oidc — A2A delegation tokens"]
ExpressApp --- OIDCProvider
```
---
## 2. HTTP Request Lifecycle
Every authenticated API request travels through the following sequence. Understanding
this sequence end-to-end is essential for debugging and for writing new endpoints
correctly.
1. HTTP request arrives at the Node.js HTTP listener — configured in `src/server.ts`, which calls `app.listen(PORT)` after `createApp()` resolves.
2. App-level middleware runs in registration order: `helmet()` sets security headers, `cors()` applies CORS policy from `CORS_ORIGIN`, `morgan('combined')` logs the request line (skipped in `NODE_ENV=test`), `express.json()` and `express.urlencoded()` parse the body, `metricsMiddleware` (`src/middleware/metrics.ts`) starts the request timer and records `agentidp_http_requests_total` and `agentidp_http_request_duration_seconds` on response finish.
3. The Express router matches the path to a route definition in `src/routes/*.ts` and hands off to the appropriate middleware chain.
4. `authMiddleware` (`src/middleware/auth.ts`) validates the Bearer JWT: extracts the token from the `Authorization` header, calls `verifyToken()` for RS256 signature and expiry, then calls `redis.get('revoked:{jti}')` to check the revocation list. On success, attaches the decoded `ITokenPayload` to `req.user`.
5. `tierMiddleware` (`src/middleware/tier.ts`) enforces per-tier daily API call limits. It reads the organisation's current tier from `TierService.fetchTier(orgId)`, checks the daily call counter from Redis key `rate:tier:calls:<orgId>` against `TIER_CONFIG[tier].maxCallsPerDay`, increments the counter on each passing request (fire-and-forget `INCR` with TTL set to next UTC midnight), and throws `TierLimitError` (429) when the limit is reached. This middleware is applied only to API routes, not to `/health`, `/metrics`, or `/dashboard`.
6. `opaMiddleware` (`src/middleware/opa.ts`) evaluates the OPA policy: builds an `OpaInput` object from `req.method`, `req.baseUrl + req.path`, and `req.user.scope.split(' ')`, then calls `evaluate(input)`. Uses the Wasm bundle (`policies/authz.wasm`) when present, or the TypeScript fallback reading `policies/data/scopes.json`. Calls `next(new AuthorizationError())` if the policy denies.
7. The controller (`src/controllers/*.ts`) receives the validated request, extracts and validates path params and body using Joi schemas, then delegates to the service layer.
8. The service (`src/services/*.ts`) executes all business logic — enforces tier limits, resolves domain rules, and calls repositories. Phase 36 introduces specialised services: `AnalyticsService` (fire-and-forget event recording), `TierService` (enforces per-tier agent and call limits), `ComplianceService` (AGNTCY compliance reports, cached 5 min in Redis), `FederationService` (cross-IdP JWT verification with cached JWKS), `DIDService` (W3C DID document generation and caching), `WebhookService` (subscription management with Vault-backed HMAC secrets), and `BillingService` (Stripe Checkout and webhook processing). The service has no knowledge of HTTP.
9. The repository (`src/repositories/*.ts`) executes parameterized SQL against PostgreSQL via `node-postgres`, or issues Redis commands via the `redis` client. No business logic lives here. Phase 36 added the following tables: `analytics_events` (daily metric counters), `organizations` (org tier and billing), `federation_partners` (cross-IdP trust registry), `webhook_subscriptions` and `webhook_deliveries` (outbound event delivery), `agent_did_keys` (public EC keys for DID documents), `delegation_chains` (A2A delegation records), `tenant_subscriptions` (Stripe subscription status).
10. The controller serialises the service result and calls `res.status(xxx).json(payload)`.
11. `AuditService.logEvent()` is called — for high-throughput paths (token issuance, introspection, revocation) this is fire-and-forget (`void` — not awaited); for CRUD operations it is awaited. The audit event is written as an immutable row to the `audit_events` table in PostgreSQL.
---
## 3. OAuth 2.0 Client Credentials Flow
```mermaid
sequenceDiagram
actor Agent
participant AgentIdP
participant PostgreSQL
participant Redis
participant Vault as Vault (optional)
Agent->>AgentIdP: POST /api/v1/token<br/>grant_type=client_credentials<br/>client_id=&lt;agentId&gt;<br/>client_secret=sk_live_...&<br/>scope=agents:read agents:write
AgentIdP->>PostgreSQL: SELECT * FROM agents WHERE agent_id = $1
PostgreSQL-->>AgentIdP: agent row (status, etc.)
AgentIdP->>PostgreSQL: SELECT * FROM credentials WHERE agent_id = $1 AND status = 'active'
PostgreSQL-->>AgentIdP: active credential rows
alt Vault path (vaultPath IS NOT NULL and VAULT_ADDR is set)
AgentIdP->>Vault: readSecret(agentId, credentialId)
Vault-->>AgentIdP: plain-text secret
AgentIdP->>AgentIdP: crypto.timingSafeEqual(stored, candidate)
else bcrypt path (fallback)
AgentIdP->>AgentIdP: bcrypt.compare(clientSecret, secretHash)
end
AgentIdP->>Redis: GET monthly:tokens:{agentId}:{yyyy-mm}
Redis-->>AgentIdP: current monthly count
AgentIdP->>AgentIdP: signToken(payload, privateKey) — RS256 JWT
AgentIdP->>Redis: INCR monthly:tokens:{agentId}:{yyyy-mm} (fire-and-forget)
AgentIdP-->>Agent: 200 OK<br/>{ access_token, token_type: "Bearer", expires_in: 3600, scope }
Note over Agent,AgentIdP: Subsequent protected API call
Agent->>AgentIdP: GET /api/v1/agents<br/>Authorization: Bearer &lt;access_token&gt;
AgentIdP->>AgentIdP: verifyToken(token, publicKey) — RS256 verify + expiry
AgentIdP->>Redis: GET revoked:{jti}
Redis-->>AgentIdP: null (not revoked)
AgentIdP->>AgentIdP: OPA evaluate({method, path, scopes})
AgentIdP-->>Agent: 200 OK — agents list
```
---
## 3b. Analytics Event Capture Flow
Every successful token issuance writes a fire-and-forget analytics event:
```mermaid
sequenceDiagram
participant Controller as TokenController
participant OAuth2Svc as OAuth2Service
participant AnalyticsSvc as AnalyticsService
participant PG as PostgreSQL
Controller->>OAuth2Svc: issueToken(clientId, clientSecret, scope, ...)
OAuth2Svc->>OAuth2Svc: signToken() — RS256 JWT
OAuth2Svc-->>Controller: ITokenResponse
Note over OAuth2Svc,AnalyticsSvc: fire-and-forget (void)
OAuth2Svc-)AnalyticsSvc: recordEvent(tenantId, 'token_issued')
AnalyticsSvc-)PG: INSERT INTO analytics_events ... ON CONFLICT DO UPDATE count + 1
```
`recordEvent` uses PostgreSQL `UPSERT` — one row per `(organization_id, date, metric_type)`. If the INSERT conflicts (same date, same org, same metric), the `count` column is incremented atomically. This keeps the table compact (one row per day per metric type per org) and fast to query.
---
## 3c. Tier Enforcement Middleware Chain
```mermaid
sequenceDiagram
actor Agent
participant TierMW as tierMiddleware
participant TierSvc as TierService
participant Redis
participant PG as PostgreSQL
Agent->>TierMW: API request (with valid Bearer token)
TierMW->>TierSvc: fetchTier(orgId)
TierSvc->>PG: SELECT tier FROM organizations WHERE organization_id = $1
PG-->>TierSvc: 'pro'
TierSvc-->>TierMW: 'pro'
TierMW->>Redis: GET rate:tier:calls:<orgId>
Redis-->>TierMW: "4999" (current daily count)
Note over TierMW: TIER_CONFIG['pro'].maxCallsPerDay = 50000 — limit not reached
TierMW-)Redis: INCR rate:tier:calls:<orgId> (fire-and-forget, TTL = next UTC midnight)
TierMW->>Agent: next() — request proceeds to opaMiddleware
```
When the counter equals or exceeds the tier limit, `tierMiddleware` throws `TierLimitError` (429) before `opaMiddleware` runs. The daily counter resets at UTC midnight via Redis TTL.
---
## 3d. A2A Delegation End-to-End Flow
```mermaid
sequenceDiagram
actor Delegator as Delegator Agent
actor Delegatee as Delegatee Agent
participant AgentIdP
participant DelegationSvc as DelegationService
participant OIDCProvider as OIDC Provider
participant PG as PostgreSQL
Delegator->>AgentIdP: POST /api/v1/oauth2/token/delegate<br/>{ delegatee_id, scope }
AgentIdP->>DelegationSvc: createDelegation(delegatorId, delegateeId, scope)
DelegationSvc->>PG: INSERT INTO delegation_chains ...
PG-->>DelegationSvc: chain_id
DelegationSvc->>OIDCProvider: issue delegation JWT (delegator claims + delegatee sub)
OIDCProvider-->>DelegationSvc: signed delegation token
DelegationSvc-->>AgentIdP: IDelegationChain (with token)
AgentIdP-->>Delegator: 201 { token, chain_id }
Note over Delegatee,AgentIdP: Delegatee uses the delegation token
Delegatee->>AgentIdP: POST /api/v1/oauth2/token/verify-delegation<br/>{ token }
AgentIdP->>DelegationSvc: verifyDelegation(token, delegateeId)
DelegationSvc->>PG: SELECT * FROM delegation_chains WHERE chain_id = $1 AND status = 'active'
PG-->>DelegationSvc: chain row (not expired, not revoked)
DelegationSvc->>OIDCProvider: verify token signature
OIDCProvider-->>DelegationSvc: verified claims
DelegationSvc-->>AgentIdP: IDelegationVerifyResult { valid: true, ... }
AgentIdP-->>Delegatee: 200 { valid: true, delegatorId, scope }
```
---
## 4. Multi-Region Deployment Topology
```mermaid
graph LR
TFRoot["Terraform Root Module\nterraform/"]
TFRoot --> AWSMod["AWS Module\nterraform/environments/aws/"]
TFRoot --> GCPMod["GCP Module\nterraform/environments/gcp/"]
subgraph AWS["AWS (us-east-1 default)"]
AWSVPC["VPC"] --> ECSCluster["ECS Cluster (Fargate)"]
ECSCluster --> ECSTask["ECS Task — AgentIdP container"]
ECSTask --> RDS["RDS PostgreSQL 14 (Multi-AZ)"]
ECSTask --> Elasticache["ElastiCache Redis 7"]
ALB["Application Load Balancer"] --> ECSCluster
end
subgraph GCP["GCP (us-central1 default)"]
GCPVPC["VPC"] --> CloudRun["Cloud Run service — AgentIdP"]
CloudRun --> CloudSQL["Cloud SQL PostgreSQL 14"]
CloudRun --> Memorystore["Memorystore Redis 7"]
GCPLB["Cloud Load Balancer"] --> CloudRun
end
AWSMod --> AWS
GCPMod --> GCP
ECR["ECR / Artifact Registry\n(container image)"] --> ECSTask
ECR --> CloudRun
```
Each region is an independent deployment with its own PostgreSQL and Redis instances.
The Terraform root module sets `aws_region` (default `us-east-1`) and `gcp_region`
(default `us-central1`) as input variables. Infrastructure modules live under
`terraform/modules/` (agentidp, lb, rds, redis) with environment-specific configuration
under `terraform/environments/aws/` and `terraform/environments/gcp/`. Cross-region
data replication and federation are Phase 3 goals.

View File

@@ -0,0 +1,334 @@
# Technology Stack and Architecture Decision Records
Every technology choice in AgentIdP was made deliberately. This document records the
decision, rationale, and alternatives considered for each major technology. New engineers
should read this before making any technology additions or changes — the pattern here is
the template for future ADRs.
---
### ADR-1: Node.js 18 LTS
**Status**: Adopted
**Component**: AgentIdP server runtime and Node.js SDK runtime
**Decision**: Use Node.js 18 LTS as the server runtime.
**Rationale**: Node.js 18 LTS provides native `fetch`, native ESM support, and a
stable V8 engine with long-term security updates. The ecosystem for Express, PostgreSQL
(`pg`), Redis (`redis`), JWT (`jsonwebtoken`), and bcrypt (`bcryptjs`) is mature and
well-maintained on this version. The non-blocking I/O model is well-suited for an IdP
that handles many concurrent short-lived authentication requests. The `engines.node`
field in `package.json` enforces `>=18.0.0`.
**Alternatives considered**:
- Deno — rejected because the npm ecosystem compatibility layer introduced friction with key dependencies (`pg`, `bcryptjs`), and the production deployment story on ECS and Cloud Run was less mature at the time of the decision.
- Bun — rejected because it lacked LTS stability guarantees at the time of the decision, which is not acceptable for a security-critical authentication service.
**Consequences**: All Dockerfiles and Terraform ECS/Cloud Run task definitions must
target Node.js 18 or a compatible LTS release. Upgrading the Node.js version requires
CTO approval and a QA sign-off on the full test suite.
---
### ADR-2: TypeScript 5.3 Strict Mode
**Status**: Adopted
**Component**: All source files — server, all SDKs, dashboard
**Decision**: TypeScript 5.3 with `strict: true` and every additional strictness flag enabled in `tsconfig.json`.
**Rationale**: AgentIdP handles authentication tokens and cryptographic secrets. Type
errors in this domain can cause security vulnerabilities — a value that should be
`string | null` treated as `string` can produce silent authentication bypasses. Strict
TypeScript with `noImplicitAny`, `strictNullChecks`, `noUnusedLocals`, `noUnusedParameters`,
and `noImplicitReturns` makes these classes of bug a compile-time error rather than a
runtime failure in production.
**Alternatives considered**:
- Plain JavaScript — rejected because a security-critical IdP with no type safety is not a system this team is willing to ship. Every public method, every error boundary, and every data transformation must be typed.
**Consequences**: All new code must compile cleanly under `tsc --strict`. Zero `any`
types — ever. No exceptions granted without CTO approval. The `tsconfig.json` enables
`noImplicitAny`, `strictNullChecks`, `strictFunctionTypes`, `strictBindCallApply`,
`strictPropertyInitialization`, `noImplicitThis`, `alwaysStrict`, `noUnusedLocals`,
`noUnusedParameters`, `noImplicitReturns`, and `noFallthroughCasesInSwitch`.
---
### ADR-3: Express 4.18
**Status**: Adopted
**Component**: HTTP server framework
**Decision**: Use Express 4.18 as the HTTP framework.
**Rationale**: Express is the most widely understood Node.js HTTP framework. Its
middleware model (`(req, res, next)`) maps directly to the IdP's layered architecture:
`helmet``cors``metricsMiddleware``authMiddleware``opaMiddleware`
controller → service → repository → `errorHandler`. The ecosystem for Express
middleware (`helmet`, `cors`, `morgan`) is mature. For a spec-first project, Express's
lack of convention about code structure is a feature — the architecture is explicit and
fully visible in `src/app.ts`.
**Alternatives considered**:
- Fastify — rejected because the team's familiarity was lower and the performance gains would be negligible for a token service whose latency is dominated by PostgreSQL queries and bcrypt comparisons.
- NestJS — rejected because its decorator-heavy convention-over-configuration style adds complexity not appropriate for the current team size and project scope.
- Koa — rejected because its ecosystem is smaller and fewer engineers are familiar with it.
**Consequences**: All HTTP concerns (routing, middleware, error handling) use the
Express 4 API. The `errorHandler` middleware must remain the last `app.use()` call in
`src/app.ts`.
---
### ADR-4: PostgreSQL 14
**Status**: Adopted
**Component**: Primary data store for agents, credentials, and audit events
**Decision**: Use PostgreSQL 14 as the primary relational database.
**Rationale**: The audit log requires ACID guarantees — partial writes or uncommitted
reads are not acceptable for a compliance-grade append-only event store. PostgreSQL's
`JSONB` column type is used for the `metadata` field in `audit_events`, allowing
structured context data without schema changes for each new event type. PostgreSQL's
row-level security is available for multi-tenancy if that becomes a Phase 3 requirement.
**Alternatives considered**:
- MySQL — rejected because its JSON support is weaker than PostgreSQL's `JSONB` with GIN indexing, and its default transaction isolation level has historically produced surprises.
- MongoDB — rejected because the audit log must be append-only and ACID-safe. MongoDB's document model requires explicit multi-document transactions for ACID behaviour, and the schema flexibility is not needed here.
**Consequences**: All schema changes go through numbered SQL migration files in
`src/db/migrations/`. Migration files are append-only — never modify an existing
migration. New tables require a new numbered file (e.g. `005_create_agent_groups.sql`).
---
### ADR-5: Redis 7
**Status**: Adopted
**Component**: Token revocation list, monthly usage counters, rate-limit sliding window
**Decision**: Use Redis 7 as the in-memory data store.
**Rationale**: Token revocation requires O(1) key lookup with TTL-based automatic
expiry. `SET revoked:{jti} 1 EX {seconds_until_expiry}` stores a revocation entry
that expires precisely when the token itself would have expired — zero manual cleanup
required. The monthly token counter uses Redis `INCR`, which is atomic and O(1). The
rate-limiter uses a Redis sorted set for the sliding-window algorithm.
**Alternatives considered**:
- Memcached — rejected because Memcached does not support per-key TTL on sorted-set structures, which is required for the sliding-window rate-limiter.
- PostgreSQL for revocation — rejected because the token verification path is the hot path in every authenticated request. A PostgreSQL round-trip adds 515 ms compared to a Redis `GET` at sub-millisecond latency.
**Consequences**: Redis is a required infrastructure dependency. A Redis instance must
be running and reachable via `REDIS_URL` before the server starts. `docker-compose.yml`
provides a Redis 7 Alpine container for local development on port 6379.
---
### ADR-6: HashiCorp Vault
**Status**: Adopted (opt-in)
**Component**: Credential secret storage — alternative to bcrypt in PostgreSQL
**Decision**: Integrate HashiCorp Vault KV v2 as an opt-in secret storage backend for agent credentials.
**Rationale**: The Phase 1 bcrypt approach stores hashes in PostgreSQL. While bcrypt
hashes cannot be reversed, some enterprises require that secrets never touch a relational
database — even in hashed form. Vault provides a dedicated secrets management plane with
HSM backing and an independent audit trail at the secrets level. The `verifySecret`
method in `VaultClient` uses `crypto.timingSafeEqual` to prevent timing-based
side-channel attacks when comparing stored and candidate secrets.
**Alternatives considered**:
- AWS Secrets Manager — rejected because it introduces cloud-vendor lock-in. AgentIdP must run identically on AWS, GCP, and on-premises; a Vault-based approach works in all environments.
- Plain bcrypt only — retained as the fallback path. When `VAULT_ADDR` is not set, `createVaultClientFromEnv()` returns `null` and the server operates identically to Phase 1.
**Consequences**: Vault is controlled by `VAULT_ADDR` (required), `VAULT_TOKEN`
(required), and `VAULT_MOUNT` (optional, defaults to `secret`). When these are not set,
bcrypt is used unchanged. Credential rows carry a nullable `vault_path` column: `null`
means bcrypt; a non-null path means Vault verification is used.
---
### ADR-7: OPA (Open Policy Agent)
**Status**: Adopted
**Component**: Request authorisation — scope enforcement on all protected endpoints
**Decision**: Use Open Policy Agent with a Rego policy compiled to a Wasm bundle for runtime authorisation.
**Rationale**: Hard-coded scope checks in middleware would require a code deployment
for every policy change. OPA decouples the policy (`policies/authz.rego`) from the
server code. The policy can be updated, re-compiled to Wasm, and hot-reloaded via
`SIGHUP` without restarting the server. The `@open-policy-agent/opa-wasm` package
evaluates the compiled Wasm bundle in-process with microsecond latency. When no Wasm
bundle is present (development, CI), the middleware falls back to a TypeScript
implementation that reads `policies/data/scopes.json`.
**Alternatives considered**:
- Custom middleware with hard-coded scope checks — rejected because policy changes require code changes and a full deployment cycle. As the endpoint surface grows this becomes unmanageable.
- Casbin — rejected because its RBAC/ABAC model is less expressive than Rego for the compound `method + path + scope-intersection` pattern AgentIdP requires.
**Consequences**: All authorisation rules live in `policies/authz.rego` and
`policies/data/scopes.json`. Adding a new endpoint requires adding its scope
requirement to `scopes.json`. A policy change is deployed by updating `scopes.json`
(or `authz.wasm`) and sending `SIGHUP` to the running process — no redeployment needed.
---
### ADR-8: React 18 + Vite 5
**Status**: Adopted
**Component**: Web dashboard SPA (`dashboard/`)
**Decision**: Use React 18 with Vite 5 as the web dashboard framework and build tool.
**Rationale**: React 18's concurrent rendering model handles the dashboard's async data
fetching patterns cleanly. The `@sentryagent/idp-sdk` Node.js package is reused
directly in the dashboard via `TokenManager` for authentication, avoiding duplicated
API client code. Vite 5 provides sub-second HMR in development and a fast production
build with tree-shaking. The dashboard is built to `dashboard/dist/` and served as
static files from Express at `/dashboard`, keeping the deployment footprint to a
single container.
**Alternatives considered**:
- Next.js — rejected because server-side rendering is not needed for an internal operator dashboard, and the added complexity of a Next.js server is not justified.
- Vue — rejected because the broader SentryAgent.ai ecosystem is React-first; consistency reduces context-switching overhead.
**Consequences**: The dashboard must be built (`cd dashboard && npm run build`) before
Express can serve it. In local development, run `cd dashboard && npm run dev` to use
Vite's dev server with HMR; the Vite proxy forwards `/api/` calls to Express at
`localhost:3000`.
---
### ADR-9: Prometheus + Grafana
**Status**: Adopted
**Component**: Operational metrics collection and visualisation
**Decision**: Use Prometheus for metrics collection and Grafana for dashboards.
**Rationale**: Prometheus is the industry standard for metrics in container
environments. The `prom-client` npm package integrates natively with Express and
provides `Counter` and `Histogram` metric types that cover all observability needs for
AgentIdP. Grafana's YAML provisioning in `monitoring/grafana/provisioning/` makes
dashboards reproducible and version-controlled. The monitoring stack runs as a Docker
Compose overlay (`docker-compose.monitoring.yml`) without interfering with the base dev
environment.
**Alternatives considered**:
- Datadog — rejected because SaaS cost and vendor lock-in are not acceptable for a free, open-source product. Operators who self-host AgentIdP should not be required to pay for monitoring.
- StatsD — rejected because StatsD's flat metric model lacks label/dimension support, which is essential for distinguishing metrics by `method`, `route`, and `status_code`.
**Consequences**: All metric definitions live exclusively in `src/metrics/registry.ts`.
No other file may instantiate a `Counter` or `Histogram` — all other files import
specific metrics from that registry. Grafana is available at port 3001 when the
monitoring overlay is running.
---
### ADR-10: Terraform
**Status**: Adopted
**Component**: Infrastructure as code — multi-region AWS + GCP deployment
**Decision**: Use Terraform with HCL for all infrastructure provisioning across AWS and GCP.
**Rationale**: Terraform's HCL syntax is readable and its provider ecosystem covers
both AWS and GCP with the same toolchain. Reusable modules in `terraform/modules/`
(agentidp, lb, rds, redis) are composed in environment-specific configurations under
`terraform/environments/aws/` and `terraform/environments/gcp/`. All infrastructure
changes go through `terraform plan` review before `terraform apply`, providing a
diff-based approval workflow.
**Alternatives considered**:
- Pulumi — rejected because the Pulumi provider ecosystem for AWS and GCP was less mature than Terraform's at the time of the Phase 2 decision, and HCL is more readable for non-engineers reviewing infrastructure changes.
- AWS CDK — rejected because it is AWS-only. AgentIdP must deploy identically to both AWS and GCP.
**Consequences**: All infrastructure changes must go through Terraform. No manual edits
via the AWS console or GCP console are permitted — they will be overwritten on the next
`terraform apply`. Terraform state is stored in a remote backend and must not be edited
manually.
---
### ADR-11: Stripe
**Status**: Adopted
**Component**: Billing — subscription management and payment processing
**Decision**: Use Stripe as the payment processing and subscription management platform. The `stripe` npm package (v21+) handles Checkout Session creation, webhook event verification, and subscription lifecycle events.
**Rationale**: Stripe's hosted Checkout flow eliminates the need to handle PCI-DSS scope for card data. The `stripe.webhooks.constructEvent()` method uses HMAC-SHA256 to verify incoming webhook payloads, preventing replay attacks. The `checkout.session.completed` event carries `metadata: { orgId, targetTier }`, allowing `BillingService` to delegate tier upgrades to `TierService.applyUpgrade()` without coupling billing logic to tier logic.
**Alternatives considered**:
- Paddle — rejected because its global merchant-of-record model introduced complexities with the open-source free tier.
- Braintree — rejected because Stripe's webhook reliability and developer experience are superior.
**Consequences**: Stripe requires `STRIPE_SECRET_KEY` (for API calls) and `STRIPE_WEBHOOK_SECRET` (`whsec_...`, for webhook verification). Per-tier Stripe price IDs are configured via `STRIPE_PRICE_ID_PRO` and `STRIPE_PRICE_ID_ENTERPRISE`. All billing webhook handlers must pass the raw `Buffer` body (not parsed JSON) to `stripe.webhooks.constructEvent()` — use `express.raw()` middleware on the webhook route.
---
### ADR-12: oidc-provider (A2A Delegation)
**Status**: Adopted
**Component**: A2A delegation — OIDC provider for agent-to-agent trust tokens
**Decision**: Use the `oidc-provider` npm package (v9.7.x) as the OIDC provider for issuing A2A delegation tokens. The provider is mounted as a sub-application at `/oidc` within the Express app.
**Rationale**: `oidc-provider` is a certified OpenID Connect implementation that handles the full OIDC protocol, including JWKS serving, token endpoint, and discovery document. Rather than implementing a custom delegation token format, using a standards-compliant OIDC provider means delegation tokens can be verified by any OIDC-aware party using the published JWKS at `/oidc/jwks`.
**Alternatives considered**:
- Custom JWT signing — rejected because hand-rolled token formats cannot benefit from OIDC tooling and interoperability.
**Consequences**: `A2A_ENABLED` env var gates the OIDC provider — when set to `'false'`, delegation endpoints return 404. The `OIDC_ISSUER` env var must be set to the full base URL of the OIDC provider (e.g. `https://api.sentryagent.ai`).
---
### ADR-13: Next.js 14 (Developer Portal)
**Status**: Adopted
**Component**: Developer Portal (`portal/`) — public-facing documentation and onboarding
**Decision**: Use Next.js 14 (App Router) with Tailwind CSS for the developer portal. The portal is a separate process served on its own port (independent of the Express API server).
**Rationale**: The developer portal has different performance and SEO requirements than the internal operator dashboard (`dashboard/`). Next.js 14's App Router supports React Server Components, which allows the marketing and documentation pages to be statically generated while the analytics dashboard and API Explorer are client-rendered. Tailwind CSS enables rapid UI development consistent with the design system.
**Alternatives considered**:
- Extending the Vite dashboard — rejected because the developer portal requires server-side rendering for SEO on marketing pages, which Vite does not provide.
- Docusaurus — rejected because the portal includes interactive components (Swagger Explorer, analytics charts) that are not well-suited to a documentation-only tool.
**Consequences**: The portal (`portal/`) has its own `package.json`, `tsconfig.json`, `tailwind.config.ts`, and `next.config.js`. It is built and run independently: `cd portal && npm install && npm run dev`. The portal calls the AgentIdP REST API using the same `@sentryagent/idp-sdk` as the dashboard.
---
### ADR-14: bull (Job Queue) + kafkajs (Event Streaming)
**Status**: Adopted (opt-in)
**Component**: Async job processing and event streaming
**Decision**: Use `bull` (Redis-backed job queue) for async webhook delivery retries and `kafkajs` for event streaming to external consumers. Both are opt-in — the system operates correctly without Kafka configured.
**Rationale**: Webhook delivery requires retry logic with exponential backoff and dead-letter handling. `bull` provides this out of the box using the existing Redis dependency. `kafkajs` enables high-throughput event streaming for analytics and audit events to external data pipelines without blocking the primary request path.
**Alternatives considered**:
- BullMQ — considered as a more modern alternative to `bull` but rejected to avoid adding a new package family during Phase 6. Migration is a future backlog item.
**Consequences**: Kafka is entirely optional. When `KAFKA_BROKERS` is not set, `kafkajs` is not initialised and no events are published. The `bull` queue for webhook delivery requires only the existing Redis instance.
---
### ADR-15: did-resolver + web-did-resolver (W3C DIDs)
**Status**: Adopted
**Component**: W3C DID Core 1.0 document resolution
**Decision**: Use `did-resolver` (v4.1.x) as the DID resolution framework and `web-did-resolver` (v2.0.x) for the `did:web` method implementation.
**Rationale**: `did-resolver` provides a pluggable resolver interface used by both the server (for internal resolution) and by third parties who want to verify AgentIdP-issued DIDs. The `did:web` method maps DID identifiers to HTTPS URLs hosting the DID document JSON, requiring no blockchain. `DIDService` generates documents that conform to the W3C DID Core 1.0 specification and include AGNTCY-specific extension fields.
**Consequences**: `DID_WEB_DOMAIN` env var is required for DID generation. DID documents are cached in Redis (`did:doc:<agentId>`, TTL from `DID_DOCUMENT_CACHE_TTL_SECONDS`, default 300s). Private keys are stored in HashiCorp Vault KV v2 when Vault is configured; in dev mode, a `dev:no-vault` marker is stored and keys are ephemeral.

View File

@@ -0,0 +1,163 @@
# Codebase Structure
---
## 1. Annotated Directory Tree
```
sentryagent-idp/
├── src/ # Express application source — controllers, services, middleware, repositories, routes
│ ├── app.ts # Express app factory — creates and configures the app; does NOT call listen
│ ├── server.ts # Entry point — calls listen, handles SIGTERM/SIGINT/SIGHUP
│ ├── types/ # Canonical TypeScript interfaces and type definitions
│ ├── controllers/ # HTTP layer — extract/validate inputs, call services, build responses
│ ├── services/ # Business logic — pure domain operations, no HTTP knowledge
│ ├── repositories/ # Database and Redis access — parameterized SQL, no logic
│ ├── middleware/ # Cross-cutting request concerns — auth, OPA, rate-limit, metrics, error handling
│ ├── routes/ # Express router definitions — wiring only, no logic
│ ├── utils/ # Shared pure utilities — errors, validators, crypto, JWT helpers
│ ├── vault/ # HashiCorp Vault KV v2 client
│ ├── metrics/ # Prometheus metrics registry — all Counter and Histogram definitions
│ ├── db/ # PostgreSQL pool factory and SQL migration files
│ └── cache/ # Redis client factory
├── tests/ # Jest test suite — mirrors src/ structure (unit/ and integration/)
├── dashboard/ # React 18 + Vite 5 web dashboard SPA
│ ├── src/ # Dashboard source — pages, components, auth, API client
│ └── dist/ # Built dashboard — served by Express at /dashboard (git-ignored)
├── sdk/ # Node.js SDK (@sentryagent/idp-sdk) — TypeScript, auto token refresh
├── sdk-python/ # Python SDK (sentryagent-idp) — sync + async clients
├── sdk-go/ # Go SDK (github.com/sentryagent/idp-sdk-go) — context-aware, goroutine-safe
├── sdk-java/ # Java SDK (ai.sentryagent:idp-sdk) — builder pattern, CompletableFuture
├── sdk-rust/ # Rust SDK (sentryagent-idp crate) — async, tokio, reqwest, typed errors
├── policies/ # OPA policy files
│ ├── authz.rego # Rego policy — normalise_path + scope-intersection allow rule
│ └── data/scopes.json # Endpoint permission map — used by Rego and TypeScript fallback
├── portal/ # Developer Portal — Next.js 14 App Router, Tailwind CSS
│ ├── app/ # Next.js App Router pages (get-started, pricing, sdks, analytics, settings, login)
│ ├── components/ # Shared UI components (Nav.tsx, SwaggerExplorer.tsx, GetStartedWizard.tsx)
│ ├── hooks/ # React hooks (useAuth.ts)
│ └── types/ # TypeScript type definitions for portal-only types
├── terraform/ # Terraform infrastructure as code
│ ├── modules/ # Reusable modules: agentidp, lb, rds, redis
│ └── environments/ # Environment configs: aws/ (ECS+RDS+ElastiCache), gcp/ (Cloud Run+SQL+Memorystore)
├── monitoring/ # Prometheus and Grafana configuration
│ ├── prometheus/ # prometheus.yml scrape configuration
│ └── grafana/ # Grafana provisioning YAML and dashboard JSON files
├── docs/ # All project documentation
│ ├── engineering/ # Internal engineering knowledge base (this directory)
│ ├── developers/ # End-user API reference and developer guides
│ ├── devops/ # Operator runbooks and environment variable reference
│ ├── agntcy/ # AGNTCY alignment documentation
│ └── openapi/ # OpenAPI 3.0 specification files
├── openspec/ # OpenSpec change management — proposals, designs, specs, tasks, archives
├── tests/ # Jest test suite — mirrors src/ structure
│ ├── unit/ # Unit tests (mocked dependencies) — mirrors src/
│ ├── integration/ # Integration tests (real DB + Redis)
│ ├── agntcy-conformance/ # AGNTCY conformance test suite (separate Jest config)
│ └── load/ # k6 load test scripts
├── Dockerfile # Multi-stage production build (build + runtime stages)
├── docker-compose.yml # Local development: PostgreSQL 14 (port 5432) + Redis 7 (port 6379)
├── docker-compose.monitoring.yml # Monitoring overlay: Prometheus (port 9090) + Grafana (port 3001)
├── package.json # Node.js dependencies and npm scripts
├── tsconfig.json # TypeScript strict configuration — compiled to dist/
└── jest.config.ts # Jest configuration — ts-jest, test timeouts, coverage thresholds
```
---
## 2. src/ Subdirectory Roles
| Directory | Role | Rule |
|-----------|------|------|
| `src/controllers/` | Receive HTTP requests, extract and validate inputs using Joi, call service methods, serialise responses | No business logic — controllers are thin wrappers that translate HTTP into service calls |
| `src/services/` | All business logic — free-tier limit enforcement, domain rule evaluation, orchestration of repository calls and audit events | Never import from controllers or routes; never know about `req` or `res` |
| `src/repositories/` | All database and Redis queries — parameterized SQL via `node-postgres`, Redis commands via `redis` client | Only called from services; never called directly from controllers; no business logic |
| `src/middleware/` | Cross-cutting request concerns — `authMiddleware`, `opaMiddleware`, `rateLimitMiddleware`, `metricsMiddleware`, `errorHandler` | Applied at router or app level in `src/app.ts`; never import from controllers |
| `src/routes/` | Map HTTP paths and methods to middleware chains and controller methods | Wiring only — no logic, no validation, no business rules |
| `src/utils/` | Shared pure utilities — `errors.ts`, `validators.ts`, `crypto.ts`, `jwt.ts`, `asyncHandler.ts` | No side effects; no imports from services or controllers |
| `src/types/` | All TypeScript type definitions, interfaces, and enums — the single source of truth for all shared types | Imported everywhere; never imports from anywhere else in `src/` |
| `src/vault/` | `VaultClient` — wraps HashiCorp Vault KV v2 operations; constant-time secret verification | Only instantiated by `createVaultClientFromEnv()` in `src/app.ts`; passed to services via constructor injection |
| `src/metrics/` | Prometheus metrics registry — all `Counter` and `Histogram` definitions in one place | Only file that calls `new Counter()` or `new Histogram()`; all other files import from here |
| `src/db/` | PostgreSQL connection pool factory (`pool.ts`) and numbered SQL migration files in `migrations/` | Pool is a singleton created once in `src/app.ts` and passed to repositories |
| `src/cache/` | Redis client factory — creates and caches a single `redis` client instance | Client is a singleton created once in `src/app.ts` and passed to repositories |
| `src/config/` | Configuration constants — `tiers.ts` exports `TIER_CONFIG`, `TIER_RANK`, `TierName`, and `isTierName()` type guard | Imported by `TierService` and `tierMiddleware`; never imports from services |
| `src/middleware/tier.ts` | Tier enforcement middleware — reads org tier from `TierService`, checks daily call counter in Redis, throws `TierLimitError` (429) when limit is exceeded, increments counter on pass | Applied only to API routes; skips `/health`, `/metrics`, and static file routes |
---
## 3. Where to Add New Code
| I need to add... | Where it goes | Example |
|-----------------|---------------|---------|
| A new API endpoint | `src/routes/` (wire it), `src/controllers/` (HTTP layer), `src/services/` (business logic), `src/repositories/` (data access) | Adding `DELETE /api/v1/agents/:id/credentials/:credId/bulk` |
| A new business rule | `src/services/[relevant]Service.ts` | Enforcing a maximum of 5 credentials per agent |
| A new database table | `src/db/migrations/` — new numbered SQL file (append-only) | Adding an `agent_groups` table as `005_create_agent_groups.sql` |
| A new authorisation policy rule | `policies/authz.rego` + `policies/data/scopes.json` | Adding a new scope `reports:read` for a `GET /api/v1/reports` endpoint |
| A new shared error type | `src/utils/errors.ts` | `VaultUnavailableError` extending `SentryAgentError` |
| A new environment variable | `src/utils/config.ts` (if it exists) or the relevant consumer file + `docs/devops/environment-variables.md` | `RATE_LIMIT_MAX` controlling the rate-limit ceiling |
| A new Prometheus metric | `src/metrics/registry.ts` | A `Histogram` for Vault lookup duration |
| A new TypeScript type used in 2+ files | `src/types/index.ts` | A new `AgentGroupMembership` interface |
| A new tier-gated feature | `src/config/tiers.ts` (add limit field) + `src/middleware/tier.ts` (add check) + service (enforce) | Adding a `maxWebhooksPerOrg` tier limit |
| A webhook event handler | `src/services/WebhookService.ts` (add event type to `WebhookEventType`) + the producer that calls `void webhookService.dispatch(orgId, eventType, payload)` | Emitting `agent.decommissioned` events to subscriber URLs |
| A new analytics metric type | `src/services/AnalyticsService.ts` (call `recordEvent(tenantId, 'new_metric')` in the relevant service using `void`) | Recording `credential_rotated` events for analytics |
| A new DID endpoint | `src/controllers/DIDController.ts` + `src/routes/did.ts` + `src/services/DIDService.ts` (if new method needed) + `policies/data/scopes.json` | Adding `GET /api/v1/agents/:id/did/rotate-key` |
---
## 4. Key Files
**`src/app.ts`**
Creates and configures the Express application. Registers all middleware (helmet, cors,
morgan, body parsers, metricsMiddleware), instantiates all infrastructure singletons
(PostgreSQL pool, Redis client, VaultClient), constructs the full dependency graph
(repositories → services → controllers), and mounts all routers. Returns the configured
`Application` without calling `listen`. Tests import `createApp()` directly — this is
the design decision that makes integration tests possible without binding a port.
**`src/server.ts`**
The only file that calls `app.listen()`. Loads environment variables via `dotenv.config()`,
calls `createApp()`, binds the port from `PORT` env var (default 3000), and registers
`SIGTERM`, `SIGINT` (graceful shutdown), and `SIGHUP` (OPA policy hot-reload via
`reloadOpaPolicy()`) signal handlers. This file is never imported by tests.
**`src/types/index.ts`**
The canonical type definition file for the entire project. Contains all exported
interfaces (`IAgent`, `ICredential`, `ITokenPayload`, `IAuditEvent`, etc.), union types
(`AgentStatus`, `AgentType`, `AuditAction`, etc.), and the global Express `Request`
augmentation that adds `req.user?: ITokenPayload`. If a type is needed in two or more
files, it lives here — never redefined inline.
**`src/utils/errors.ts`**
The `SentryAgentError` base class and all typed error subclasses. Every error thrown
in the application must extend `SentryAgentError` — never `throw new Error('string')`.
The `errorHandler` middleware in `src/middleware/errorHandler.ts` maps
`SentryAgentError` subclasses to their `httpStatus` codes and serialises the response
as `IErrorResponse { code, message, details }`.
**`docker-compose.yml`**
Starts PostgreSQL 14 (Alpine) on port 5432 with database `sentryagent_idp` and
Redis 7 (Alpine) on port 6379. Used for local development only. Both services have
health checks so `depends_on` conditions work correctly. The `app` service mounts
`./src` as a read-only volume for live code reloading.
**`tsconfig.json`**
TypeScript compiler configuration. `strict: true` enables the full suite of strictness
checks. `target: ES2022`, `module: commonjs` (the project compiles to CommonJS for
Node.js compatibility). `outDir: ./dist`, `rootDir: ./src`. The `noUnusedLocals` and
`noUnusedParameters` flags are enabled — unused code is a compile error. Never disable
these flags.
---
## 5. DRY Enforcement
Every piece of logic lives in exactly one place. Violations are CTO-blocking.
| Concern | Single source of truth | Violation pattern to reject |
|---------|----------------------|----------------------------|
| Business logic | One service method — called from multiple controllers if needed | Business logic duplicated in a route handler or controller |
| Database queries | One repository method — never repeated inline | SQL written directly in a service or controller |
| Error types | `src/utils/errors.ts` — imported wherever errors are thrown | `new Error('AGENT_NOT_FOUND')` instead of `new AgentNotFoundError()` |
| TypeScript types | `src/types/index.ts` — imported in every consumer file | An interface defined inline in a service file |
| Validation logic | `src/utils/validators.ts` — Joi schemas used in controllers | Validation logic duplicated across multiple controllers |
| Prometheus metrics | `src/metrics/registry.ts` — one definition per metric | A second `new Counter({ name: 'agentidp_tokens_issued_total' })` anywhere |

View File

@@ -0,0 +1,584 @@
# Service Deep Dives
---
### AgentService
**Purpose**: Manages the full lifecycle of AI agent identities — registration, retrieval, updates, and decommissioning.
**Responsibility boundary**: AgentService does not handle HTTP, credential secrets,
token issuance, or audit log queries. It delegates all data access to
`AgentRepository` and `CredentialRepository`, and all audit logging to `AuditService`.
It enforces free-tier limits and domain rules before any data is written.
**Public interface** (key methods):
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `registerAgent` | `data: ICreateAgentRequest, ipAddress: string, userAgent: string` | `Promise<IAgent>` | Checks the free-tier 100-agent limit, enforces email uniqueness, creates the agent record, writes an `agent.created` audit event, increments `agentidp_agents_registered_total` Prometheus counter |
| `getAgentById` | `agentId: string` | `Promise<IAgent>` | Retrieves a single agent by UUID; throws `AgentNotFoundError` if not found |
| `listAgents` | `filters: IAgentListFilters` | `Promise<IPaginatedAgentsResponse>` | Returns a paginated, optionally filtered list; filters include `owner`, `agentType`, `status`, `page`, `limit` |
| `updateAgent` | `agentId: string, data: IUpdateAgentRequest, ipAddress: string, userAgent: string` | `Promise<IAgent>` | Partially updates agent metadata; rejects updates to decommissioned agents; determines the correct audit action (`agent.updated`, `agent.suspended`, `agent.reactivated`, `agent.decommissioned`) based on status transition |
| `decommissionAgent` | `agentId: string, ipAddress: string, userAgent: string` | `Promise<void>` | Soft-deletes the agent (sets `status = 'decommissioned'`); revokes all active credentials by calling `credentialRepository.revokeAllForAgent(agentId)` before decommissioning |
**Database / storage schema**:
- Table `agents`: `agent_id` (UUID PK), `email` (UNIQUE), `agent_type`, `version`, `capabilities` (text array), `owner`, `deployment_env`, `status`, `created_at`, `updated_at`.
- No Redis usage — AgentService is PostgreSQL-only.
**Error types**:
- `FreeTierLimitError` (403) — 100-agent limit reached
- `AgentAlreadyExistsError` (409) — email already registered
- `AgentNotFoundError` (404) — agent UUID not found
- `AgentAlreadyDecommissionedError` (409) — agent is already decommissioned
**Configuration**: None — AgentService reads no environment variables. The free-tier limit (`FREE_TIER_MAX_AGENTS = 100`) is a module-level constant.
---
### OAuth2Service
**Purpose**: Issues, introspects, and revokes RS256 JWT access tokens via the OAuth 2.0 Client Credentials grant.
**Responsibility boundary**: OAuth2Service does not know about HTTP or routing. It
receives already-extracted values (`clientId`, `clientSecret`, `scope`) from the
controller, resolves credential verification (Vault or bcrypt), enforces the 10,000
tokens/month free-tier limit, and returns a typed `ITokenResponse`. All audit writes
on high-throughput paths (issue, introspect, revoke) are fire-and-forget (`void`) to
keep token endpoint latency low.
**Public interface** (key methods):
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `issueToken` | `clientId: string, clientSecret: string, scope: string, ipAddress: string, userAgent: string` | `Promise<ITokenResponse>` | Verifies credentials (Vault or bcrypt), checks agent status, enforces 10k/month limit, signs RS256 JWT, increments monthly counter and audit event as fire-and-forget |
| `introspectToken` | `token: string, callerPayload: ITokenPayload, ipAddress: string, userAgent: string` | `Promise<IIntrospectResponse>` | Verifies JWT signature and checks Redis revocation list; always returns 200 with `active: true/false` per RFC 7662 |
| `revokeToken` | `token: string, callerPayload: ITokenPayload, ipAddress: string, userAgent: string` | `Promise<void>` | Decodes token without verification; enforces that caller can only revoke their own tokens (`decoded.sub === callerPayload.sub`); adds JTI to Redis revocation list with TTL matching token expiry |
**Database / storage schema**:
- Redis key `revoked:{jti}` — value `1`, TTL = seconds until token expiry. Written on revocation; read on every authenticated request via `authMiddleware`.
- Redis key `monthly:tokens:{agentId}:{yyyy-mm}` — integer counter, incremented on every successful token issuance. Read to enforce the 10k/month free-tier limit.
**Error types**:
- `AuthenticationError` (401) — agent not found, or no active credential matches the provided secret
- `AuthorizationError` (403) — agent is suspended or decommissioned; or caller attempts to revoke another agent's token
- `FreeTierLimitError` (403) — 10,000 tokens/month limit reached
**Configuration**:
- `JWT_PRIVATE_KEY` — PEM-encoded RSA private key, required, read at app startup in `src/app.ts`
- `JWT_PUBLIC_KEY` — PEM-encoded RSA public key, required, read at app startup and in `authMiddleware`
- `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_MOUNT` — optional; when set, Vault is used for credential verification instead of bcrypt
---
### CredentialService
**Purpose**: Manages the full lifecycle of agent credentials — generation, listing, rotation, and revocation.
**Responsibility boundary**: CredentialService does not know about HTTP or token
issuance. It enforces that credentials can only be generated for `active` agents. It
delegates secret storage to either `VaultClient` (Phase 2) or bcrypt (Phase 1 fallback).
The plain-text `clientSecret` is generated here, returned once in the response, and
never stored or logged — only the bcrypt hash or Vault path is persisted.
**Public interface** (key methods):
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `generateCredential` | `agentId: string, data: IGenerateCredentialRequest, ipAddress: string, userAgent: string` | `Promise<ICredentialWithSecret>` | Verifies agent exists and is `active`; generates a cryptographically random secret via `generateClientSecret()`; writes to Vault (when configured) or hashes with bcrypt; returns `ICredentialWithSecret` — the only time the plain-text secret is returned |
| `listCredentials` | `agentId: string, filters: ICredentialListFilters` | `Promise<IPaginatedCredentialsResponse>` | Returns paginated credentials for an agent; `clientSecret` is never included in list responses |
| `rotateCredential` | `agentId: string, credentialId: string, data: IGenerateCredentialRequest, ipAddress: string, userAgent: string` | `Promise<ICredentialWithSecret>` | Generates a new secret for the same `credentialId`; overwrites Vault entry (new KV v2 version) or updates bcrypt hash; old secret is immediately invalidated; returns new `ICredentialWithSecret` once |
| `revokeCredential` | `agentId: string, credentialId: string, ipAddress: string, userAgent: string` | `Promise<void>` | Sets credential `status = 'revoked'`; permanently deletes the Vault secret via `vaultClient.deleteSecret()` when Vault is configured; idempotent rejection of already-revoked credentials with `CredentialAlreadyRevokedError` |
**Database / storage schema**:
- Table `credentials`: `credential_id` (UUID PK), `client_id` (= `agentId`, FK to `agents`), `secret_hash` (bcrypt hash; empty string when Vault path is set), `vault_path` (nullable — KV v2 data path), `status`, `created_at`, `expires_at` (nullable), `revoked_at` (nullable).
**Error types**:
- `AgentNotFoundError` (404) — agent UUID not found
- `CredentialError` (400) — agent is not in `active` status (code: `AGENT_NOT_ACTIVE`)
- `CredentialNotFoundError` (404) — credential not found or belongs to a different agent
- `CredentialAlreadyRevokedError` (409) — credential is already revoked
**Configuration**:
- `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_MOUNT` — optional; when set, new credentials are stored in Vault KV v2 instead of bcrypt. Existing bcrypt-based credentials continue to work unchanged.
---
### AuditService
**Purpose**: Creates and queries immutable audit events for compliance and observability.
**Responsibility boundary**: AuditService does not know about HTTP, tokens, or agents.
It receives already-assembled event data from other services and delegates all
persistence to `AuditRepository`. It enforces the 90-day free-tier retention window
on all query and retrieval operations — events older than 90 days are treated as
non-existent.
**Public interface** (key methods):
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `logEvent` | `agentId: string, action: AuditAction, outcome: AuditOutcome, ipAddress: string, userAgent: string, metadata: Record<string, unknown>` | `Promise<IAuditEvent>` | Writes an immutable audit row to PostgreSQL. For token endpoints, callers use `void` (fire-and-forget). For CRUD operations, callers `await` this method. |
| `queryEvents` | `filters: IAuditListFilters` | `Promise<IPaginatedAuditEventsResponse>` | Returns paginated, filtered audit events; enforces the 90-day retention window by computing the cutoff date and rejecting queries with `fromDate` before the cutoff; validates that `fromDate <= toDate` |
| `getEventById` | `eventId: string` | `Promise<IAuditEvent>` | Retrieves a single event by UUID; returns `AuditEventNotFoundError` for both genuinely missing events and events outside the 90-day retention window (indistinguishable by design) |
**Database / storage schema**:
- Table `audit_events`: `event_id` (UUID PK), `agent_id` (text FK to agents), `action` (text — one of the `AuditAction` union type values), `outcome` (`success` or `failure`), `ip_address` (text), `user_agent` (text), `metadata` (JSONB), `timestamp` (timestamptz, NOT NULL, indexed).
- No Redis usage — AuditService is PostgreSQL-only.
**Error types**:
- `AuditEventNotFoundError` (404) — event not found or outside retention window
- `RetentionWindowError` (400) — query `fromDate` is before the 90-day retention cutoff
- `ValidationError` (400) — `fromDate` is after `toDate`
**Configuration**: None — the retention window (`FREE_TIER_RETENTION_DAYS = 90`) is a module-level constant.
---
### VaultClient
**Purpose**: Wraps HashiCorp Vault KV v2 operations for credential secret storage and verification.
**Responsibility boundary**: VaultClient is a client adapter — it knows only about
Vault API calls. It has no knowledge of business rules, HTTP, or PostgreSQL. It is
injected into `CredentialService` and `OAuth2Service` via constructor injection. When
`VAULT_ADDR` is not set, `createVaultClientFromEnv()` returns `null` and the bcrypt
code path is used unchanged.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `writeSecret` | `agentId: string, credentialId: string, plainSecret: string` | `Promise<string>` | Writes the plain-text secret to the KV v2 data path; returns the path; creates a new KV v2 version on subsequent calls (used for rotation) |
| `readSecret` | `agentId: string, credentialId: string` | `Promise<string>` | Reads and returns the plain-text secret from Vault; throws `CredentialError` if the path is not found or the read fails |
| `verifySecret` | `agentId: string, credentialId: string, candidateSecret: string` | `Promise<boolean>` | Reads the stored secret via `readSecret`, then compares using `crypto.timingSafeEqual` to prevent timing-based side-channel attacks; returns `false` on any Vault error rather than throwing |
| `deleteSecret` | `agentId: string, credentialId: string` | `Promise<void>` | Permanently deletes all versions of a credential secret by calling the KV v2 metadata path (`DELETE {mount}/metadata/agentidp/agents/{agentId}/credentials/{credentialId}`) |
**KV v2 path structure**:
- Data path: `{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`
- Metadata path (for permanent deletion): `{mount}/metadata/agentidp/agents/{agentId}/credentials/{credentialId}`
- Default mount: `secret` (overridable via `VAULT_MOUNT`)
**Opt-in configuration**:
- `VAULT_ADDR` — Vault server address (e.g. `http://127.0.0.1:8200`) — required to enable Vault mode
- `VAULT_TOKEN` — Vault authentication token — required to enable Vault mode
- `VAULT_MOUNT` — KV v2 mount path — optional, defaults to `secret`
**Constant-time comparison rationale**: The `verifySecret` method uses Node.js
`crypto.timingSafeEqual` instead of `===` to prevent attackers from inferring the
length or content of stored secrets by measuring how long the comparison takes. When
the stored and candidate secrets differ in length, a dummy `timingSafeEqual` call is
still performed to eliminate the timing signal from the early-exit path.
---
### OPA Policy Engine
**Purpose**: Enforces scope-based authorisation on every protected HTTP request without requiring a code deployment to change access rules.
**Responsibility boundary**: The OPA policy engine (`src/middleware/opa.ts`) is a
middleware layer — it does not know about business rules, credentials, or audit events.
It receives the HTTP method, full request path, and caller scopes from `req.user`, and
returns allow or deny. All policy logic lives in `policies/authz.rego` and
`policies/data/scopes.json`.
**Policy file locations**:
- `policies/authz.rego` — Rego policy defining `normalise_path`, `lookup_key`, and the `allow` rule. Evaluated by the Wasm bundle when compiled; replicated in TypeScript for the fallback path.
- `policies/data/scopes.json` — JSON map of `"METHOD:/path/pattern"``[required_scopes]`. Loaded as data into the Wasm policy and used directly by the TypeScript fallback.
- `policies/authz.wasm` — compiled Wasm bundle (not committed to source control; built from `authz.rego` using the OPA CLI). When present, the Wasm path is used; when absent, the TypeScript fallback reads `scopes.json`.
**How `opaMiddleware` evaluates input**:
1. `createOpaMiddleware()` is called once at app startup in `src/app.ts`.
2. It attempts to load `policies/authz.wasm`. If found, `loadPolicy(wasmBuffer)` is called and `scopes.json` data is injected via `loaded.setData(parsed)`.
3. If no Wasm bundle is found, `scopes.json` is loaded into `scopesMap` as the TypeScript fallback.
4. On every request, the middleware builds an `OpaInput` object: `{ method: req.method, path: req.baseUrl + req.path, scopes: req.user.scope.split(' ') }`.
5. `evaluate(input)` checks the Wasm policy (if loaded) or applies `normalisePath` + scope-intersection logic against `scopesMap`. Returns `false` if neither is loaded (fail-closed).
6. If `evaluate` returns `false`, the middleware calls `next(new AuthorizationError())`.
**How to write a new policy rule**:
1. Add the new endpoint's scope requirement to `policies/data/scopes.json`:
```json
"GET:/api/v1/reports": ["reports:read"]
```
2. Add `"reports:read"` to the `OAuthScope` union type in `src/types/index.ts`.
3. If Wasm mode is in use, recompile `authz.rego` to `authz.wasm` using the OPA CLI: `opa build policies/authz.rego -d policies/data/ -o policies/authz.wasm`.
4. Send `SIGHUP` to the running process to hot-reload: `kill -HUP <pid>`.
**How to test a policy rule**:
```bash
# Using the OPA CLI directly
opa eval --data policies/data/scopes.json \
--input '{"method":"GET","path":"/api/v1/agents","scopes":["agents:read"]}' \
--bundle policies/ \
'data.authz.allow'
```
Expected output: `true`. Replace method/path/scopes to test deny cases.
**Hot-reload via SIGHUP**: When `SIGHUP` is received by the Node.js process,
`server.ts` calls `reloadOpaPolicy()`. This re-executes the same startup loading logic:
tries to load the Wasm bundle, falls back to `scopes.json`. The in-memory `wasmPolicy`
and `scopesMap` module-level variables are replaced atomically. No requests are dropped.
---
### Web Dashboard
**Purpose**: Provides a browser-based UI for human operators to manage agents, credentials, and audit logs without writing API calls directly.
**Responsibility boundary**: The dashboard is a pure client-side React SPA. It has no
server-side logic. It calls the AgentIdP REST API using the `@sentryagent/idp-sdk`
`TokenManager` for authentication and a typed `ApiClient` from `dashboard/src/lib/client.ts`
for all API calls. It never stores the `access_token` in localStorage — only
`client_id`, `client_secret`, and `baseUrl` are stored in `sessionStorage` (cleared
on tab close).
**React component structure**:
```
dashboard/src/
├── main.tsx # React root — mounts App into #root, wraps with BrowserRouter
├── App.tsx # Route definitions — AuthProvider, RequireAuth, AppShell
├── lib/
│ ├── auth.tsx # AuthContext, AuthProvider, useAuth hook, sessionStorage helpers
│ └── client.ts # Typed ApiClient class — wraps fetch with TokenManager token injection
├── components/
│ ├── RequireAuth.tsx # Route guard — redirects to /dashboard/login if not authenticated
│ └── layout/AppShell.tsx # Persistent sidebar navigation + Outlet for page content
└── pages/
├── Login.tsx # Login form — calls auth.login(), redirects to /dashboard/agents
├── Agents.tsx # Paginated agents list with status filter and search
├── AgentDetail.tsx # Single agent view — status, metadata, update, decommission actions
├── Credentials.tsx # Credential list for an agent — generate, rotate, revoke actions
├── AuditLog.tsx # Paginated audit log with date range and action filters
└── Health.tsx # /health endpoint response — PostgreSQL and Redis status display
```
**Authentication flow with sessionStorage**:
1. On `Login.tsx` form submit, `auth.login(creds)` is called.
2. `validateCredentials(creds)` creates a `TokenManager` and calls `getToken()` — if this succeeds, the credentials are valid.
3. `saveCredentials(creds)` stores `{ clientId, clientSecret, baseUrl }` in `sessionStorage` under key `agentidp_credentials`.
4. On every subsequent API call, `getClient()` in `lib/client.ts` reads credentials from `sessionStorage`, creates a `TokenManager`, and injects the current `access_token` into the `Authorization: Bearer` header. The `TokenManager` handles automatic token refresh when the token is expired.
5. `auth.logout()` calls `clearCredentials()` (removes the `sessionStorage` key) and navigates to `/dashboard/login`.
**Main views and their API calls**:
- **Agents** — `GET /api/v1/agents?page=N&limit=20` — paginated list with `status` filter
- **AgentDetail** — `GET /api/v1/agents/:id`, `PATCH /api/v1/agents/:id`, `DELETE /api/v1/agents/:id`
- **Credentials** — `GET /api/v1/agents/:id/credentials`, `POST /api/v1/agents/:id/credentials`, `POST /api/v1/agents/:id/credentials/:credId/rotate`, `DELETE /api/v1/agents/:id/credentials/:credId`
- **AuditLog** — `GET /api/v1/audit?page=N&limit=20&fromDate=...&toDate=...`
- **Health** — `GET /health`
**Local development**:
```bash
cd dashboard
npm install
npm run dev # Vite dev server with HMR — dashboard available at http://localhost:5173/dashboard
```
The Vite dev server proxies `/api/` calls to the Express server at `http://localhost:3000`.
The Express server must be running separately for API calls to work.
---
### Prometheus/Grafana Monitoring
**Purpose**: Provides operational visibility into AgentIdP's HTTP traffic, token issuance rates, agent registration rates, database latency, and Redis command latency.
**Responsibility boundary**: The metrics middleware (`src/middleware/metrics.ts`) and
the metrics registry (`src/metrics/registry.ts`) are observability concerns only — they
do not affect business logic. Metrics are exposed at `GET /metrics` via
`createMetricsRouter()` using `metricsRegistry.metrics()` from `prom-client`. The
`/metrics` endpoint is unauthenticated, intended for scraping by Prometheus only and
not exposed to the public internet.
**Key metrics with labels**:
| Metric Name | Type | Labels | Description |
|-------------|------|--------|-------------|
| `agentidp_http_requests_total` | Counter | `method`, `route`, `status_code` | Total HTTP requests received; route is normalised (UUIDs replaced with `:id`) |
| `agentidp_http_request_duration_seconds` | Histogram | `method`, `route`, `status_code` | HTTP request duration; buckets from 5ms to 2.5s |
| `agentidp_tokens_issued_total` | Counter | `scope` | Total OAuth 2.0 access tokens successfully issued |
| `agentidp_agents_registered_total` | Counter | `deployment_env` | Total AI agents successfully registered |
| `agentidp_db_query_duration_seconds` | Histogram | `operation` | PostgreSQL query duration; buckets from 1ms to 1s |
| `agentidp_redis_command_duration_seconds` | Histogram | `command` | Redis command duration; buckets from 0.5ms to 250ms |
**How to add a new Counter**:
1. Open `src/metrics/registry.ts`.
2. Add a new `Counter` export:
```typescript
export const myNewCounter = new Counter({
name: 'agentidp_my_new_counter_total',
help: 'Description of what this counts.',
labelNames: ['label_one'] as const,
registers: [metricsRegistry],
});
```
3. Import and call `myNewCounter.inc({ label_one: value })` in the service or middleware where the event occurs.
**How to add a new Histogram**:
1. Open `src/metrics/registry.ts`.
2. Add a new `Histogram` export with appropriate buckets:
```typescript
export const myDurationHistogram = new Histogram({
name: 'agentidp_my_operation_duration_seconds',
help: 'Duration of my operation in seconds.',
labelNames: ['operation'] as const,
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
registers: [metricsRegistry],
});
```
3. Use `const end = myDurationHistogram.startTimer({ operation: 'name' }); ... end();` around the operation being measured.
**Grafana access in local Docker**:
Start the monitoring overlay:
```bash
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up
```
- Prometheus: `http://localhost:9090`
- Grafana: `http://localhost:3001` — default credentials: `admin` / `agentidp`
Grafana is pre-provisioned with a Prometheus data source pointing to `http://prometheus:9090`
and dashboard JSON files from `monitoring/grafana/dashboards/`. No manual configuration
is needed after startup.
---
### AnalyticsService
**Purpose**: Records daily aggregated analytics events (token issuances, agent activity) and exposes query methods for token trends, agent activity heatmaps, and per-agent usage summaries. All query methods scope results strictly to the supplied `tenantId`. The `recordEvent` method is fire-and-forget — it catches all errors internally and never propagates them to the caller, so analytics writes never block primary request paths.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `recordEvent` | `tenantId: string, metricType: string` | `Promise<void>` | Upserts a daily counter row in `analytics_events` via `INSERT ... ON CONFLICT DO UPDATE SET count = count + 1`. Catches and swallows all errors; safe to call with `void` on hot paths. |
| `getTokenTrend` | `tenantId: string, days: number` | `Promise<ITokenTrendEntry[]>` | Returns daily token issuance counts for the last N days (clamped to 90). Uses `generate_series` + `LEFT JOIN` so that days with no events appear as `count: 0`. Results sorted ascending by date. |
| `getAgentActivity` | `tenantId: string` | `Promise<IAgentActivityEntry[]>` | Returns agent activity bucketed by day-of-week (0=Sun…6=Sat) and hour-of-day for the last 30 days. Reads only rows whose `metric_type` matches the pattern `agent:<agentId>:<metricType>`. |
| `getAgentUsageSummary` | `tenantId: string` | `Promise<IAgentUsageSummaryEntry[]>` | Returns per-agent token issuance totals for the current calendar month, joined with the agent name (`owner` field). Sorted descending by `token_count`. Excludes decommissioned agents. |
**Dependencies**: PostgreSQL connection pool (`Pool` from `pg`). No Redis usage.
**Configuration**: None. `MAX_TREND_DAYS = 90` is a module-level constant.
**DB tables**:
- `analytics_events`: `organization_id` (UUID FK to `organizations`), `date` (DATE), `metric_type` (text — e.g. `'token_issued'`, `'agent:<agentId>:token_issued'`), `count` (integer). Unique constraint on `(organization_id, date, metric_type)`.
- `agents`: read in `getAgentUsageSummary` to join `owner` and filter by `organization_id`.
---
### TierService
**Purpose**: Single authority for all subscription tier business logic — fetches current tier and live usage, initiates Stripe Checkout sessions for upgrades, applies confirmed upgrades to the `organizations` table, and enforces per-tier agent count limits. Controllers and middleware delegate all tier decisions to this service; no tier logic lives elsewhere.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `getStatus` | `orgId: string` | `Promise<ITierStatus>` | Returns current `tier`, per-tier `limits` (from `TIER_CONFIG`), live `usage` (Redis counters + DB agent count), and `resetAt` (ISO 8601 next UTC midnight). Falls back to `0` for Redis counters when Redis is unavailable. |
| `initiateUpgrade` | `orgId: string, targetTier: TierName` | `Promise<IUpgradeInitiation>` | Validates that `targetTier` is strictly higher rank than current tier. Creates a Stripe Checkout Session with `mode: 'subscription'`, `metadata: { orgId, targetTier }`, and the price ID from `STRIPE_PRICE_ID_<TIER>` env var. Returns `{ checkoutUrl }`. |
| `applyUpgrade` | `orgId: string, tier: TierName` | `Promise<void>` | Sets `organizations.tier` and `organizations.tier_updated_at = NOW()`. Called by the Stripe webhook handler after `checkout.session.completed`. |
| `fetchTier` | `orgId: string` | `Promise<TierName>` | Queries `organizations.tier` for the given org. Returns `'free'` as a safe default when no row is found or the stored value is not a valid `TierName`. |
| `enforceAgentLimit` | `orgId: string, tier: TierName` | `Promise<void>` | Counts non-decommissioned agents for the org and throws `TierLimitError` when count is at or over `TIER_CONFIG[tier].maxAgents`. No-op for Enterprise (infinite limit). Called by `AgentService` before creating a new agent. |
**Dependencies**: PostgreSQL (`Pool`), Redis (`RedisClientType`), Stripe client (`Stripe`). Imports `TIER_CONFIG` and `TIER_RANK` from `src/config/tiers.ts`.
**Configuration**:
- `STRIPE_PRICE_ID_PRO` — Stripe price ID for the Pro tier
- `STRIPE_PRICE_ID_ENTERPRISE` — Stripe price ID for the Enterprise tier
- `STRIPE_PRICE_ID` — Fallback Stripe price ID when tier-specific vars are not set
- `STRIPE_SUCCESS_URL` — Redirect URL on successful checkout (default: `APP_BASE_URL/dashboard?billing=success`)
- `STRIPE_CANCEL_URL` — Redirect URL when checkout is cancelled (default: `APP_BASE_URL/dashboard?billing=cancel`)
- `APP_BASE_URL` — Base URL for redirect URL construction (default: `http://localhost:3000`)
**Redis keys**:
- `rate:tier:calls:<orgId>` — integer, daily API call counter; TTL set at next UTC midnight. Read in `getStatus`.
- `rate:tier:tokens:<orgId>` — integer, daily token issuance counter; same TTL. Read in `getStatus`.
**DB tables**:
- `organizations`: `organization_id` (UUID PK), `tier` (text — `'free'|'pro'|'enterprise'`), `tier_updated_at` (timestamptz). Read in `fetchTier`; written in `applyUpgrade`.
- `agents`: read in `enforceAgentLimit` and `getStatus` to count non-decommissioned agents per org.
**Error types**:
- `ValidationError` (400) — target tier is not higher than current tier
- `TierLimitError` (429) — agent count limit reached for the current tier
---
### ComplianceService
**Purpose**: Generates AGNTCY-standard compliance reports and exports agent cards for a tenant. Reports cover two sections: `agent-identity` (DID presence and credential expiry checks) and `audit-trail` (cryptographic hash chain verification). Reports are cached in Redis for 5 minutes to avoid repeated expensive DB queries. Agent card export returns all active agents in AGNTCY-standard JSON format.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `generateReport` | `tenantId: string` | `Promise<IComplianceReport>` | Attempts to read `compliance:report:<tenantId>` from Redis; if found, returns it with `from_cache: true`. Otherwise builds the report by running `buildAgentIdentitySection` and `buildAuditTrailSection` in parallel, rolls up the overall status (fail > warn > pass), caches the result for 300 seconds, and returns it. |
| `exportAgentCards` | `tenantId: string` | `Promise<IAgentCard[]>` | Queries all non-decommissioned agents for the tenant and maps each to an AGNTCY agent card with `id` (DID or agent UUID), `name`, `capabilities`, `endpoint`, `created_at`, and `agntcy_schema_version: '1.0'`. |
**Dependencies**: PostgreSQL (`Pool`), Redis (`RedisClientType`). Internally instantiates `AuditVerificationService` for hash chain verification.
**Configuration**: None. `CACHE_TTL_SECONDS = 300` and `AGNTCY_SCHEMA_VERSION = '1.0'` are module-level constants.
**Redis keys**:
- `compliance:report:<tenantId>` — JSON-serialised `IComplianceReport`, TTL 300 seconds. Written by `generateReport`; read on every call within the cache window.
**DB tables**:
- `agents`: queried in both `buildAgentIdentitySection` (checks DID presence) and `exportAgentCards`.
- `credentials`: queried in `buildAgentIdentitySection` to check active credential expiry per agent.
- `audit_events`: read via `AuditVerificationService` in `buildAuditTrailSection` to verify hash chain integrity.
**Error types**: None thrown directly. Internal errors in section builders produce `status: 'fail'` sections rather than exceptions.
**Report structure**:
- `agent-identity` section: `fail` when any active agent is missing a DID or has expired credentials; `warn` when any credential expires within 7 days; `pass` otherwise.
- `audit-trail` section: `fail` when `AuditVerificationService.verifyChain()` returns `verified: false`; `pass` otherwise.
---
### FederationService
**Purpose**: Manages trusted federation partners and cross-IdP JWT token verification. At partner registration, the partner's JWKS endpoint is validated and the keys are cached in Redis. At token verification, the service fetches (or reuses cached) partner JWKS, verifies the JWT signature and standard claims, enforces the partner's `allowed_organizations` filter, and rejects tokens from suspended or expired partners.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `registerPartner` | `req: ICreatePartnerRequest` | `Promise<IFederationPartner>` | Validates the `jwks_uri` is reachable (5-second timeout) and returns valid JWKS. Inserts the partner row into `federation_partners`. Caches the JWKS in Redis under `federation:jwks:<issuer>`. |
| `listPartners` | _(none)_ | `Promise<IFederationPartner[]>` | Updates any partners past `expires_at` to `status = 'expired'` before returning all rows ordered by `created_at DESC`. |
| `getPartner` | `id: string` | `Promise<IFederationPartner>` | Applies the same expiry update, then returns the partner row. Throws `FederationPartnerNotFoundError` (404) when not found. |
| `updatePartner` | `id: string, req: IUpdatePartnerRequest` | `Promise<IFederationPartner>` | Applies a partial update. When `jwks_uri` changes, invalidates the old issuer's JWKS cache entry (`DEL federation:jwks:<oldIssuer>`). |
| `deletePartner` | `id: string` | `Promise<void>` | Deletes the partner row and invalidates the JWKS cache. |
| `verifyFederatedToken` | `req: IFederationVerifyRequest` | `Promise<IFederationVerifyResult>` | Decodes token header/payload without verification, rejects `alg:none`, looks up partner by `iss`, checks partner status and expiry, fetches JWKS (cache-first), finds matching key by `kid`, converts JWK to PEM, verifies signature via `jsonwebtoken.verify` (RS256 or ES256), enforces `allowed_organizations` filter. Returns `{ valid, issuer, subject, organization_id, claims }`. |
**Dependencies**: PostgreSQL (`Pool`), Redis (`RedisClientType`). Uses `jsonwebtoken` for JWT decoding/verification and Node.js `crypto.createPublicKey` for JWK-to-PEM conversion.
**Configuration**:
- `FEDERATION_JWKS_CACHE_TTL_SECONDS` — TTL for cached partner JWKS in Redis (default: `3600`)
**Redis keys**:
- `federation:jwks:<issuer>` — JSON-serialised `IJWKSKey[]`, TTL from `FEDERATION_JWKS_CACHE_TTL_SECONDS`. Written on partner registration and on cache miss during token verification; deleted when a partner is updated (JWKS URI changed) or deleted.
**DB tables**:
- `federation_partners`: `id` (UUID PK), `name` (text), `issuer` (text — the IdP's issuer URL), `jwks_uri` (text), `allowed_organizations` (text[] — empty means all orgs allowed), `status` (`active|suspended|expired`), `created_at`, `updated_at`, `expires_at` (nullable timestamptz).
**Error types**:
- `FederationPartnerError` (400) — JWKS endpoint unreachable or returns invalid JWKS
- `FederationPartnerNotFoundError` (404) — partner UUID not found
- `FederationVerificationError` (401) — token malformed, alg:none, unknown issuer, partner suspended/expired, signature invalid, org not in allow list
---
### DIDService
**Purpose**: Manages W3C DID Core 1.0 document generation, EC P-256 key pair creation, and AGNTCY agent card export. Generates per-agent `did:web` identifiers, stores private keys in HashiCorp Vault (or records a `dev:no-vault` marker in dev mode), and caches DID documents in Redis. Builds both an instance-level DID document (for AgentIdP itself) and per-agent DID documents with AGNTCY extension properties.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `generateDIDForAgent` | `agentId: string, organizationId: string` | `Promise<{ did: string; publicKeyJwk: IPublicKeyJwk }>` | Generates an EC P-256 key pair. Stores the private key PEM in Vault KV v2 at `{mount}/data/agentidp/agents/{agentId}/did-key`. Encrypts the vault path via `EncryptionService` (when configured). Inserts a row into `agent_did_keys`. Updates `agents.did` and `agents.did_created_at`. Returns the `did:web` identifier and public key JWK. |
| `buildInstanceDIDDocument` | _(none)_ | `Promise<IDIDDocument>` | Builds the root instance DID document for AgentIdP (format: `did:web:{DID_WEB_DOMAIN}`). Cached in Redis under `did:doc:instance`. |
| `buildAgentDIDDocument` | `agentId: string` | `Promise<IAgentDIDDocumentResult>` | Builds a per-agent DID document (format: `did:web:{DID_WEB_DOMAIN}:agents:{agentId}`). Decommissioned agents get a deactivated document with an `AgentStatus: decommissioned` service entry. Cached in Redis under `did:doc:{agentId}` for active agents only. Throws `AgentNotFoundError` if the agent does not exist. |
| `buildResolutionResult` | `agentId: string` | `Promise<IDIDResolutionResult>` | Wraps `buildAgentDIDDocument` with W3C DID Resolution metadata (`didDocumentMetadata`, `didResolutionMetadata`). |
| `buildAgentCard` | `agentId: string` | `Promise<IAgentCard>` | Returns an AGNTCY-format agent card with `did`, `name` (agent email), `agentType`, `capabilities`, `owner`, `version`, `deploymentEnv`, `identityProvider`, and `issuedAt`. |
**Dependencies**: PostgreSQL (`Pool`), Redis (`RedisClientType`), optional `VaultClient`, optional `EncryptionService`. Uses `node-vault` directly for DID private key storage.
**Configuration**:
- `DID_WEB_DOMAIN` — required; the domain for `did:web` DID construction (e.g. `idp.sentryagent.ai`)
- `DID_DOCUMENT_CACHE_TTL_SECONDS` — Redis cache TTL for DID documents (default: `300`)
- `VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_MOUNT` — when set, private keys are stored in Vault; otherwise `dev:no-vault` marker is used
**Redis keys**:
- `did:doc:instance` — JSON-serialised instance `IDIDDocument`, TTL from `DID_DOCUMENT_CACHE_TTL_SECONDS`
- `did:doc:<agentId>` — JSON-serialised per-agent `IDIDDocument`, same TTL. Not cached for decommissioned agents.
**DB tables**:
- `agents`: `did` (text — `did:web:...`), `did_created_at` (timestamptz). Written by `generateDIDForAgent`; read in all document-building methods.
- `agent_did_keys`: `key_id` (UUID PK), `agent_id` (UUID FK), `organization_id` (UUID FK), `public_key_jwk` (JSONB), `vault_key_path` (text — Vault KV v2 path or `dev:no-vault`), `key_type` (`'EC'`), `curve` (`'P-256'`), `created_at`. Written by `generateDIDForAgent`.
**Error types**:
- `AgentNotFoundError` (404) — agent UUID not found in `buildAgentDIDDocument`, `buildResolutionResult`, `buildAgentCard`
---
### WebhookService
**Purpose**: Manages webhook subscriptions and their delivery history for a tenant organisation. HMAC signing secrets are stored in HashiCorp Vault KV v2 (when configured) or bcrypt-hashed in PostgreSQL in local mode. The raw secret is only returned once at subscription creation time. `vault_secret_path` is encrypted at rest via `EncryptionService` (AES-256-CBC) before being written to PostgreSQL (SOC 2 CC6.1 compliance).
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `createSubscription` | `orgId: string, req: ICreateWebhookRequest` | `Promise<IWebhookSubscription & { secret: string }>` | Generates a 32-byte random hex HMAC secret. Stores in Vault at `secret/data/agentidp/webhooks/{orgId}/{id}/secret` (Vault mode) or bcrypt-hashes and stores in `secret_hash` (local mode). Encrypts `vault_secret_path` via `EncryptionService`. Returns the subscription including the one-time `secret`. Validates URL must use `https://` and events array must be non-empty. |
| `listSubscriptions` | `orgId: string` | `Promise<IWebhookSubscription[]>` | Returns all subscriptions for the org, ordered by `created_at DESC`. No secret fields are included. |
| `getSubscription` | `id: string, orgId: string` | `Promise<IWebhookSubscription>` | Returns a single subscription. Verifies org ownership. |
| `updateSubscription` | `id: string, orgId: string, req: IUpdateWebhookRequest` | `Promise<IWebhookSubscription>` | Partially updates `name`, `url`, `events`, or `active` fields. Validates `https://` if URL is changing. |
| `deleteSubscription` | `id: string, orgId: string` | `Promise<void>` | Permanently deletes the subscription and all deliveries (via PostgreSQL CASCADE). |
| `getSubscriptionSecret` | `subscriptionId: string, orgId: string` | `Promise<string>` | Retrieves the raw HMAC secret from Vault (Vault mode only). Throws `WebhookValidationError` in local mode since the secret cannot be recovered after creation. |
| `listDeliveries` | `subscriptionId: string, orgId: string, limit: number, offset: number` | `Promise<IPaginatedDeliveriesResponse>` | Returns paginated delivery records for a subscription. Verifies org ownership before querying. |
**Dependencies**: PostgreSQL (`Pool`), optional `VaultClient`, Redis (`RedisClientType` — reserved for future caching), optional `EncryptionService`.
**Configuration**: Inherits Vault configuration from `VaultClient` (`VAULT_ADDR`, `VAULT_TOKEN`, `VAULT_MOUNT`). `EncryptionService` requires `ENCRYPTION_KEY` env var (see `EncryptionService` docs).
**DB tables**:
- `webhook_subscriptions`: `id` (UUID PK), `organization_id` (UUID FK), `name` (text), `url` (text — https only), `events` (JSONB — `WebhookEventType[]`), `secret_hash` (text — bcrypt hash in local mode, `'vault'` in Vault mode), `vault_secret_path` (text — encrypted Vault path or `'local'`), `active` (boolean), `failure_count` (integer), `created_at`, `updated_at`.
- `webhook_deliveries`: `id` (UUID PK), `subscription_id` (UUID FK), `event_type` (text), `payload` (JSONB), `status` (`pending|delivered|failed|dead_letter`), `http_status_code` (integer nullable), `attempt_count` (integer), `next_retry_at` (timestamptz nullable), `delivered_at` (timestamptz nullable), `created_at`, `updated_at`. Cascades on subscription delete.
**Error types**:
- `WebhookNotFoundError` (404) — subscription not found or belongs to another org
- `WebhookValidationError` (400) — invalid URL scheme, empty events array, or secret not recoverable in local mode
---
### BillingService
**Purpose**: Manages Stripe billing integration — creates Checkout Sessions for tenant subscriptions, processes incoming Stripe webhook events (subscription lifecycle and checkout completion), and retrieves current subscription status. When a `checkout.session.completed` event carries `{ orgId, targetTier }` in its metadata, delegates to `TierService.applyUpgrade` to update the organisation's tier.
**Public methods**:
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `createCheckoutSession` | `tenantId: string, successUrl: string, cancelUrl: string` | `Promise<string>` | Creates a Stripe Checkout Session with `mode: 'subscription'`, `client_reference_id: tenantId`, and the price from `STRIPE_PRICE_ID`. Returns the checkout URL. Throws if Stripe does not return a URL. |
| `handleWebhookEvent` | `rawBody: Buffer, sig: string, webhookSecret: string` | `Promise<void>` | Verifies the Stripe webhook signature via `stripe.webhooks.constructEvent`. Handles `customer.subscription.created/updated/deleted` (upserts `tenant_subscriptions`) and `checkout.session.completed` (applies tier upgrade via `TierService` when metadata contains `orgId` and `targetTier`). |
| `getSubscriptionStatus` | `tenantId: string` | `Promise<ISubscriptionStatus>` | Queries `tenant_subscriptions` for the given tenant. Returns `{ tenantId, status: 'free', currentPeriodEnd: null, stripeSubscriptionId: null }` when no row exists. |
**Dependencies**: PostgreSQL (`Pool`), Stripe client (`Stripe`), optional `TierService`.
**Configuration**:
- `STRIPE_PRICE_ID` — Stripe price ID for subscription checkout sessions
- `STRIPE_WEBHOOK_SECRET` — Stripe webhook endpoint secret (`whsec_...`); passed by the webhook controller, not read directly by the service
**DB tables**:
- `tenant_subscriptions`: `tenant_id` (UUID PK or unique), `status` (text — `'free'|'active'|'past_due'|'canceled'`), `stripe_customer_id` (text), `stripe_subscription_id` (text), `current_period_end` (timestamptz nullable), `updated_at`. Upserted on subscription lifecycle events.
**Error types**: None defined in the service. Stripe signature failures raise `Error` from `stripe.webhooks.constructEvent`; these propagate to the error handler as 400 responses.
---
### OIDCService (A2A / OIDC Provider)
**Note**: `src/services/OIDCService.ts` does not exist as a standalone file — OIDC provider functionality is handled by the `oidc-provider` npm package, configured in `src/app.ts` and related route files. The service boundary for OIDC-related business logic is the `DelegationService`. Document the OIDC integration as follows.
**Purpose**: The OIDC/A2A subsystem provides agent-to-agent (A2A) delegation using the `oidc-provider` library (v9.7.x). The provider is mounted as a sub-application at `/oidc` and issues short-lived delegation tokens scoped to a specific `delegatee_id`. The `DelegationService` (`src/services/DelegationService.ts`) manages the `delegation_chains` table for auditing.
**Key endpoints exposed by the OIDC provider**:
- `POST /oidc/token` — issues delegation tokens via `client_credentials` or custom grant
- `GET /oidc/.well-known/openid-configuration` — OIDC discovery document
- `GET /oidc/jwks` — public JWK Set for verifying delegation tokens
**DelegationService public methods** (from `src/services/DelegationService.ts`):
| Method | Parameters | Returns | Description |
|--------|-----------|---------|-------------|
| `createDelegation` | `delegatorId: string, delegateeId: string, scope: string, expiresAt?: Date` | `Promise<IDelegationChain>` | Inserts a delegation chain record into `delegation_chains`. Validates both agents exist and are active. |
| `verifyDelegation` | `token: string, delegateeId: string` | `Promise<IDelegationVerifyResult>` | Verifies the delegation token signature and checks the chain record is active and not expired. |
| `revokeDelegation` | `chainId: string, delegatorId: string` | `Promise<void>` | Sets `delegation_chains.status = 'revoked'` and `revoked_at = NOW()`. Validates the delegator owns the chain. |
**DB tables**:
- `delegation_chains`: `chain_id` (UUID PK), `delegator_id` (UUID), `delegatee_id` (UUID), `scope` (text), `status` (`active|revoked|expired`), `created_at`, `expires_at` (nullable), `revoked_at` (nullable), `token` (text — the delegation JWT).
**Configuration**:
- `A2A_ENABLED` — when set to `'false'`, A2A/delegation endpoints return 404
- `OIDC_ISSUER` — issuer URL for the OIDC provider

View File

@@ -0,0 +1,974 @@
# 06 — Code Walkthroughs
Last verified against commit: `1f95cfe89d1f45fa43b9fb7cff237f07bf9e889e`
These walkthroughs trace three real production code paths from the HTTP request
to the database and back. Every step includes a `file:line` reference and a
"why" annotation explaining the design decision.
---
## Walkthrough 1 — Token Issuance
**Request:** `POST /api/v1/token` with `grant_type=client_credentials`
This is the most security-critical path in the codebase. An AI agent calling this
endpoint is proving its identity and receiving a token that grants access to the
entire API for one hour.
---
### Step 1 — Express middleware stack
**File:** `src/app.ts` lines 5783
```
helmet() → security headers
cors() → CORS headers
morgan() → access log line (skipped in test env)
express.json() → parse JSON bodies
express.urlencoded({ extended: false }) → parse form-encoded bodies
metricsMiddleware → start request timer, record counters on finish
```
**Why `extended: false`?** The token endpoint receives `application/x-www-form-urlencoded`
bodies (RFC 6749 mandates this format for OAuth 2.0). The `express.urlencoded`
middleware parses them into `req.body`. `extended: false` uses the native `querystring`
parser, which is sufficient and avoids `qs` library complexity for flat key-value data.
---
### Step 2 — Route dispatch
**File:** `src/routes/token.ts` line 24
```typescript
router.post('/', asyncHandler(rateLimitMiddleware), asyncHandler(tokenController.issueToken.bind(tokenController)));
```
**Why no `authMiddleware` here?** The token endpoint is where the agent _gets_ its
token — it cannot present a Bearer token to authenticate. Instead, credentials go
in the request body (`client_id`, `client_secret`). `POST /token` is deliberately
unauthenticated at the transport layer; authentication happens inside the controller.
**Why `asyncHandler`?** Express does not natively support async middleware. `asyncHandler`
wraps the async function and calls `next(err)` if the promise rejects, routing the
error to `errorHandler`.
---
### Step 3 — Rate limit check
**File:** `src/middleware/rateLimit.ts`
The rate limiter checks a Redis sliding-window counter for the client's IP address.
If the counter exceeds 100 requests/minute, it throws `RateLimitError` (429).
**Why Redis, not in-memory?** If the server restarts or scales horizontally to multiple
instances, an in-memory counter would reset. Redis maintains the counter across
instances and restarts.
---
### Step 4 — Controller: validate grant_type
**File:** `src/controllers/TokenController.ts` lines 84103
```typescript
issueToken = async (req: Request, res: Response, _next: NextFunction): Promise<void> => {
const body = req.body as ITokenRequest;
if (!body.grant_type) { ... return res.status(400).json({error: 'invalid_request', ...}) }
if (body.grant_type !== 'client_credentials') { ... return res.status(400).json(...) }
```
**Why does this method catch errors itself instead of calling `next(err)`?** The token
endpoint must return errors in the **OAuth 2.0 error format** (`{ error, error_description }`)
per RFC 6749 §5.2, not the standard SentryAgent.ai format (`{ code, message }`). The
`mapToOAuth2Error()` helper translates `AuthenticationError` and `AuthorizationError`
into OAuth2 error codes. The `_next` parameter is intentionally unused for the error path.
---
### Step 5 — Controller: Joi validation and credential extraction
**File:** `src/controllers/TokenController.ts` lines 106138
```typescript
const { error, value } = tokenRequestSchema.validate(body, { abortEarly: false });
// ...
// Support HTTP Basic auth fallback (RFC 6749 §2.3.1)
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(':');
clientId = decoded.slice(0, colonIndex);
clientSecret = decoded.slice(colonIndex + 1);
}
```
**Why `abortEarly: false`?** This returns all validation errors at once, so the
client can fix all problems in one round trip.
**Why Basic auth support?** RFC 6749 §2.3.1 specifies that client credentials MAY
be sent via HTTP Basic authentication. Some OAuth libraries default to this method.
---
### Step 6 — Controller: scope validation
**File:** `src/controllers/TokenController.ts` lines 141151
```typescript
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) { return res.status(400).json({error: 'invalid_scope', ...}) }
```
**Why validate scopes here?** Scope validation at the controller layer provides an
RFC 6749-compliant `invalid_scope` error before we even look up the agent. This is
faster and gives the client a clearer error message.
---
### Step 7 — Service: agent lookup
**File:** `src/services/OAuth2Service.ts` lines 8394
```typescript
const agent = await this.agentRepository.findById(clientId);
if (!agent) {
void this.auditService.logEvent(clientId, 'auth.failed', 'failure', ..., { reason: 'agent_not_found', clientId });
throw new AuthenticationError('Client authentication failed...');
}
```
**Why log auth failures?** Failed authentication attempts may indicate a brute-force
attack or a misconfigured client. Having them in the audit log enables incident
investigation and alerting.
**Why not distinguish between "agent not found" and "wrong secret" in the error message?**
Revealing which is wrong gives an attacker information — they can enumerate valid
`client_id` values by checking whether they get "agent not found" vs "wrong secret".
Both cases return the same message.
---
### Step 8 — Service: credential verification
**File:** `src/services/OAuth2Service.ts` lines 97131
```typescript
const { credentials } = await this.credentialRepository.findByAgentId(clientId, { status: 'active', page: 1, limit: 100 });
for (const cred of credentials) {
const credRow = await this.credentialRepository.findById(cred.credentialId);
if (credRow) {
if (credRow.expiresAt !== null && credRow.expiresAt < new Date()) { continue; }
let matches: boolean;
if (credRow.vaultPath !== null && this.vaultClient !== null) {
matches = await this.vaultClient.verifySecret(clientId, credRow.credentialId, clientSecret);
} else {
matches = await verifySecret(clientSecret, credRow.secretHash);
}
if (matches) { credentialVerified = true; break; }
}
}
```
**Why iterate over multiple credentials?** An agent can have multiple active
credentials (e.g. one per service that calls it). The agent rotates credentials
one at a time — if credential A is rotated while service X is still using it,
service X will fail. By checking all active credentials, we allow overlapping rotation.
**Why check expiry before hashing?** Bcrypt is intentionally slow (~100ms). Checking
expiry first is a cheap early exit that avoids the bcrypt computation on expired
credentials.
---
### Step 9 — Service: status and monthly limit checks
**File:** `src/services/OAuth2Service.ts` lines 144176
```typescript
if (agent.status === 'suspended') { throw new AuthorizationError(...) }
if (agent.status === 'decommissioned') { throw new AuthorizationError(...) }
const monthlyCount = await this.tokenRepository.getMonthlyCount(clientId);
if (monthlyCount >= FREE_TIER_MAX_MONTHLY_TOKENS) { throw new FreeTierLimitError(...) }
```
**Why check status after credential verification?** We verify credentials first so
a suspended agent with a wrong secret gets `AuthenticationError` (401) not
`AuthorizationError` (403). This prevents leaking which agents are suspended to
unauthenticated callers.
---
### Step 10 — Service: sign the JWT
**File:** `src/services/OAuth2Service.ts` lines 179190
```typescript
const jti = uuidv4();
const payload: Omit<ITokenPayload, 'iat' | 'exp'> = { sub: clientId, client_id: clientId, scope, jti };
const accessToken = signToken(payload, this.privateKey);
```
**File:** `src/utils/jwt.ts` lines 1931
```typescript
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' });
}
```
**Why RS256 instead of HS256?** RS256 (RSA asymmetric) allows any consumer of the
token to verify it using the public key without needing the private signing key.
HS256 (HMAC symmetric) would require sharing the secret with every service that
verifies tokens.
**Why `jti` (JWT ID)?** The `jti` is a unique identifier for this specific token.
It is used as the key in the Redis revocation list. Without `jti`, you cannot
revoke a single token without revoking all tokens for the agent.
---
### Step 11 — Service: fire-and-forget operations
**File:** `src/services/OAuth2Service.ts` lines 193207
```typescript
void this.tokenRepository.incrementMonthlyCount(clientId);
void this.auditService.logEvent(clientId, 'token.issued', 'success', ..., { scope, expiresAt });
tokensIssuedTotal.inc({ scope });
```
**Why `void` (fire-and-forget)?** The token has been signed and is ready to return.
Waiting for the Redis increment and audit write would add ~510ms to every token
request. These operations are best-effort — if they fail, the token is still valid.
**Why is the Prometheus `.inc()` call synchronous?** Prometheus counters are
in-process memory operations — they do not write to Redis or PostgreSQL. They are
O(1) and sub-microsecond.
---
### Step 12 — Response
**File:** `src/controllers/TokenController.ts` lines 163167
```typescript
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
res.status(200).json(tokenResponse);
```
**Why `Cache-Control: no-store`?** RFC 6749 §5.1 mandates that token responses
must not be cached. Without this header, a shared proxy or CDN could cache the
response and replay it to another client.
Final response:
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read agents:write"
}
```
---
## Walkthrough 2 — Agent Registration
**Request:** `POST /api/v1/agents` with Bearer token and agent data JSON body
After token issuance, registering an agent is the second most common operation.
This walkthrough shows a request that goes through all three auth middleware layers.
---
### Step 1 — Middleware stack
**File:** `src/app.ts` lines 5783 (same security and parsing middleware as Walkthrough 1)
---
### Step 2 — Route dispatch
**File:** `src/routes/agents.ts` lines 2227
```typescript
router.use(asyncHandler(authMiddleware));
router.use(opaMiddleware);
router.use(asyncHandler(rateLimitMiddleware));
router.post('/', asyncHandler(agentController.registerAgent.bind(agentController)));
```
All three middleware run on every request to the agents router before the handler.
---
### Step 3 — Auth middleware: Bearer token verification
**File:** `src/middleware/auth.ts` lines 2877
```typescript
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) { throw new AuthenticationError(...) }
const token = authHeader.slice(7).trim();
const publicKey = process.env['JWT_PUBLIC_KEY'];
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.') }
}
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();
```
**Why check Redis after signature verification?** Signature verification is a pure
cryptographic operation (no I/O). If the token is expired or has a bad signature,
there is no need to hit Redis. The fast path exits early; Redis is the slower
secondary check.
**Why `await getRedisClient()` instead of storing the client?** `getRedisClient()`
returns the same singleton every time — the connection is created once and reused.
The `await` is fast (no I/O after the first call).
---
### Step 4 — OPA middleware: scope enforcement
**File:** `src/middleware/opa.ts` lines 230257
```typescript
const input: OpaInput = {
method: req.method, // "POST"
path: req.baseUrl + req.path, // "/api/v1/agents"
scopes: req.user.scope.split(' '), // ["agents:read", "agents:write"]
};
if (!evaluate(input)) {
next(new AuthorizationError());
return;
}
```
For `POST /api/v1/agents`, the policy requires `["agents:write"]`. If `agents:write`
is not in the token's scope, the request is rejected with 403 before the controller
runs.
**Why reconstruct the full path with `req.baseUrl + req.path`?** The OPA policy
uses full paths (`/api/v1/agents/:id`). Inside a nested router, `req.path` is
relative to the router's mount point (e.g. `/`). `req.baseUrl` is the mount prefix
(`/api/v1/agents`). Concatenating them gives the full path the policy expects.
---
### Step 5 — Controller: validation
**File:** `src/controllers/AgentController.ts` lines 3760
```typescript
registerAgent = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.user) { throw new AuthorizationError() }
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);
```
**Why check `req.user` in the controller when `authMiddleware` already set it?**
TypeScript's type system marks `req.user` as `ITokenPayload | undefined`. The check
at line 39 narrows the type so subsequent code can use `req.user` without null
assertions. It is a guard, not redundant authentication.
**Why pass `ipAddress` and `userAgent` to the service?** The service logs audit events.
Audit events include the client IP and User-Agent for forensic value. These values
come from the HTTP request, which the service has no access to — so the controller
extracts them and passes them down.
---
### Step 6 — Service: free-tier limit check
**File:** `src/services/AgentService.ts` lines 5965
```typescript
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.', ...);
}
```
**Why count before checking email uniqueness?** If the limit is reached, there is
no point checking whether the email already exists. Doing the cheaper check (count)
first avoids an unnecessary query.
---
### Step 7 — Service: email uniqueness check
**File:** `src/services/AgentService.ts` lines 6871
```typescript
const existing = await this.agentRepository.findByEmail(data.email);
if (existing !== null) { throw new AgentAlreadyExistsError(data.email) }
```
**Why not rely on the database UNIQUE constraint?** We could, but catching a
PostgreSQL `23505` error code in the repository would be less readable and would
not produce a typed `AgentAlreadyExistsError` with a structured `details` field.
The explicit check gives better error messages and keeps the repository layer clean.
---
### Step 8 — Repository: INSERT
**File:** `src/repositories/AgentRepository.ts` lines 6785
```typescript
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]);
}
```
**Why generate `agentId` in application code instead of relying on `gen_random_uuid()`?**
Because we use the UUID as the OAuth 2.0 `client_id`. We need the UUID before writing
to the database so we can use it in the audit event and the response. Having it in
application code avoids a separate SELECT after the INSERT.
**Why `RETURNING *`?** PostgreSQL's `RETURNING` clause sends back the inserted row
in the same round trip as the INSERT. This avoids a second SELECT to fetch the
newly created record.
---
### Step 9 — Service: audit event
**File:** `src/services/AgentService.ts` lines 7683
```typescript
await this.auditService.logEvent(
agent.agentId,
'agent.created',
'success',
ipAddress,
userAgent,
{ agentType: agent.agentType, owner: agent.owner },
);
```
**Why `await` here but `void` for token audit events?** Agent registration is a
database write operation that happens once. Adding ~5ms for the audit write is
acceptable and ensures the audit event is recorded before the 201 response is sent.
Token issuance happens far more frequently — audit is fire-and-forget there.
---
### Step 10 — Response
**File:** `src/controllers/AgentController.ts` line 56
```typescript
res.status(201).json(agent);
```
Returns the full `IAgent` object with HTTP 201 Created.
---
## Walkthrough 3 — Credential Rotation
**Request:** `POST /api/v1/agents/:agentId/credentials/:credentialId/rotate`
Credential rotation is the process of replacing an existing client secret with a
new one without changing the `credentialId`. This is the recommended security
practice — rotate periodically and rotate immediately after suspected compromise.
---
### Step 1 — Route dispatch
**File:** `src/routes/credentials.ts` line 34
```typescript
router.post('/:credentialId/rotate', asyncHandler(credentialController.rotateCredential.bind(credentialController)));
```
The credentials router is mounted at `/api/v1/agents/:agentId/credentials` in `app.ts`.
The full path becomes `POST /api/v1/agents/:agentId/credentials/:credentialId/rotate`.
---
### Step 2 — Auth middleware
Same as Walkthrough 2, Step 3. Bearer token is verified via RS256 and Redis revocation check.
`req.user` is populated with the JWT payload.
---
### Step 3 — OPA middleware
The path `/api/v1/agents/:agentId/credentials/:credId/rotate` is normalised to
`/api/v1/agents/:id/credentials/:credId/rotate`. The policy requires `["agents:write"]`.
---
### Step 4 — Controller: ownership check
**File:** `src/controllers/CredentialController.ts` lines 127137
```typescript
rotateCredential = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
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.');
}
```
**Why check `req.user.sub !== agentId`?** An agent's token contains its own
`agentId` as the `sub` claim. This check enforces that an agent can only manage
its own credentials. Even if an agent has `agents:write` scope, it cannot rotate
another agent's credentials. This is Phase 1 behaviour — there is no admin scope yet.
---
### Step 5 — Controller: request validation
**File:** `src/controllers/CredentialController.ts` lines 139157
```typescript
const { error, value } = generateCredentialSchema.validate(req.body ?? {}, { abortEarly: false });
// generateCredentialSchema validates optional `expiresAt` field
const data = value as IGenerateCredentialRequest;
const result = await this.credentialService.rotateCredential(agentId, credentialId, data, ipAddress, userAgent);
res.status(200).json(result);
```
**Why `req.body ?? {}`?** The rotation body is optional — an agent may rotate a
credential without an expiry date, in which case the body may be empty. Passing
`undefined` to Joi would cause a different error than passing `{}`.
---
### Step 6 — Service: existence checks
**File:** `src/services/CredentialService.ts` lines 163177
```typescript
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() ?? ...);
}
```
**Why check `existing.clientId !== agentId`?** Even though OPA restricts the agent
to its own credentials, a malicious actor could craft a request with a valid
`agentId` in the path but a `credentialId` belonging to another agent. This check
ensures that a credential is only accessible to the agent it was created for.
---
### Step 7 — Service: generate new secret and write to Vault or bcrypt
**File:** `src/services/CredentialService.ts` lines 180192
```typescript
const expiresAt = data.expiresAt !== undefined ? new Date(data.expiresAt) : null;
const plainSecret = generateClientSecret(); // sk_live_<64 hex chars>
let updated: ICredential | null;
if (this.vaultClient !== null) {
// Phase 2: overwrite the existing Vault secret (KV v2 creates a new version)
const vaultPath = await this.vaultClient.writeSecret(agentId, credentialId, plainSecret);
updated = await this.credentialRepository.updateVaultPath(credentialId, vaultPath, expiresAt);
} else {
// Phase 1: use bcrypt
const newHash = await hashSecret(plainSecret);
updated = await this.credentialRepository.updateHash(credentialId, newHash, expiresAt);
}
```
**Why does Vault rotation write to the same path?** Vault KV v2 is versioned — writing
to an existing path creates a new version without overwriting previous versions.
This preserves an audit trail in Vault itself.
**Why does the Vault path stay the same after rotation?** The `vault_path` column
stores the path, not the secret. The path is deterministic:
`{mount}/data/agentidp/agents/{agentId}/credentials/{credentialId}`. Since the
`credentialId` does not change on rotation, the path does not change either.
Only the Vault version at that path changes.
---
### Step 8 — Repository: UPDATE the credential
**File:** `src/repositories/CredentialRepository.ts` lines 180218
```typescript
// Bcrypt path (updateHash):
UPDATE credentials
SET secret_hash = $1, vault_path = NULL, expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *
// Vault path (updateVaultPath):
UPDATE credentials
SET vault_path = $1, secret_hash = '', expires_at = $2, status = 'active', revoked_at = NULL
WHERE credential_id = $3
RETURNING *
```
**Why `status = 'active'` in the UPDATE?** A credential could theoretically be
in any state when rotated. The UPDATE explicitly sets it to active. This handles
edge cases where a revoked credential is being "un-revoked" by rotation (though
the service layer prevents this — revoked credentials throw `CredentialAlreadyRevokedError`).
The belt-and-suspenders approach at the SQL layer ensures data integrity.
---
### Step 9 — Service: audit event
**File:** `src/services/CredentialService.ts` lines 199206
```typescript
await this.auditService.logEvent(
agentId,
'credential.rotated',
'success',
ipAddress,
userAgent,
{ credentialId },
);
```
The audit event records which credential was rotated. Combined with the timestamp,
this gives a complete rotation history for each credential.
---
### Step 10 — Response
**File:** `src/controllers/CredentialController.ts` line 161
```typescript
res.status(200).json(result);
```
Returns `ICredentialWithSecret` — the updated credential including the new
`clientSecret`. This is the only time the new secret is ever returned. The caller
must store it securely.
```json
{
"credentialId": "d4e5f6a7-...",
"clientId": "a1b2c3d4-...",
"status": "active",
"clientSecret": "sk_live_4f8a2e9b...",
"createdAt": "2026-01-15T10:00:00Z",
"expiresAt": "2027-01-15T10:00:00Z",
"revokedAt": null
}
```
---
## Walkthrough 4 — A2A Delegation End-to-End
**Request:** `POST /api/v1/oauth2/token/delegate` — one AI agent delegating a scoped capability to another
This walkthrough traces how agent A (an orchestrator) issues a delegation token that grants agent B (a sub-agent) the right to act on its behalf with a restricted scope.
---
### Step 1 — Route dispatch
**File:** `src/routes/delegation.ts`
```typescript
router.post(
'/token/delegate',
asyncHandler(authMiddleware),
opaMiddleware,
asyncHandler(delegationController.createDelegation.bind(delegationController))
);
```
Both `authMiddleware` and `opaMiddleware` run. The OPA policy requires scope `agents:write` for delegation creation.
---
### Step 2 — Controller: extract delegator and validate
**File:** `src/controllers/DelegationController.ts`
```typescript
const delegatorId = req.user.sub; // From the Bearer token's sub claim
const { delegatee_id, scope, expires_at } = req.body;
```
The controller validates that `delegatee_id` is a non-empty UUID, `scope` is a non-empty string, and `expires_at` (if provided) is a valid ISO 8601 datetime in the future. It passes these to `DelegationService.createDelegation()`.
---
### Step 3 — Service: verify both agents exist
**File:** `src/services/DelegationService.ts`
```typescript
const delegator = await this.agentRepository.findById(delegatorId);
if (!delegator || delegator.status !== 'active') { throw new AgentNotFoundError(delegatorId) }
const delegatee = await this.agentRepository.findById(delegateeId);
if (!delegatee || delegatee.status !== 'active') { throw new AgentNotFoundError(delegateeId) }
```
Both agents must exist and be in `active` status. A suspended or decommissioned agent cannot participate in delegation.
---
### Step 4 — Service: insert delegation chain record
**File:** `src/services/DelegationService.ts`
```typescript
await this.pool.query(
`INSERT INTO delegation_chains (chain_id, delegator_id, delegatee_id, scope, status, expires_at)
VALUES ($1, $2, $3, $4, 'active', $5)`,
[chainId, delegatorId, delegateeId, scope, expiresAt]
);
```
The `chain_id` is a UUID generated by the service. The `delegation_chains` table provides the authoritative source of truth for which delegations are active, independent of any token.
---
### Step 5 — Response
```json
{
"chain_id": "f1e2d3c4-...",
"token": "eyJhbGciOiJSUzI1NiJ9...",
"delegator_id": "a1b2c3d4-...",
"delegatee_id": "b2c3d4e5-...",
"scope": "agents:read",
"status": "active",
"expires_at": "2026-04-05T00:00:00Z"
}
```
The `token` field is the signed delegation JWT. The delegatee presents this token to `POST /api/v1/oauth2/token/verify-delegation` to prove it has authority to act on the delegator's behalf.
**Why store both the DB record and the JWT?** The DB record allows revocation — when the delegator calls `DELETE /api/v1/delegation-chains/:chainId`, the record is soft-deleted and all subsequent `verify-delegation` calls will fail even if the JWT itself has not yet expired.
---
## Walkthrough 5 — Tier Enforcement Request Lifecycle
**Request:** Any authenticated API request when the organisation's daily call limit is reached
This walkthrough traces how `tierMiddleware` intercepts a request before it reaches the OPA middleware, preventing quota-exceeded traffic from consuming service resources.
---
### Step 1 — Auth middleware passes
Same as Walkthrough 2, Step 3. The Bearer JWT is verified and `req.user` is populated with `sub` (agentId) and `organization_id`.
---
### Step 2 — Tier middleware: fetch org tier
**File:** `src/middleware/tier.ts`
```typescript
const orgId = req.user.organization_id;
const tier = await tierService.fetchTier(orgId);
const config = TIER_CONFIG[tier];
```
`fetchTier()` issues `SELECT tier FROM organizations WHERE organization_id = $1`. Returns `'free'` if no row is found (safe default).
---
### Step 3 — Tier middleware: read daily counter
**File:** `src/middleware/tier.ts`
```typescript
const callsKey = `rate:tier:calls:${orgId}`;
const callsToday = await redis.get(callsKey);
const count = callsToday !== null ? parseInt(callsToday, 10) : 0;
if (count >= config.maxCallsPerDay) {
throw new TierLimitError('calls', config.maxCallsPerDay, { orgId, tier, current: count });
}
```
The Redis key `rate:tier:calls:<orgId>` is read. If null (first call of the day), count is 0. When count equals or exceeds the tier limit, `TierLimitError` (HTTP 429) is thrown immediately — no further middleware runs.
---
### Step 4 — Tier middleware: increment counter (fire-and-forget)
**File:** `src/middleware/tier.ts`
```typescript
// Set TTL to next UTC midnight if key is new
void redis.multi()
.incr(callsKey)
.expireAt(callsKey, nextUtcMidnightUnix())
.exec();
next();
```
The counter is incremented atomically using a Redis MULTI block. The `EXPIREAT` command sets the key to auto-delete at the next UTC midnight, resetting the daily counter without any scheduled job. The increment is fire-and-forget — the request proceeds immediately to `opaMiddleware`.
**Why expire at UTC midnight rather than a rolling 24-hour window?** Tier limits are documented as "per day", which users interpret as resetting at midnight. A rolling window would allow a user to consume their full daily quota twice within a 48-hour period straddling midnight, which is counterintuitive. UTC midnight is predictable and easy to reason about.
---
### Step 5 — Error handler serialises TierLimitError
**File:** `src/middleware/errorHandler.ts`
```json
HTTP 429
{
"code": "TIER_LIMIT_EXCEEDED",
"message": "Daily API call limit reached for your tier.",
"details": {
"tier": "free",
"limit": 1000,
"current": 1000
}
}
```
The `Retry-After` header is set to the number of seconds until next UTC midnight so clients can implement automatic backoff.
---
## Walkthrough 6 — Analytics Event Capture Flow
**Trigger:** Any successful token issuance (`POST /api/v1/token`)
This walkthrough traces how an analytics event is captured without affecting the latency of the primary token issuance response.
---
### Step 1 — Token issuance completes
**File:** `src/services/OAuth2Service.ts`
```typescript
const accessToken = signToken(payload, this.privateKey);
// Primary response is ready — analytics is now fire-and-forget
void this.analyticsService.recordEvent(tenantId, 'token_issued');
tokensIssuedTotal.inc({ scope });
```
The `signToken()` call completes synchronously (RSA signing is CPU-bound, not I/O). The controller can now send the response. `analyticsService.recordEvent()` is called with `void` — the `await` is deliberately omitted.
**Why `void` instead of `await`?** Token issuance latency must remain below 100ms (per the QA performance gate). A PostgreSQL write adds 515ms. Since analytics data is aggregated (not transactional), losing an occasional event due to an error is acceptable. The response is never delayed for analytics.
---
### Step 2 — AnalyticsService: UPSERT daily counter
**File:** `src/services/AnalyticsService.ts`
```typescript
async recordEvent(tenantId: string, metricType: string): Promise<void> {
try {
await this.pool.query(
`INSERT INTO analytics_events (organization_id, date, metric_type, count)
VALUES ($1, CURRENT_DATE, $2, 1)
ON CONFLICT (organization_id, date, metric_type)
DO UPDATE SET count = analytics_events.count + 1`,
[tenantId, metricType],
);
} catch (err) {
console.error('[AnalyticsService] recordEvent failed — primary path unaffected', err);
}
}
```
The `ON CONFLICT DO UPDATE` upsert is atomic. Whether this is the first or the ten-thousandth `token_issued` event for this tenant today, the row is updated correctly. All errors are caught and swallowed — the token has already been returned to the caller.
**Why one row per day per metric, not one row per event?** Storing a row per event would create millions of rows. The daily aggregate model keeps the table compact while still providing daily trend data (the granularity that analytics dashboards need). Sub-day granularity is available from the Prometheus `agentidp_tokens_issued_total` counter if needed.
---
### Step 3 — Dashboard query (deferred)
When a developer visits the analytics page in the developer portal, the portal calls:
```
GET /api/v1/analytics/token-trend?days=30
```
**File:** `src/services/AnalyticsService.ts``getTokenTrend(tenantId, 30)`
```sql
SELECT
gs.date::DATE::TEXT AS date,
COALESCE(ae.count, 0)::INTEGER AS count
FROM generate_series(
CURRENT_DATE - 29 * INTERVAL '1 day',
CURRENT_DATE,
INTERVAL '1 day'
) AS gs(date)
LEFT JOIN analytics_events ae
ON ae.date = gs.date::DATE
AND ae.organization_id = $2
AND ae.metric_type = 'token_issued'
ORDER BY gs.date ASC
```
The `generate_series` + `LEFT JOIN` pattern ensures all 30 days appear in the result, with `count: 0` for days with no events. This avoids the need for the client to fill in gaps.

View File

@@ -0,0 +1,404 @@
# 07 — Development Environment Setup
This guide takes you from a fresh machine to a running AgentIdP server with a
passing smoke test. Estimated time: 1520 minutes.
---
## 8.1 Prerequisites
Install all of these before proceeding.
| Prerequisite | Minimum version | Install link |
|-------------|----------------|--------------|
| Node.js | 18.x LTS | https://nodejs.org/en/download — use the LTS version |
| npm | 9.x (ships with Node.js 18) | Included with Node.js |
| Docker Desktop | Latest stable | https://docs.docker.com/get-docker/ |
| Git | 2.x | https://git-scm.com/downloads |
**Verify your versions:**
```bash
node --version # Should print v18.x.x or higher
npm --version # Should print 9.x or higher
docker --version # Should print Docker version 24.x or higher
git --version # Should print git version 2.x
```
---
## 8.2 Clone and Install
```bash
# Clone the repository
git clone https://github.com/sentryagent-ai/sentryagent-idp.git
cd sentryagent-idp
# Install Node.js dependencies
npm install
```
This installs all production dependencies (Express, pg, Redis, etc.) and
development dependencies (TypeScript, Jest, ts-jest, eslint).
---
## 8.3 Environment Variables Setup
The server requires a `.env` file at the project root. There is no `.env.example`
file — create it from scratch using the template below.
```bash
touch .env
```
Add the following content to `.env`. Every variable is documented below.
```bash
# ─────────────────────────────────────────────────────────────
# PostgreSQL connection
# ─────────────────────────────────────────────────────────────
DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp
# ─────────────────────────────────────────────────────────────
# Redis connection
# ─────────────────────────────────────────────────────────────
REDIS_URL=redis://localhost:6379
# ─────────────────────────────────────────────────────────────
# HTTP server port
# ─────────────────────────────────────────────────────────────
PORT=3000
# ─────────────────────────────────────────────────────────────
# JWT RSA keys (generate these below)
# ─────────────────────────────────────────────────────────────
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
# ─────────────────────────────────────────────────────────────
# CORS (optional — defaults to '*' which allows all origins)
# ─────────────────────────────────────────────────────────────
CORS_ORIGIN=*
# ─────────────────────────────────────────────────────────────
# Node environment
# ─────────────────────────────────────────────────────────────
NODE_ENV=development
# ─────────────────────────────────────────────────────────────
# OPA policy directory (optional — defaults to ./policies)
# ─────────────────────────────────────────────────────────────
# POLICY_DIR=/path/to/policies
# ─────────────────────────────────────────────────────────────
# HashiCorp Vault (optional — omit to use bcrypt mode)
# ─────────────────────────────────────────────────────────────
# VAULT_ADDR=http://127.0.0.1:8200
# VAULT_TOKEN=your-vault-token
# VAULT_MOUNT=secret
```
**Complete environment variable reference:**
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `DATABASE_URL` | Yes | — | PostgreSQL connection string. Format: `postgresql://user:password@host:port/dbname` |
| `REDIS_URL` | Yes | — | Redis connection URL. Format: `redis://host:port[/db]` |
| `PORT` | No | `3000` | TCP port the HTTP server listens on |
| `JWT_PRIVATE_KEY` | Yes | — | PEM-encoded RSA private key (2048-bit minimum) for signing tokens |
| `JWT_PUBLIC_KEY` | Yes | — | PEM-encoded RSA public key (matching the private key above) for verifying tokens |
| `CORS_ORIGIN` | No | `*` | CORS allowed origin. Use `*` for development, set to your dashboard domain in production |
| `NODE_ENV` | No | `development` | Set to `test` to suppress Morgan HTTP logging during tests |
| `POLICY_DIR` | No | `./policies` | Absolute path to the directory containing `authz.wasm` or `data/scopes.json` |
| `VAULT_ADDR` | No | — | HashiCorp Vault server address (e.g. `http://127.0.0.1:8200`). When omitted, bcrypt mode is used |
| `VAULT_TOKEN` | No | — | Vault authentication token. Required when `VAULT_ADDR` is set |
| `VAULT_MOUNT` | No | `secret` | Vault KV v2 mount path. Only used when Vault is configured |
**Generating JWT keys:**
```bash
# Generate RSA 2048-bit private key
openssl genrsa -out private.pem 2048
# Extract public key
openssl rsa -in private.pem -pubout -out public.pem
# Print private key as single-line for .env (replace newlines with \n)
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' private.pem
# Print public key as single-line for .env
awk 'NF {sub(/\r/, ""); printf "%s\\n",$0;}' public.pem
```
Paste the output (including the `-----BEGIN/END-----` lines) as the value for
`JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` in your `.env` file, surrounded by double
quotes.
---
## 8.4 Docker Compose Startup and Health Checks
Docker Compose starts PostgreSQL 14 and Redis 7. The application reads the
`DATABASE_URL` and `REDIS_URL` from your `.env` file to connect to them.
```bash
# Start PostgreSQL and Redis in the background
docker compose up postgres redis -d
# Wait for health checks to pass (usually 10-15 seconds)
docker compose ps
```
Expected output when both services are healthy:
```
NAME STATUS PORTS
sentryagent-idp-postgres-1 Up (healthy) 0.0.0.0:5432->5432/tcp
sentryagent-idp-redis-1 Up (healthy) 0.0.0.0:6379->6379/tcp
```
**Manual health check:**
```bash
# Test PostgreSQL connection
docker exec sentryagent-idp-postgres-1 pg_isready -U sentryagent -d sentryagent_idp
# Expected: /var/run/postgresql:5432 - accepting connections
# Test Redis connection
docker exec sentryagent-idp-redis-1 redis-cli ping
# Expected: PONG
```
---
## 8.5 Database Migrations
Run the migration script to create all required tables:
```bash
npm run db:migrate
```
Expected output:
```
Running database migrations...
✓ Applied: 001_create_agents.sql
✓ Applied: 002_create_credentials.sql
✓ Applied: 003_create_audit_events.sql
✓ Applied: 004_create_tokens.sql
✓ Applied: 005_add_vault_path.sql
Migrations complete. 5 migration(s) applied.
```
Running `npm run db:migrate` a second time is safe — it skips already-applied migrations:
```
- Skipped (already applied): 001_create_agents.sql
...
Migrations complete. 0 migration(s) applied.
```
**Migration internals:**
The migration runner (`scripts/migrate.ts`) reads `.sql` files from `src/db/migrations/`
in alphabetical order, wraps each in a transaction, and records the filename in the
`schema_migrations` table. If a migration fails, the transaction rolls back and
the runner exits with code 1.
---
## 8.6 Start the Server
```bash
npm run dev
# Expected: SentryAgent.ai AgentIdP listening on port 3000
```
`npm run dev` uses `ts-node` to execute `src/server.ts` directly without compiling.
This is faster for development. For a production-style start, compile first:
```bash
npm run build
npm start
```
---
## 8.7 Smoke Test
Verify the server is working with these three curl commands.
**1. Health check:**
```bash
curl http://localhost:3000/health
```
Expected response (200 OK):
```json
{
"status": "healthy",
"checks": {
"database": "healthy",
"redis": "healthy"
}
}
```
**2. Register an agent:**
First, you need a token to authenticate. But to get a token, you need credentials.
And to get credentials, you need an agent. The chicken-and-egg is resolved by the
fact that agent registration requires an `agents:write` scoped token — which means
you need to bootstrap the first agent another way.
For local development, temporarily test without auth by using the `/api/v1` prefix
directly (the server accepts requests; OPA will enforce scope).
The easiest approach: generate a test token programmatically:
```bash
# Generate test keys
openssl genrsa -out /tmp/test_private.pem 2048 2>/dev/null
openssl rsa -in /tmp/test_private.pem -pubout -out /tmp/test_public.pem 2>/dev/null
# Set them in your environment temporarily
export JWT_PRIVATE_KEY="$(cat /tmp/test_private.pem)"
export JWT_PUBLIC_KEY="$(cat /tmp/test_public.pem)"
# Start server with these keys and use a tool or short Node.js script to mint a test token
```
**3. Token endpoint with seeded credentials:**
Once you have an agent with credentials (e.g. created via the API or seeded in
development), issue a token:
```bash
curl -X POST http://localhost:3000/api/v1/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_AGENT_ID" \
-d "client_secret=sk_live_YOUR_SECRET" \
-d "scope=agents:read agents:write"
```
Expected response (200 OK):
```json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "agents:read agents:write"
}
```
---
## 8.8 Troubleshooting
### Error: `connection refused` on PostgreSQL or Redis
**Cause:** Docker services not running or not yet healthy.
**Fix:**
```bash
docker compose ps # Check status
docker compose up postgres redis -d # Start if not running
docker compose logs postgres # Check for startup errors
```
### Error: `DATABASE_URL environment variable is required`
**Cause:** `.env` file missing or not being loaded.
**Fix:** Ensure `.env` exists at the project root. `npm run dev` loads it via `dotenv.config()` in `src/server.ts`.
### Error: `JWT_PRIVATE_KEY and JWT_PUBLIC_KEY environment variables are required`
**Cause:** JWT keys not in `.env`, or newlines in the PEM keys are not properly escaped.
**Fix:** Ensure the keys are wrapped in double quotes and newlines are represented as `\n`. Use the `awk` command from section 8.3 to format them correctly.
### Error: `Cannot find module 'ts-node'`
**Cause:** `npm install` was not run, or ran against a different Node.js version.
**Fix:**
```bash
node --version # Confirm Node.js 18+
rm -rf node_modules package-lock.json
npm install
```
### Error: `Cannot connect to Redis` (during migration or server start)
**Cause:** Redis container not running or `REDIS_URL` is incorrect.
**Fix:**
```bash
docker exec sentryagent-idp-redis-1 redis-cli ping
# If container is not running:
docker compose up redis -d
```
### Port 3000 already in use
**Cause:** Another process is listening on port 3000.
**Fix:**
```bash
lsof -i :3000 # Find the process
kill <PID> # Kill it
# Or: set PORT=3001 in .env and restart
```
---
## 8.9 Running Tests Locally
```bash
# Run all tests (unit + integration)
npm test
# Run tests with coverage report
npm run test:unit -- --coverage
# Coverage report: coverage/lcov-report/index.html
# Run only unit tests
npm run test:unit
# Run only integration tests (requires running PostgreSQL and Redis)
npm run test:integration
# Run a single test file
npx jest tests/unit/services/AgentService.test.ts
# Run tests matching a pattern
npx jest --testNamePattern="registerAgent"
# Watch mode (re-runs on file changes)
npx jest --watch
```
**Integration test requirements:**
Integration tests connect to real PostgreSQL and Redis. Set these environment
variables before running integration tests:
```bash
TEST_DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test
TEST_REDIS_URL=redis://localhost:6379/1
```
The integration tests create their own tables (using `CREATE TABLE IF NOT EXISTS`)
and clean up after themselves with `DELETE FROM` statements in `afterAll`.
---
## 8.10 Web Dashboard Local Development
The web dashboard is a separate Vite project in the `dashboard/` directory.
```bash
# From the project root
cd dashboard
npm install
# Start the Vite development server
npm run dev
# Dashboard available at http://localhost:5173
```
The Vite dev server proxies all `/api/*` requests to `http://localhost:3000`,
so the API server must be running concurrently (in a separate terminal).
**Build for production:**
```bash
cd dashboard
npm run build
# Output: dashboard/dist/ (served by Express at /dashboard)
```
After building, the Express server serves the built dashboard at
`http://localhost:3000/dashboard`. You do not need to run the Vite dev server
for this — the static files are served directly by Express.

View File

@@ -0,0 +1,372 @@
# 08 — Engineering Workflow
---
## 9.1 OpenSpec Spec-First Workflow
Every feature in this codebase was designed before it was implemented.
The OpenSpec workflow enforces this order without exception.
### The Full Sequence
```
1. CEO identifies a feature or change
└── Documents it in the backlog
2. CEO approves the feature for the current sprint
└── Creates an OpenSpec change document
3. Virtual Architect designs the API
└── Writes the OpenAPI 3.0 spec BEFORE any implementation
└── Produces spec file in docs/openapi/ or openspec/changes/<name>/specs/
└── Every endpoint in the spec must have:
- Summary and description
- Request body schema (with all validation rules)
- All response schemas (every status code)
- Error response schemas
- Authentication requirements
- Example requests and responses
4. Virtual CTO reviews the spec
└── Checks DRY, SOLID, AGNTCY compliance, completeness
└── Either approves or returns with corrections
5. CEO approves the spec
└── Only after CTO approval
└── Scope changes require re-running from step 1
6. Virtual Principal Developer implements the spec
└── Implementation must match the spec exactly
└── TypeScript strict mode, DRY, SOLID, JSDoc on all public methods
└── Zero any types
└── All errors typed and handled
7. Virtual QA Engineer writes and runs tests
└── Unit tests: >80% coverage on all services
└── Integration tests: every endpoint in the spec tested
└── Edge cases: null, empty, invalid inputs
└── Performance: token endpoints <100ms, all others <200ms
└── Verifies spec matches implementation exactly
8. Virtual CTO reviews the implementation and QA report
└── If quality gates not met: returns to step 6 or 7
└── If approved: notifies CEO
9. CEO approves → code is merged to develop
└── No code ever goes to main without CEO awareness
```
### Rule: No Code Without a Spec
If you find yourself implementing something without an approved OpenAPI spec,
stop. Write the spec first, get it reviewed, then implement. This is not
bureaucracy — it is how you avoid building the wrong thing.
---
## 9.2 OpenSpec CLI Commands Reference
OpenSpec is the change management workflow built into this project. Changes
are tracked in `openspec/changes/`.
```bash
# Create a new change (starts the design process)
openspec new change <name>
# Creates: openspec/changes/<name>/proposal.md, design.md, specs/, tasks.md
# Check the status of all active changes
openspec status
# Shows: change name, phase (design/implementation/review), completion %
# List all active changes
openspec list
# Get implementation instructions for a change
openspec instructions <name>
# Outputs the tasks.md formatted for implementation
# Archive a completed change
openspec archive <name>
# Moves: openspec/changes/<name>/ → openspec/archive/<name>/
```
### Change Lifecycle
```
openspec/changes/<name>/
├── proposal.md — Business case and feature description (CEO-authored)
├── design.md — Technical design decisions (Architect-authored)
├── specs/ — OpenAPI specs and interface contracts
│ └── *.md or *.yaml
└── tasks.md — Implementation tasks checklist (checked off as work completes)
```
---
## 9.3 Branching Strategy
### Branch naming
```
feature/<short-description> — New features (from develop)
fix/<short-description> — Bug fixes (from develop)
docs/<short-description> — Documentation changes only (from develop)
```
### Workflow
```
main — Production. Only CTO-approved, CEO-aware merges.
└── develop — Integration branch. All feature branches merge here.
└── feature/my-feature — Your work branch
```
1. Create your branch from `develop`:
```bash
git checkout develop
git pull origin develop
git checkout -b feature/my-feature
```
2. Work on your branch, committing as you go.
3. When ready, push and open a PR targeting `develop`:
```bash
git push -u origin feature/my-feature
gh pr create --base develop --title "feat: my feature" --body "..."
```
4. Virtual QA reviews the PR (all quality gates must pass).
5. Virtual CTO approves the PR.
6. Merge to `develop`.
7. `develop` → `main` requires an explicit CEO decision.
**Rule:** Never push directly to `main` or `develop`. Always work through a PR.
---
## 9.4 TypeScript and Code Standards
### Strict Mode
All compiler strictness flags are enabled in `tsconfig.json`. These are
non-negotiable:
```json
{
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true
}
```
**Consequence of violation:** The TypeScript compiler (`npm run build`) will fail.
PRs that cause build failures are rejected automatically.
### No `any` Types
Never use `any`. If a third-party library returns `unknown` or `any`, cast it to
a specific interface you define:
```typescript
// BAD
const result: any = await vault.read(path);
const secret = result.data.data.clientSecret;
// GOOD
interface KvV2ReadResponse {
data: { data: Record<string, string>; metadata: { version: number; } };
}
const result = (await vault.read(path)) as KvV2ReadResponse;
const secret = result.data.data.clientSecret;
```
### DRY (Don't Repeat Yourself)
Zero code duplication. See `04-codebase-structure.md` section 5.5 for the complete
mapping of what lives where. Before writing a utility function, check whether
it already exists in `src/utils/`.
### SOLID Principles
Each service has a single, clear responsibility. If you find yourself adding a
method to `AgentService` that queries the `audit_events` table, stop — that
belongs in `AuditService`. If you find yourself adding SQL to a controller, stop —
that belongs in a repository.
### JSDoc on All Public Methods
Every public class, method, and interface must have a JSDoc comment that includes:
- `@param` for every parameter
- `@returns` describing the return value
- `@throws` for every error that can be thrown
```typescript
/**
* Registers a new AI agent identity.
*
* @param data - Agent registration request 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>
```
### Error Handling
Always throw a typed error from the `SentryAgentError` hierarchy. Never throw
raw `Error` objects with string messages in service or controller code:
```typescript
// BAD
throw new Error('Agent not found');
// GOOD
throw new AgentNotFoundError(agentId);
```
The `errorHandler` middleware maps `SentryAgentError` subclasses to HTTP status
codes automatically. Adding a new error type only requires adding a class in
`src/utils/errors.ts`.
---
## 9.5 PR Checklist
Every pull request must pass all items before it can be merged.
```
Code quality:
- [ ] TypeScript builds without errors: npm run build
- [ ] No any types introduced
- [ ] All new public methods have JSDoc
- [ ] ESLint passes: npm run lint
- [ ] No code duplication — logic extracted to utils/services
Testing:
- [ ] Unit tests added for all new service methods
- [ ] Integration tests added for all new API endpoints
- [ ] Coverage threshold maintained: npm run test:unit -- --coverage
(>80% statements, branches, functions, lines)
- [ ] Integration tests pass against real PostgreSQL and Redis
Spec compliance:
- [ ] Implementation matches the approved OpenAPI spec exactly
- [ ] If the spec needs updating, the spec was updated BEFORE the code was changed
Documentation:
- [ ] docs/engineering/ updated if the change affects service interfaces or workflows
- [ ] CHANGELOG.md updated with a summary of the change
- [ ] Any new environment variables documented in docs/engineering/07-dev-setup.md
Database:
- [ ] If a new table or column is added, a migration file exists in src/db/migrations/
- [ ] Migration file is numbered correctly and tested locally
Review:
- [ ] Virtual CTO reviewed the implementation
- [ ] Virtual QA signed off on tests
```
---
## 9.6 Virtual Engineering Team Roles for Contributors
External contributors operate within the same team structure as the internal
Virtual Engineering Team. Here is how to interact with each role:
**Virtual CTO (architecture gate)**
Opens architectural discussions by filing a GitHub issue labeled `architecture`.
The Virtual CTO must approve any change to:
- The layered architecture (adding a direct DB call in a controller)
- The error hierarchy
- The authentication or authorisation flow
- Any new dependency (package)
- Multi-region deployment topology
**Virtual Architect (spec owner)**
All API changes require an updated OpenAPI spec. If you are adding an endpoint,
file an issue labeled `spec-change` with your proposed additions before writing
any code. The Architect will review and approve the spec.
**Virtual Principal Developer (code reviewer)**
All implementation PRs are reviewed for TypeScript compliance, DRY violations,
SOLID violations, JSDoc completeness, and correctness against the spec.
**Virtual QA Engineer (quality gate)**
All PRs require >80% coverage and passing integration tests. The QA Engineer
will review test completeness and flag edge cases that need coverage.
---
## 9.7 Commit Message Conventions
This project uses **Conventional Commits** (https://www.conventionalcommits.org).
### Format
```
<type>(<optional scope>): <short description>
<optional body — why this change was made>
<optional footer — breaking changes, issue references>
```
### Types
| Type | When to use | Example |
|------|-------------|---------|
| `feat` | A new feature | `feat(agents): add agent status filter to list endpoint` |
| `fix` | A bug fix | `fix(auth): handle TokenExpiredError separately from JsonWebTokenError` |
| `docs` | Documentation only | `docs(engineering): add credential rotation walkthrough` |
| `test` | Adding or updating tests | `test(oauth2): add monthly token limit integration test` |
| `chore` | Build, tooling, dependencies | `chore: update jest to 29.7.0` |
| `refactor` | Code change with no behaviour change | `refactor(credential): extract secret storage to VaultClient` |
| `perf` | Performance improvement | `perf(token): make audit log write fire-and-forget` |
### Rules
- Keep the description under 72 characters
- Use the imperative mood: "add" not "added" or "adds"
- Include the scope in parentheses when the change is limited to one area
- Reference issues in the footer: `Closes #123`
### Examples
```
feat(vault): add optional HashiCorp Vault credential backend
Adds VaultClient wrapping node-vault for KV v2 operations.
When VAULT_ADDR and VAULT_TOKEN are set, new credentials are
stored in Vault instead of as bcrypt hashes in PostgreSQL.
Backwards compatible: existing bcrypt credentials continue to work.
Closes #45
```
```
fix(opa): normalise /token/revoke path before OPA lookup
The path /api/v1/token/revoke was not in the normalisation
switch, causing OPA to deny all revocation requests even with
the correct scope.
```
```
docs: add engineering knowledge base for new hires
Adds docs/engineering/ with 11 documents covering architecture,
service deep-dives, code walkthroughs, dev setup, workflow,
testing, deployment, and SDK guide.
```

View File

@@ -0,0 +1,586 @@
# 09 — Testing Strategy
---
## 10.1 Test Types and Purposes
This codebase uses two types of tests. Understanding when to use each prevents
you from writing integration tests for things that should be unit tests (slow)
and unit tests for things that need a real database (misleading).
### Unit Tests
**Location:** `tests/unit/`
**What they test:** A single class or function in complete isolation. All
dependencies (repositories, services, external clients) are replaced with Jest mocks.
**When to use:**
- Testing service business logic (free-tier limits, status transitions, error cases)
- Testing utility functions (crypto, jwt, validators)
- Testing error hierarchy behaviour
- Any code that has conditional logic you want to test exhaustively
**What they do NOT test:**
- Whether the SQL queries are correct
- Whether the HTTP routing works
- Whether middleware chains execute in the right order
**Speed:** Milliseconds. Hundreds of unit tests should complete in under 10 seconds.
### Integration Tests
**Location:** `tests/integration/`
**What they test:** A full HTTP request through the Express application against
a real PostgreSQL database and real Redis instance.
**When to use:**
- Testing that a route is correctly wired to the right controller method
- Testing authentication and authorisation middleware in combination
- Testing database operations end-to-end (INSERT → read back → verify)
- Testing response shapes match the OpenAPI spec exactly
**What they require:**
- Running PostgreSQL (at `TEST_DATABASE_URL` or default)
- Running Redis (at `TEST_REDIS_URL` or default)
- The test creates its own tables and cleans up after every test case
**Speed:** Seconds. Expect 25 seconds per integration test file.
---
## 10.2 Test Framework Stack
| Tool | Role |
|------|------|
| **Jest 29.7** | Test runner. `describe`, `it`, `expect`, `beforeEach`, `afterAll`. Also provides mocking via `jest.mock()`, `jest.fn()`, `jest.spyOn()`. |
| **ts-jest** | Transforms TypeScript test files for Jest without a separate compilation step. Configured in `jest.config.ts`. |
| **Supertest 6.3** | HTTP testing library. Used in integration tests to make real HTTP requests against the Express app without opening a network port. Works by passing the `Application` object directly. |
**Jest configuration** (`jest.config.ts`):
```typescript
export default {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testPathPattern: ['tests/unit', 'tests/integration'],
collectCoverageFrom: ['src/**/*.ts', '!src/server.ts'],
};
```
---
## 10.3 Coverage Gates
All four coverage metrics must be above 80% before a feature is considered complete:
| Metric | Gate | What it means |
|--------|------|---------------|
| Statements | >80% | Each statement was executed at least once |
| Branches | >80% | Each `if`/`else`/`switch` branch was taken at least once |
| Functions | >80% | Each function was called at least once |
| Lines | >80% | Each line was executed at least once |
**Enforcement:**
Coverage is checked in the PR process:
```bash
npm run test:unit -- --coverage
# Fails if any metric is below 80%
```
Coverage reports are output to `coverage/lcov-report/index.html` for visual inspection.
The coverage threshold configuration is in `jest.config.ts`:
```typescript
coverageThreshold: {
global: {
statements: 80,
branches: 80,
functions: 80,
lines: 80,
},
},
```
---
## 10.4 How to Run the Test Suite
```bash
# Run all tests (unit + integration)
npm test
# Run only unit tests
npm run test:unit
# Run only integration tests
npm run test:integration
# Run unit tests with coverage report
npm run test:unit -- --coverage
# HTML report: coverage/lcov-report/index.html
# Run a single test file
npx jest tests/unit/services/AgentService.test.ts
# Run tests matching a name pattern
npx jest --testNamePattern="should throw FreeTierLimitError"
# Run tests in watch mode (re-runs on file changes)
npx jest --watch
# Run with verbose output (shows each test name)
npx jest --verbose
```
**Integration test environment variables:**
```bash
export TEST_DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test
export TEST_REDIS_URL=redis://localhost:6379/1
npm run test:integration
```
Using database index `/1` for Redis in tests prevents test runs from polluting
the main database (index `0`) used for local development.
---
## 10.5 Unit Test Writing Conventions
Unit tests follow a strict pattern. Study this example carefully — it shows every
convention in use.
**Real example from `tests/unit/services/AgentService.test.ts`:**
```typescript
/**
* 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 {
AgentAlreadyExistsError,
FreeTierLimitError,
} from '../../../src/utils/errors';
import { IAgent, ICreateAgentRequest } from '../../../src/types/index';
// Mock all dependencies — none of them execute real code
jest.mock('../../../src/repositories/AgentRepository');
jest.mock('../../../src/repositories/CredentialRepository');
jest.mock('../../../src/services/AuditService');
// Get typed mock constructors so we can call .mockResolvedValue() on them
const MockAgentRepository = AgentRepository as jest.MockedClass<typeof AgentRepository>;
const MockCredentialRepository = CredentialRepository as jest.MockedClass<typeof CredentialRepository>;
const MockAuditService = AuditService as jest.MockedClass<typeof AuditService>;
// Define a complete test fixture — reuse this instead of duplicating object literals
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'),
};
describe('AgentService', () => {
let agentService: AgentService;
let agentRepo: jest.Mocked<AgentRepository>;
let credentialRepo: jest.Mocked<CredentialRepository>;
let auditService: jest.Mocked<AuditService>;
beforeEach(() => {
// Clear all mocks before each test — prevents state leakage
jest.clearAllMocks();
// Create fresh mock instances for each test
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>;
// Inject mocks into the system under test
agentService = new AgentService(agentRepo, credentialRepo, auditService);
});
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 () => {
// Arrange — set up mock return values
agentRepo.countActive.mockResolvedValue(0);
agentRepo.findByEmail.mockResolvedValue(null);
agentRepo.create.mockResolvedValue(MOCK_AGENT);
auditService.logEvent.mockResolvedValue({} as never);
// Act — call the method under test
const result = await agentService.registerAgent(createData, '127.0.0.1', 'test/1.0');
// Assert — verify the result
expect(result).toEqual(MOCK_AGENT);
// Also verify the mock was called with the right arguments
expect(agentRepo.create).toHaveBeenCalledWith(createData);
});
it('should throw FreeTierLimitError when 100 agents already registered', async () => {
// Arrange — simulate limit reached
agentRepo.countActive.mockResolvedValue(100);
// Assert error — rejects.toThrow checks the error type
await expect(agentService.registerAgent(createData, '127.0.0.1', 'test/1.0'))
.rejects.toThrow(FreeTierLimitError);
});
it('should throw AgentAlreadyExistsError if email is already registered', async () => {
agentRepo.countActive.mockResolvedValue(0);
agentRepo.findByEmail.mockResolvedValue(MOCK_AGENT); // Simulate existing agent
await expect(agentService.registerAgent(createData, '127.0.0.1', 'test/1.0'))
.rejects.toThrow(AgentAlreadyExistsError);
});
});
});
```
### Conventions explained:
1. **One test file per source file.** `AgentService.test.ts` tests `AgentService.ts`.
2. **`jest.mock()` before any imports from the mocked module.** Jest hoists mock declarations.
3. **`jest.clearAllMocks()` in `beforeEach`.** Prevents mock call counts from leaking between tests.
4. **AAA pattern (Arrange, Act, Assert).** Every `it` block follows this order.
5. **Test both the happy path and every error case.** A service with 3 error conditions
needs at least 4 tests (1 success + 3 failures).
6. **Verify mock calls for side effects.** Use `.toHaveBeenCalledWith()` to verify that
`auditService.logEvent` was called with the right arguments, not just that it was called.
7. **Use typed error assertions.** `.rejects.toThrow(FreeTierLimitError)` verifies the
error type, not just a message string.
---
## 10.6 Integration Test Writing Conventions
Integration tests use Supertest to make real HTTP requests against a live Express app.
**Real example from `tests/integration/agents.test.ts`:**
```typescript
/**
* Integration tests for Agent Registry endpoints.
*/
import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
// Generate RSA keys for test tokens — done once per test module
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// Set environment variables BEFORE importing the app
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';
// Helper: mint a valid test token
function makeToken(sub: string = uuidv4(), scope: string = 'agents:read agents:write'): string {
return signToken({ sub, client_id: sub, scope, jti: uuidv4() }, privateKey);
}
describe('Agent Registry Integration Tests', () => {
let app: Application;
let pool: Pool;
beforeAll(async () => {
// Boot the real Express app
app = await createApp();
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
// Create test tables (idempotent)
await pool.query(`CREATE TABLE IF NOT EXISTS agents (...)`);
});
afterEach(async () => {
// Clean up after each test — order matters (foreign key constraints)
await pool.query('DELETE FROM audit_events');
await pool.query('DELETE FROM credentials');
await pool.query('DELETE FROM agents');
});
afterAll(async () => {
// Close all connections — prevents Jest from hanging
await pool.end();
await closePool();
await closeRedisClient();
});
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({
email: 'test-agent@sentryagent.ai',
agentType: 'screener',
version: '1.0.0',
capabilities: ['resume:read'],
owner: 'test-team',
deploymentEnv: 'development',
});
expect(res.status).toBe(201);
expect(res.body.agentId).toBeDefined();
expect(res.body.email).toBe('test-agent@sentryagent.ai');
expect(res.body.status).toBe('active');
});
it('should return 401 without a token', async () => {
const res = await request(app)
.post('/api/v1/agents')
.send({ email: 'test@sentryagent.ai' });
expect(res.status).toBe(401);
});
it('should return 409 for duplicate email', async () => {
const token = makeToken();
const body = { email: 'dup@sentryagent.ai', agentType: 'screener', version: '1.0', capabilities: [], owner: 'team', deploymentEnv: 'development' };
await request(app).post('/api/v1/agents').set('Authorization', `Bearer ${token}`).send(body);
const res = await request(app).post('/api/v1/agents').set('Authorization', `Bearer ${token}`).send(body);
expect(res.status).toBe(409);
expect(res.body.code).toBe('AGENT_ALREADY_EXISTS');
});
});
});
```
### Conventions explained:
1. **Set `process.env` before importing the app.** The app reads env vars at import
time (`getPool()`, JWT keys). Setting them after import does nothing.
2. **`afterEach` cleanup.** Delete all rows after each test so tests are independent.
Always delete in child-to-parent order (audit_events → credentials → agents)
to respect foreign key constraints.
3. **`afterAll` close connections.** Always close the pool and Redis client at the end
of the suite. Jest will hang if connections remain open.
4. **Test both success and failure status codes.** Every endpoint test must include
an unauthenticated request (401) and an invalid request (400).
5. **Verify response body shape.** Check `res.body.code` for error responses to
verify the correct error type, not just the status code.
6. **Use `makeToken()` for test tokens.** A helper function keeps token creation
consistent across all integration test files.
---
## 10.7 OWASP Top 10 Security Testing Reference
These are the security concerns most relevant to an identity provider. For each,
here is what AgentIdP does to mitigate the risk and how to test it.
| OWASP Category | Relevant risk | Mitigation | Test approach |
|---------------|--------------|-----------|---------------|
| **A01 Broken Access Control** | Agent A accesses agent B's credentials | `req.user.sub !== agentId` check in all credential endpoints | Test: send credential request with a token for agent A but agentId for agent B in the path — expect 403 |
| **A02 Cryptographic Failures** | Weak credential secrets or JWT algorithm | `sk_live_<64 hex>` = 256-bit entropy; RS256 signing; bcrypt 10 rounds | Test: verify generated secrets are 72 chars; verify JWT header shows `alg: RS256` |
| **A03 Injection** | SQL injection via input fields | Parameterised queries (`$1, $2, ...`) in all repositories | Test: send `'; DROP TABLE agents; --` as `owner` field — expect 400 from Joi validation |
| **A05 Security Misconfiguration** | Server leaking stack traces | `errorHandler` returns generic 500 for unknown errors | Test: trigger an unexpected error (mock a repository to throw `new Error()`) — verify response body does not contain stack trace |
| **A06 Vulnerable Components** | Outdated dependencies with CVEs | Regular `npm audit` | Run: `npm audit` in CI; fail on high/critical findings |
| **A07 Auth Failures** | Timing attack on credential verification | `crypto.timingSafeEqual` in `VaultClient.verifySecret()`; bcrypt inherently timing-safe | Test: measure multiple failed verification attempts with wrong secrets of varying lengths — timing should not increase linearly with shared prefix length |
| **A08 Integrity Failures** | Forged JWT tokens | RS256 verification rejects tokens signed with wrong key | Test: create a token signed with a different private key — expect 401 |
| **A09 Logging Failures** | Auth failures not logged | `auth.failed` audit events written for every authentication failure | Test: attempt token issuance with wrong secret — verify `auth_events` table contains `auth.failed` row |
| **A10 SSRF** | Not applicable to current API surface | No outbound HTTP from user-supplied URLs | N/A — no URL-accepting fields in current API |
**JWT algorithm confusion (bonus):**
Test that the server rejects tokens with `alg: none` or `alg: HS256`. The
`verifyToken()` function specifies `algorithms: ['RS256']`, which causes jsonwebtoken
to reject any token with a different algorithm header.
---
## 10.8 AGNTCY Conformance Test Suite
**Location:** `tests/agntcy-conformance/conformance.test.ts`
**Purpose:** Verifies that the AgentIdP platform conforms to the AGNTCY agent identity specification. These tests exercise live HTTP requests through the Express application against real PostgreSQL and Redis instances, exactly like integration tests — but they validate AGNTCY-specific protocol guarantees rather than individual endpoint correctness.
**How to run:**
```bash
# Run the conformance suite (separate Jest config)
npm run test:agntcy-conformance
# Equivalent long form
npx jest --config tests/agntcy-conformance/jest.config.cjs
# Run with TEST_DATABASE_URL and TEST_REDIS_URL overrides
TEST_DATABASE_URL=postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test \
TEST_REDIS_URL=redis://localhost:6379/1 \
npm run test:agntcy-conformance
# Enable A2A delegation conformance tests (gated by env var)
A2A_ENABLED=true npm run test:agntcy-conformance
```
The conformance suite uses its own `jest.config.cjs` (located in `tests/agntcy-conformance/`) so it does not run with `npm test` by default. This is intentional — the suite requires `COMPLIANCE_ENABLED=true` and optionally `A2A_ENABLED=true`, which should not be required for the standard unit/integration test run.
**What each test validates:**
| Conformance Test | What it validates | AGNTCY Domain |
|-----------------|-------------------|---------------|
| **Conformance 1 — Agent registration creates DID:WEB identifier** | `POST /api/v1/agents` returns a `did` field matching `did:web:*` pattern when `DID_WEB_DOMAIN` is set. The `did` field is optional in the response (test is conditional on presence) — but when present, it must conform to the `did:web:` scheme. | Non-Human Identity |
| **Conformance 2 — Token issuance via `client_credentials` grant** | Registers an agent, generates credentials via API, then exercises the full OAuth 2.0 Client Credentials flow. Validates that `POST /api/v1/token` returns a 200 response with `access_token` (string), `token_type: 'Bearer'`, and a JWT with 3 dot-separated parts. | Authentication |
| **Conformance 3 — A2A delegation chain create + verify** | _(Gated by `A2A_ENABLED=true`.)_ Creates a delegation chain between two agents via `POST /api/v1/oauth2/token/delegate`. If a token is returned, verifies it via `POST /api/v1/oauth2/token/verify-delegation`. Accepts 200 or 201 on creation and 200 or 204 on verification. | Agent-to-Agent Trust |
| **Conformance 4 — Compliance report returns valid AGNTCY structure** | Calls `GET /api/v1/compliance/report` and validates all required AGNTCY fields: `generated_at` (valid ISO 8601), `tenant_id` (string), `agntcy_schema_version: '1.0'`, `sections` (array with `name`, `status`, `details` per entry), `overall_status` (one of `pass/fail/warn`). Also verifies the `agent-identity` and `audit-trail` section names are present. A second request verifies the Redis cache (`X-Cache: HIT` header and `from_cache: true` body field). | Audit, Compliance |
**Schema tables created by conformance suite:** The suite creates its own tables using `CREATE TABLE IF NOT EXISTS` before tests run. The tables match the production schema and include: `organizations`, `agents`, `credentials`, `audit_events`, `token_revocations`, `agent_did_keys`, `delegation_chains`. These are cleaned up via `DELETE` in `afterEach` (child-to-parent order respecting FK constraints) and dropped implicitly when the test database is reset.
**Environment variables used:**
| Variable | Required | Purpose |
|---|---|---|
| `TEST_DATABASE_URL` | Yes (or default) | PostgreSQL connection string for the test database |
| `TEST_REDIS_URL` | Yes (or default) | Redis connection string (index 1 recommended) |
| `COMPLIANCE_ENABLED` | Yes (`'true'`) | Enables the compliance report endpoint |
| `A2A_ENABLED` | No (default `'true'`) | Set to `'false'` to skip Conformance 3 (A2A delegation) |
| `DID_WEB_DOMAIN` | No | When set, Conformance 1 validates the `did:web:` format |
---
## 10.9 Tier Enforcement Tests
**Location:** `tests/unit/services/TierService.test.ts` and `tests/integration/`
**The TierService has the following test cases that must all pass:**
### Unit tests (`tests/unit/services/TierService.test.ts`)
The unit tests mock PostgreSQL (`Pool`) and Redis (`RedisClientType`) and Stripe. Key scenarios:
| Test | Description |
|------|-------------|
| `getStatus() — returns correct tier and limits` | Mocks `SELECT tier FROM organizations` returning `'pro'`; mocks Redis GET calls for `rate:tier:calls` and `rate:tier:tokens`; verifies `ITierStatus.limits` matches `TIER_CONFIG['pro']`. |
| `getStatus() — falls back to 0 when Redis unavailable` | Redis GET throws; verifies `usage.callsToday = 0` and `usage.tokensToday = 0` with no error thrown. |
| `getStatus() — returns 'free' when org not found` | `SELECT` returns 0 rows; verifies `tier === 'free'`. |
| `initiateUpgrade() — throws ValidationError on downgrade attempt` | `targetTier = 'free'` when current is `'pro'`; verifies `ValidationError` is thrown with `TIER_RANK` comparison failure message. |
| `initiateUpgrade() — calls Stripe with correct metadata` | Verifies `stripe.checkout.sessions.create` is called with `metadata: { orgId, targetTier }` and `mode: 'subscription'`. |
| `applyUpgrade() — executes UPDATE organizations SET tier` | Verifies parameterized SQL is called with `[targetTier, orgId]`. |
| `enforceAgentLimit() — throws TierLimitError when limit reached` | Mock agent count equals `TIER_CONFIG[tier].maxAgents`; verifies `TierLimitError` with `limit` and `current` details. |
| `enforceAgentLimit() — no-op for Enterprise tier` | `TIER_CONFIG['enterprise'].maxAgents = Infinity`; verifies no SQL query for agent count and no error. |
| `fetchTier() — returns 'free' for unknown tier string in DB` | DB returns unrecognised string; verifies `isTierName` guard returns `'free'`. |
### Integration (middleware) tests
When writing integration tests for the tier enforcement middleware (`src/middleware/tier.ts`), the following scenarios must be covered:
| Scenario | Expected behaviour |
|----------|-------------------|
| Request with org on `free` tier, under daily call limit | Request proceeds normally (2xx from downstream handler) |
| Request that would exceed `maxCallsPerDay` for the org's tier | `429 TierLimitError` — body contains `code: 'TIER_LIMIT_EXCEEDED'` |
| Request to `/health` or `/metrics` (unprotected routes) | Tier middleware not applied — always 200 |
| Org not found in `organizations` table | Defaults to `free` tier limits |
---
## 10.10 Analytics Service Tests
**Location:** `tests/unit/services/AnalyticsService.test.ts`
The AnalyticsService unit tests mock the PostgreSQL `Pool`. Key scenarios that must be covered:
| Test | Description |
|------|-------------|
| `recordEvent() — executes UPSERT without throwing` | Verifies `pool.query` is called with the `INSERT ... ON CONFLICT DO UPDATE` SQL pattern and the correct `[tenantId, metricType]` parameters. |
| `recordEvent() — catches and swallows pool errors` | Pool `query` throws; verifies `recordEvent` resolves (not rejects) and the error does not propagate. This is the fire-and-forget contract. |
| `getTokenTrend() — clamps days to 90` | Calls with `days = 200`; verifies `pool.query` receives `clampedDays = 90` as the first parameter. |
| `getTokenTrend() — maps rows to ITokenTrendEntry[]` | Mock returns rows with `date: '2026-03-01', count: '42'`; verifies the result is `[{ date: '2026-03-01', count: 42 }]` (count coerced to number). |
| `getAgentActivity() — maps rows to IAgentActivityEntry[]` | Mock returns rows with string-typed `dow`, `hour`, `count`; verifies all are coerced to numbers in the result. |
| `getAgentUsageSummary() — maps rows to IAgentUsageSummaryEntry[]` | Mock returns rows with `token_count: '150'`; verifies `token_count: 150` (number) in the result. |
| `getAgentUsageSummary() — joins with agents table on organization_id` | Verifies the SQL query joins `agents` with `LEFT JOIN analytics_events` and filters `a.organization_id = $1`. |
**Coverage gate:** `AnalyticsService` must maintain >80% statement, branch, function, and line coverage. Run:
```bash
npm run test:unit -- --coverage --testPathPattern=AnalyticsService
```
---
## 10.11 Running the Complete Phase 6 Test Matrix
All of the following must pass before any Phase 6 feature is considered complete:
```bash
# 1. Unit tests (all services including Phase 36)
npm run test:unit -- --coverage
# Must exit 0 with all 4 coverage metrics ≥ 80%
# 2. Integration tests (requires PostgreSQL + Redis running)
npm run test:integration
# 3. AGNTCY conformance suite
COMPLIANCE_ENABLED=true \
A2A_ENABLED=true \
npm run test:agntcy-conformance
# 4. Dependency security audit
npm audit --audit-level=high
# Must exit 0 — no high or critical vulnerabilities
# 5. TypeScript compilation
npx tsc --noEmit
# Must exit 0 — zero type errors
```
**Current test file inventory** (as of Phase 6 completion):
Unit test files in `tests/unit/services/`:
| File | Service tested |
|------|---------------|
| `AgentService.test.ts` | `AgentService` |
| `AnalyticsService.test.ts` | `AnalyticsService` |
| `AuditService.test.ts` | `AuditService` |
| `AuditVerificationService.test.ts` | `AuditVerificationService` |
| `BillingService.test.ts` | `BillingService` |
| `ComplianceService.test.ts` | `ComplianceService` |
| `CredentialService.test.ts` | `CredentialService` |
| `DIDService.test.ts` | `DIDService` |
| `DelegationService.test.ts` | `DelegationService` |
| `EncryptionService.test.ts` | `EncryptionService` |
| `FederationService.test.ts` | `FederationService` |
| `IDTokenService.test.ts` | `IDTokenService` |
| `OAuth2Service.test.ts` | `OAuth2Service` |
| `OIDCKeyService.test.ts` | `OIDCKeyService` |
| `OrgService.test.ts` | `OrgService` |
| `ScaffoldService.test.ts` | `ScaffoldService` |
| `ScaffoldService.errors.test.ts` | `ScaffoldService` error cases |
| `TierService.test.ts` | `TierService` |
| `WebhookService.test.ts` | `WebhookService` |

View File

@@ -0,0 +1,273 @@
# 10 — Deployment and Operations
This document covers building and running AgentIdP in production: Docker, environment variables, database migrations, Terraform multi-region deployment, Prometheus/Grafana monitoring, and operational runbooks for common incidents.
---
## 1. Docker Build and Run
The Dockerfile uses a two-stage build:
- **Stage 1 (builder):** `node:18-alpine` — installs all dependencies (including dev) and compiles TypeScript to `dist/`.
- **Stage 2 (production):** `node:18-alpine` — copies `dist/` and `node_modules` (production only), runs as the built-in non-root `node` user.
```bash
# Build
docker build -t sentryagent-idp:latest .
# Run (supply required env vars)
docker run -d \
-p 3000:3000 \
-e DATABASE_URL=postgresql://sentryagent:sentryagent@<host>:5432/sentryagent_idp \
-e REDIS_URL=redis://<host>:6379 \
-e JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n..." \
-e JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n..." \
sentryagent-idp:latest
```
The container exposes port `3000`. Override with `PORT` environment variable if needed.
For local full-stack development, use Docker Compose instead:
```bash
docker compose up -d
```
The `docker-compose.yml` starts the app, PostgreSQL 14, and Redis 7 with health checks and data volumes.
---
## 2. Environment Variables Reference
All variables are loaded at startup via `dotenv`. In production, inject them directly into the process environment — do not commit `.env` to version control.
| Variable | Required | Default | Purpose |
|----------|----------|---------|---------|
| `DATABASE_URL` | Yes | — | PostgreSQL connection string. Format: `postgresql://<user>:<password>@<host>:<port>/<db>` |
| `REDIS_URL` | Yes | — | Redis connection URL. Format: `redis://<host>:<port>` |
| `JWT_PRIVATE_KEY` | Yes | — | PEM-encoded RSA-2048 private key for signing RS256 JWT tokens |
| `JWT_PUBLIC_KEY` | Yes | — | PEM-encoded RSA-2048 public key for verifying tokens on every authenticated request |
| `PORT` | No | `3000` | HTTP port the Express server listens on |
| `NODE_ENV` | No | `undefined` | Set to `production` in production, `test` in test (disables Morgan logging in test) |
| `CORS_ORIGIN` | No | `*` | Allowed CORS origin(s). Set to specific URL in production (e.g. `https://app.mycompany.ai`) |
| `VAULT_ADDR` | No | — | HashiCorp Vault server address. When set with `VAULT_TOKEN`, new credentials are stored in Vault KV v2 instead of bcrypt |
| `VAULT_TOKEN` | No | — | Vault authentication token. Required when `VAULT_ADDR` is set |
| `VAULT_MOUNT` | No | `secret` | KV v2 secrets engine mount path |
| `POLICY_DIR` | No | `<cwd>/policies` | Directory containing OPA policy files (`authz.wasm` or `data/scopes.json`) |
**Validation at startup:** `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` are checked in `createApp()` (see `src/app.ts:117121`). If missing, the process exits before binding to any port. `DATABASE_URL` and `REDIS_URL` are validated when their respective singletons are first initialised.
---
## 3. Database Migrations
Migrations are plain SQL files in `src/db/migrations/`. They are append-only — never modify an existing migration file. Always create a new numbered file.
Current migration files:
| File | What it creates |
|------|----------------|
| `001_create_agents.sql` | `agents` table with UUID primary key, email unique constraint, status enum |
| `002_create_credentials.sql` | `credentials` table linked to `agents` by `client_id` foreign key |
| `003_create_audit_events.sql` | `audit_events` table with JSONB `metadata` column |
| `004_create_tokens.sql` | `token_monthly_counts` table for free-tier token limit tracking |
| `005_add_vault_path.sql` | Adds `vault_path VARCHAR(512)` column to the `credentials` table |
**Run migrations:**
```bash
npm run db:migrate
```
This executes `scripts/migrate.ts` which applies all SQL files that have not yet been recorded in the `schema_migrations` tracking table.
**Adding a new migration:**
1. Create `src/db/migrations/006_<description>.sql`
2. Write idempotent SQL (use `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, etc.)
3. Run `npm run db:migrate`
---
## 4. Terraform Multi-Region Deployment
The `terraform/` directory contains reusable modules and two environment configurations.
**Directory structure:**
```
terraform/
modules/
agentidp/ # Core AgentIdP compute resources
lb/ # Load balancer (ALB/Cloud Load Balancer)
rds/ # RDS PostgreSQL (AWS)
redis/ # ElastiCache Redis (AWS) / Memorystore (GCP)
environments/
aws/ # AWS deployment (ECS Fargate, ALB, RDS, ElastiCache)
gcp/ # GCP deployment (Cloud Run, Cloud SQL, Memorystore)
```
### AWS Deployment
Architecture: `Internet → Route 53 → ALB (public subnets, HTTPS) → ECS Fargate tasks (private subnets) → RDS PostgreSQL 14 (Multi-AZ) + ElastiCache Redis 7`
All secrets are stored in AWS Secrets Manager and injected into ECS task definitions at launch time.
```bash
cd terraform/environments/aws
terraform init
terraform plan -var="aws_region=us-east-1"
terraform apply
```
Resources provisioned:
- VPC with public and private subnets across multiple availability zones
- ECS Cluster and Fargate task definition (running `sentryagent-idp` container)
- Application Load Balancer with HTTPS listener and health check target group
- RDS PostgreSQL 14 (Multi-AZ for high availability)
- ElastiCache Redis 7 (primary + replica)
- IAM roles and instance profiles for ECS task permissions
- Security groups enforcing least-privilege network access
### GCP Deployment
Architecture: `Internet → Cloud Run (Google-managed TLS, auto-scaling) → Cloud SQL PostgreSQL 14 (REGIONAL HA) + Memorystore Redis 7 (STANDARD_HA)`
All secrets are stored in GCP Secret Manager and mounted into the Cloud Run service at startup.
```bash
cd terraform/environments/gcp
terraform init
terraform plan -var="gcp_region=us-central1"
terraform apply
```
Resources provisioned:
- VPC network with Serverless VPC Access connector (Cloud Run → private databases)
- Cloud Run service (auto-scales to zero, Google-managed TLS)
- Cloud Load Balancer with global anycast IP
- Cloud SQL PostgreSQL 14 with regional high-availability
- Memorystore Redis 7 (STANDARD_HA with in-transit encryption)
- IAM service accounts and bindings
**Important:** All infrastructure changes must go through Terraform. Never make manual edits in the AWS console or GCP Cloud Console — they will be overwritten on the next `terraform apply` and will not be tracked in state.
---
## 5. Prometheus and Grafana
**Metrics endpoint:** `GET /metrics` (unauthenticated — restrict in production to internal network or scrape from within the cluster)
The metrics endpoint is served by the `prom-client` library using a dedicated registry (`metricsRegistry`) defined in `src/metrics/registry.ts`. The registry is isolated from the default global registry to prevent conflicts in tests.
### Metric Definitions
All 6 metrics are defined in `src/metrics/registry.ts`:
| Metric name | Type | Labels | What it measures |
|-------------|------|--------|-----------------|
| `agentidp_tokens_issued_total` | Counter | `scope` | Total OAuth 2.0 access tokens issued successfully |
| `agentidp_agents_registered_total` | Counter | `deployment_env` | Total AI agents registered successfully |
| `agentidp_http_requests_total` | Counter | `method`, `route`, `status_code` | Total HTTP requests received |
| `agentidp_http_request_duration_seconds` | Histogram | `method`, `route`, `status_code` | HTTP request duration in seconds (buckets: 5ms2.5s) |
| `agentidp_db_query_duration_seconds` | Histogram | `operation` | PostgreSQL query duration in seconds |
| `agentidp_redis_command_duration_seconds` | Histogram | `command` | Redis command duration in seconds |
The HTTP metrics (`agentidp_http_requests_total` and `agentidp_http_request_duration_seconds`) are populated by `metricsMiddleware` in `src/middleware/metrics.ts`, which is registered before all routes in `src/app.ts`. Route labels are normalised to replace UUIDs with `:id` to prevent high cardinality (e.g. `/api/v1/agents/:id` rather than `/api/v1/agents/a1b2c3...`).
### Local Grafana
```bash
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
```
- Prometheus: http://localhost:9090
- Grafana: http://localhost:3001 (admin password: `agentidp`)
The monitoring compose overlay starts `prom/prometheus:v2.53.0` and `grafana/grafana:11.2.0`. Grafana dashboards and datasource provisioning are loaded from `monitoring/grafana/provisioning/`.
### Adding a New Metric
1. Define the metric in `src/metrics/registry.ts` using the shared `metricsRegistry` (not the default prom-client registry).
2. Export it from that file.
3. Import it in the file where the instrumentation point lives.
4. Call `.inc(labels)` for Counters or `.observe(labels, value)` for Histograms at the instrumentation point.
5. Verify it appears in `GET /metrics` after starting the server.
---
## 6. Operational Runbook
### Health Check
```bash
curl http://<host>/health
```
Expected response:
```json
{"status":"ok","postgres":"connected","redis":"connected"}
```
Troubleshooting:
- If `postgres: "error"` — verify `DATABASE_URL` is correct and PostgreSQL is reachable. Check `docker compose logs postgres` for local dev.
- If `redis: "error"` — verify `REDIS_URL` is correct and Redis is reachable. Check `docker compose logs redis` for local dev.
- If the health endpoint returns 502 or times out — the app process has crashed; check application logs.
---
### Rotate the JWT Signing Key
All active tokens become invalid after a key rotation — agents must re-authenticate.
1. Generate a new RSA-2048 key pair:
```bash
openssl genrsa -out new-private.pem 2048
openssl rsa -in new-private.pem -pubout -out new-public.pem
```
2. Update `JWT_PRIVATE_KEY` and `JWT_PUBLIC_KEY` in your deployment environment (AWS Secrets Manager, GCP Secret Manager, or `.env`).
3. Perform a rolling restart:
- **ECS:** trigger a new task deployment — ECS drains existing tasks and starts new ones with the updated secret values.
- **Cloud Run:** deploy a new revision — Cloud Run gradually shifts traffic to the new revision.
4. Tokens signed with the old key will fail verification immediately after all instances have restarted.
---
### Revoke All Tokens for a Compromised Agent
Suspend the agent to stop new token issuance immediately:
```bash
curl -X PATCH http://<host>/api/v1/agents/<agentId> \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"status": "suspended"}'
```
This prevents any new `POST /api/v1/token` requests for that agent. Active tokens remain valid until their TTL (1 hour). To invalidate active tokens immediately, also revoke all credentials for the agent:
```bash
# List credentials
curl http://<host>/api/v1/agents/<agentId>/credentials \
-H "Authorization: Bearer <admin_token>"
# Revoke each active credential
curl -X DELETE http://<host>/api/v1/agents/<agentId>/credentials/<credentialId> \
-H "Authorization: Bearer <admin_token>"
```
---
### Read Audit Logs for an Incident
Query the audit log with date range and agent filter:
```bash
curl "http://<host>/api/v1/audit?agentId=<agentId>&startDate=2026-01-01T00:00:00Z&endDate=2026-01-31T23:59:59Z" \
-H "Authorization: Bearer <admin_token>"
```
Events are returned newest-first. Audit log retention is 90 days on the free tier. Each event includes: `eventId`, `agentId`, `action`, `outcome`, `ipAddress`, `userAgent`, `metadata`, `timestamp`.
Common `action` values: `token.issued`, `token.revoked`, `token.introspected`, `agent.created`, `agent.updated`, `agent.suspended`, `agent.decommissioned`, `credential.generated`, `credential.rotated`, `credential.revoked`, `auth.failed`.

View File

@@ -0,0 +1,735 @@
# 11 — SDK Integration Guide
AgentIdP ships four official client SDKs — Node.js, Python, Go, and Java. All four expose an identical API surface, handle OAuth 2.0 token acquisition automatically, and throw typed errors. This document covers installation, complete working examples, error handling, and the contribution guide for adding new endpoints.
---
## 1. SDK Architecture Overview
Every SDK composes the same four service clients:
| Service client | Node.js | Python | Go | Java |
|---------------|---------|--------|----|------|
| Agent Registry | `AgentRegistryClient` | `AgentRegistryClient` | `AgentsClient` | `AgentServiceClient` |
| Credential Management | `CredentialClient` | `CredentialClient` | `CredentialsClient` | `CredentialServiceClient` |
| Token Operations | `TokenClient` | `TokenClient` | `TokenServiceClient` | `TokenServiceClient` |
| Audit Log | `AuditClient` | `AuditClient` | `AuditClient` | `AuditServiceClient` |
All four SDKs also implement:
- **`AgentIdPClient`** — the top-level client that composes all four service clients and wires them to a shared `TokenManager`.
- **`TokenManager`** — fetches and caches the OAuth 2.0 access token. Automatically requests a new token when the cached one is within 60 seconds of expiry. Thread-safe / goroutine-safe.
- **Typed error class** — `AgentIdPError` (Node.js, Python, Go) or `AgentIdPException` (Java) — with `code`, `httpStatus`, and `details` fields.
This consistency is a maintained standard. When a new API endpoint is added to the server, it must be added to all four SDKs simultaneously.
---
## 2. Node.js SDK
**Install:**
```bash
npm install @sentryagent/idp-sdk
```
**Requirements:** Node.js 18+ (uses native `fetch`).
**Complete example:**
```typescript
import { AgentIdPClient, AgentIdPError } from '@sentryagent/idp-sdk';
const client = new AgentIdPClient({
baseUrl: 'http://localhost:3000',
clientId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
clientSecret: 'sk_live_...',
// Optional: restrict scopes. Defaults to all four.
// scopes: ['agents:read', 'tokens:read'],
});
// Register a new agent
const agent = await client.agents.registerAgent({
email: 'classifier-v2@myorg.ai',
agentType: 'classifier',
version: '2.0.0',
capabilities: ['resume:read', 'classify'],
owner: 'platform-team',
deploymentEnv: 'production',
});
console.log('Registered:', agent.agentId);
// List active agents (token acquired automatically)
const { data: agents } = await client.agents.listAgents({ status: 'active' });
console.log('Active agents:', agents.length);
// Generate credentials for an agent
const cred = await client.credentials.generateCredential(agent.agentId);
console.log('Client secret (store this — shown once):', cred.clientSecret);
// Rotate credentials
const newCred = await client.credentials.rotateCredential(agent.agentId, cred.credentialId);
console.log('New secret:', newCred.clientSecret);
// Introspect a token
const introspection = await client.tokens.introspectToken('eyJ...');
console.log('Active:', introspection.active);
// Error handling
try {
await client.agents.getAgent('non-existent-id');
} catch (err) {
if (err instanceof AgentIdPError) {
console.error(err.code); // e.g. AGENT_NOT_FOUND
console.error(err.httpStatus); // e.g. 404
console.error(err.details); // optional structured context
}
}
// Force a fresh token on the next call (e.g. after credential rotation)
client.clearTokenCache();
```
**Token manager behaviour:** `TokenManager` in `sdk/src/token-manager.ts` caches the token and requests a new one when fewer than 60 seconds remain before expiry.
**Service clients are accessible at:**
- `client.agents``AgentRegistryClient` (register, list, get, update, decommission)
- `client.credentials``CredentialClient` (generate, list, rotate, revoke)
- `client.tokens``TokenClient` (introspect, revoke)
- `client.audit``AuditClient` (query, get event)
---
## 3. Python SDK
**Install:**
```bash
pip install sentryagent-idp
```
**Requirements:** Python 3.9+. Synchronous client uses `requests`; asynchronous client uses `httpx`.
### Synchronous example
```python
from sentryagent_idp import AgentIdPClient, AgentIdPError, RegisterAgentRequest
client = AgentIdPClient(
base_url="http://localhost:3000",
client_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
client_secret="sk_live_...",
# scopes=["agents:read", "tokens:read"], # optional
)
# Register an agent
agent = client.agents.register_agent(RegisterAgentRequest(
email="screener@myorg.ai",
agent_type="screener",
version="1.0.0",
capabilities=["resume:read"],
owner="recruiting-team",
deployment_env="production",
))
print("Registered:", agent.agent_id)
# List agents
result = client.agents.list_agents(status="active", page=1, limit=20)
for a in result.data:
print(a.agent_id, a.status)
# Generate credentials
cred = client.credentials.generate_credential(agent.agent_id)
print("Client secret (shown once):", cred.client_secret)
# Error handling
try:
client.agents.get_agent("non-existent-id")
except AgentIdPError as e:
print(e.code) # e.g. AGENT_NOT_FOUND
print(e.http_status) # e.g. 404
print(e.details) # optional dict
```
### Asynchronous example
```python
import asyncio
from sentryagent_idp import AsyncAgentIdPClient, AgentIdPError
async def main() -> None:
client = AsyncAgentIdPClient(
base_url="http://localhost:3000",
client_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
client_secret="sk_live_...",
)
result = await client.agents.list_agents(status="active")
print(f"Found {result.total} active agents")
# Rotate a credential
new_cred = await client.credentials.rotate_credential(
"agent-uuid", "credential-uuid"
)
print("New secret:", new_cred.client_secret)
asyncio.run(main())
```
`AsyncAgentIdPClient` uses an `AsyncTokenManager` backed by `httpx.AsyncClient`. Both sync and async clients are available from the `sentryagent_idp` top-level package.
---
## 4. Go SDK
**Install:**
```bash
go get github.com/sentryagent/idp-sdk-go
```
**Requirements:** Go 1.21+.
**Complete example:**
```go
package main
import (
"context"
"fmt"
"log"
agentidp "github.com/sentryagent/idp-sdk-go"
)
func main() {
ctx := context.Background()
client := agentidp.NewAgentIdPClient(agentidp.AgentIdPClientConfig{
BaseURL: "http://localhost:3000",
ClientID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
ClientSecret: "sk_live_...",
// Scope: "agents:read agents:write", // optional
})
// Register an agent
agent, err := client.Agents.RegisterAgent(ctx, agentidp.RegisterAgentRequest{
Email: "screener@myorg.ai",
AgentType: "screener",
Version: "1.0.0",
Capabilities: []string{"resume:read"},
Owner: "recruiting-team",
DeploymentEnv: "production",
})
if err != nil {
// Type-assert for structured error information
var idpErr *agentidp.AgentIdPError
if errors.As(err, &idpErr) {
log.Fatalf("API error: code=%s status=%d", idpErr.Code, idpErr.HTTPStatus)
}
log.Fatal(err)
}
fmt.Println("Registered:", agent.AgentID)
// List agents with filters
list, err := client.Agents.ListAgents(ctx, &agentidp.ListAgentsParams{
Status: "active",
Page: 1,
Limit: 20,
})
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d agents\n", list.Total)
// Generate credentials
cred, err := client.Credentials.GenerateCredential(ctx, agent.AgentID, nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("Client secret (shown once):", cred.ClientSecret)
// Rotate credentials
newCred, err := client.Credentials.RotateCredential(ctx, agent.AgentID, cred.CredentialID, nil)
if err != nil {
log.Fatal(err)
}
fmt.Println("New secret:", newCred.ClientSecret)
}
```
`context.Context` is the first parameter of every method — use `context.Background()` for simple cases or a derived context with deadline/cancellation for production code. The `TokenManager` is goroutine-safe and the client is safe for concurrent use.
---
## 5. Java SDK
**Maven dependency:**
```xml
<dependency>
<groupId>ai.sentryagent</groupId>
<artifactId>idp-sdk</artifactId>
<version>1.0.0</version>
</dependency>
```
**Gradle:**
```groovy
implementation 'ai.sentryagent:idp-sdk:1.0.0'
```
**Requirements:** Java 17+.
### Synchronous example
```java
import ai.sentryagent.idp.AgentIdPClient;
import ai.sentryagent.idp.AgentIdPException;
import ai.sentryagent.idp.models.*;
// Builder pattern — scope is optional (defaults to all four scopes)
AgentIdPClient client = new AgentIdPClient(
"http://localhost:3000",
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"sk_live_..."
);
// Register an agent
Agent agent = client.agents().registerAgent(
RegisterAgentRequest.builder()
.email("screener@myorg.ai")
.agentType("screener")
.version("1.0.0")
.capabilities(List.of("resume:read"))
.owner("recruiting-team")
.deploymentEnv("production")
.build()
);
System.out.println("Registered: " + agent.getAgentId());
// List agents
PaginatedAgents result = client.agents().listAgents(
ListAgentsParams.builder().status("active").page(1).limit(20).build()
);
System.out.println("Total: " + result.getTotal());
// Generate credentials
CredentialWithSecret cred = client.credentials().generateCredential(agent.getAgentId());
System.out.println("Client secret (shown once): " + cred.getClientSecret());
// Rotate credentials
CredentialWithSecret newCred = client.credentials().rotateCredential(
agent.getAgentId(), cred.getCredentialId()
);
System.out.println("New secret: " + newCred.getClientSecret());
// Error handling
try {
client.agents().getAgent("non-existent-id");
} catch (AgentIdPException ex) {
System.out.printf("code=%s status=%d%n", ex.getCode(), ex.getHttpStatus());
// e.g. code=AGENT_NOT_FOUND status=404
}
```
### Async example (CompletableFuture)
```java
import java.util.concurrent.CompletableFuture;
// Every sync method has an async counterpart
CompletableFuture<Agent> future = client.agents().getAgentAsync("agent-uuid");
future.thenAccept(a -> System.out.println(a.getAgentId()));
// Compose multiple async calls
client.agents().getAgentAsync("agent-uuid")
.thenCompose(a -> client.credentials().generateCredentialAsync(a.getAgentId()))
.thenAccept(cred -> System.out.println("New secret: " + cred.getClientSecret()))
.exceptionally(ex -> {
if (ex.getCause() instanceof AgentIdPException idpEx) {
System.err.printf("code=%s%n", idpEx.getCode());
}
return null;
});
```
The `TokenManager` is thread-safe. `AgentIdPClient` is safe for concurrent use from multiple threads.
---
## 6. Rust SDK
The Rust SDK (`sdk-rust/`) is a production-grade, async-first client for the SentryAgent.ai AgentIdP API. It provides full coverage of the 14 API endpoints across agent identity, OAuth 2.0 token management, credential rotation, audit logs, the public marketplace, and agent-to-agent (A2A) delegation.
**Requirements:** Rust 1.75+ (stable), `tokio` runtime.
---
### Installation
Add the crate to your `Cargo.toml`:
```toml
[dependencies]
sentryagent-idp = "1.0"
tokio = { version = "1.35", features = ["full"] }
```
The crate uses `reqwest` with `rustls-tls` (no OpenSSL dependency) and `serde` for JSON serialisation.
---
### Authentication
The Rust SDK uses the OAuth 2.0 Client Credentials grant, managed transparently by `TokenManager`. You never call `TokenManager` directly — it is embedded in `AgentIdPClient` and invoked automatically before every request.
**Token refresh behaviour:**
- The first API call triggers a `POST /oauth2/token` request with `grant_type=client_credentials`.
- The returned token is cached behind an async `tokio::sync::Mutex`.
- Subsequent calls within the token lifetime return the cached token without a network round trip.
- The cache expires 60 seconds before the server-reported `expires_in`, ensuring tokens never expire mid-flight.
- The `Mutex` guarantees only one refresh happens even when many `tokio` tasks call `get_token()` concurrently.
**Environment variable construction:**
```rust
use sentryagent_idp::AgentIdPClient;
// from_env() reads AGENTIDP_API_URL, AGENTIDP_CLIENT_ID, AGENTIDP_CLIENT_SECRET
let client = AgentIdPClient::from_env()?;
```
**Explicit construction:**
```rust
use sentryagent_idp::AgentIdPClient;
let client = AgentIdPClient::new(
"https://api.sentryagent.ai",
"a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"sk_live_...",
);
```
| Environment Variable | Required | Purpose |
|---|---|---|
| `AGENTIDP_API_URL` | Yes | Base URL of the AgentIdP API |
| `AGENTIDP_CLIENT_ID` | Yes | OAuth 2.0 client identifier |
| `AGENTIDP_CLIENT_SECRET` | Yes | OAuth 2.0 client secret |
---
### Complete Working Example
The following example covers the full agent identity lifecycle: register → generate credentials → issue token → retrieve agent → list audit logs → delete agent.
```rust
use sentryagent_idp::{
AgentIdPClient, AgentIdPError,
AuditLogFilters, MarketplaceFilters, RegisterAgentRequest,
};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Build client from environment variables.
// Requires: AGENTIDP_API_URL, AGENTIDP_CLIENT_ID, AGENTIDP_CLIENT_SECRET
let client = AgentIdPClient::from_env()?;
// ── Register a new agent ──────────────────────────────────────────────────
let agent = client.register_agent(RegisterAgentRequest {
name: "my-screener-agent".to_owned(),
description: Some("Screens resumes using ML".to_owned()),
agent_type: "screener".to_owned(),
capabilities: vec!["resume:read".to_owned(), "classify".to_owned()],
metadata: None,
}).await?;
println!("Registered: {} (DID: {})", agent.id, agent.did);
// ── Generate credentials for the agent ───────────────────────────────────
let creds = client.generate_credentials(&agent.id).await?;
println!("Client ID: {}", creds.client_id);
println!("Client Secret: {} (store this — shown once)", creds.client_secret);
// ── Issue a scoped token (TokenManager handles this automatically) ────────
let token_resp = client.issue_token(&agent.id, &["agents:read", "agents:write"]).await?;
println!("Token type: {}, expires in {}s", token_resp.token_type, token_resp.expires_in);
// ── Retrieve the agent ────────────────────────────────────────────────────
let fetched = client.get_agent(&agent.id).await?;
println!("Fetched: {} (public: {})", fetched.name, fetched.is_public);
// ── List agents ───────────────────────────────────────────────────────────
let list = client.list_agents(Some(1), Some(10)).await?;
println!("Total agents: {}", list.total);
// ── Audit logs ────────────────────────────────────────────────────────────
let logs = client.list_audit_logs(AuditLogFilters {
agent_id: Some(agent.id.clone()),
event_type: None,
from: None,
to: None,
page: 1,
per_page: 10,
}).await?;
println!("Audit events: {}", logs.total);
// ── Rotate credentials ────────────────────────────────────────────────────
let new_creds = client.rotate_credentials(&agent.id).await?;
println!("New secret: {}", new_creds.client_secret);
// ── Delete agent ──────────────────────────────────────────────────────────
client.delete_agent(&agent.id).await?;
println!("Agent deleted.");
Ok(())
}
```
Run the bundled quickstart example directly:
```bash
AGENTIDP_API_URL=http://localhost:3000 \
AGENTIDP_CLIENT_ID=your-client-id \
AGENTIDP_CLIENT_SECRET=your-client-secret \
cargo run --example quickstart
```
---
### Client Methods Reference
All methods are `async` and return `Result<T, AgentIdPError>`. The client is cheap to clone — the inner `reqwest::Client` and token cache are shared via `Arc`.
**Agent Registry** (`sdk-rust/src/agents.rs`):
| Method | Signature | Description |
|--------|-----------|-------------|
| `register_agent` | `(req: RegisterAgentRequest) -> Result<Agent>` | `POST /agents` — 201 |
| `get_agent` | `(agent_id: &str) -> Result<Agent>` | `GET /agents/{id}` — 200 |
| `list_agents` | `(page: Option<u32>, per_page: Option<u32>) -> Result<AgentList>` | `GET /agents` — 200 |
| `update_agent` | `(agent_id: &str, req: UpdateAgentRequest) -> Result<Agent>` | `PATCH /agents/{id}` — 200 |
| `delete_agent` | `(agent_id: &str) -> Result<()>` | `DELETE /agents/{id}` — 204 |
**Credential Management** (`sdk-rust/src/credentials.rs`):
| Method | Signature | Description |
|--------|-----------|-------------|
| `generate_credentials` | `(agent_id: &str) -> Result<Credentials>` | `POST /agents/{id}/credentials` — 201. `client_secret` shown once. |
| `rotate_credentials` | `(agent_id: &str) -> Result<Credentials>` | `POST /agents/{id}/credentials/rotate` — 200. New secret shown once. |
| `revoke_credentials` | `(agent_id: &str, cred_id: &str) -> Result<()>` | `DELETE /agents/{id}/credentials/{cred_id}` — 204 |
**Token Operations** (`sdk-rust/src/oauth2.rs`):
| Method | Signature | Description |
|--------|-----------|-------------|
| `issue_token` | `(agent_id: &str, scopes: &[&str]) -> Result<TokenResponse>` | Issues a scoped Bearer JWT. Token is cached by `TokenManager` automatically. |
**Audit Log** (`sdk-rust/src/audit.rs`):
| Method | Signature | Description |
|--------|-----------|-------------|
| `list_audit_logs` | `(filters: AuditLogFilters) -> Result<AuditLogList>` | Paginated audit log query with optional agent_id, event_type, from, to filters. |
**Marketplace** (`sdk-rust/src/marketplace.rs`):
| Method | Signature | Description |
|--------|-----------|-------------|
| `list_public_agents` | `(filters: MarketplaceFilters) -> Result<MarketplaceAgentList>` | Lists publicly discoverable agents with optional `q`, `capability`, `publisher` filters. |
**A2A Delegation** (`sdk-rust/src/delegation.rs`):
| Method | Signature | Description |
|--------|-----------|-------------|
| `delegate` | `(req: DelegateRequest) -> Result<DelegationToken>` | Creates a delegation chain and returns the delegation JWT. |
| `verify_delegation` | `(token: &str) -> Result<DelegationVerification>` | Verifies a delegation token and returns the verified claims. |
---
### Error Types
All SDK operations return `Result<T, AgentIdPError>`. Match on the enum variants for structured error handling:
```rust
use sentryagent_idp::AgentIdPError;
match client.get_agent("unknown-id").await {
Ok(agent) => println!("Found: {}", agent.name),
Err(AgentIdPError::NotFound(msg)) => {
eprintln!("Agent not found: {}", msg);
}
Err(AgentIdPError::AuthError(msg)) => {
eprintln!("Auth failed: {}", msg);
// Token may have been revoked — check credentials
}
Err(AgentIdPError::RateLimited { retry_after_secs }) => {
eprintln!("Rate limited — retry after {}s", retry_after_secs);
tokio::time::sleep(std::time::Duration::from_secs(retry_after_secs)).await;
}
Err(AgentIdPError::ApiError { status, message, code }) => {
eprintln!("API error {}: {} (code: {:?})", status, message, code);
}
Err(AgentIdPError::ConfigError(msg)) => {
// Missing environment variable — fix before running
eprintln!("Config error: {}", msg);
}
Err(AgentIdPError::HttpError(e)) => {
// reqwest transport error — network issue
eprintln!("HTTP transport error: {}", e);
}
Err(AgentIdPError::SerdeError(e)) => {
// JSON parse failure — API response shape mismatch
eprintln!("Serialization error: {}", e);
}
Err(AgentIdPError::DelegationError(msg)) => {
eprintln!("Delegation chain invalid: {}", msg);
}
}
```
| Variant | Trigger | HTTP status |
|---------|---------|-------------|
| `HttpError(reqwest::Error)` | Network-level failure (connection refused, timeout) | N/A |
| `ApiError { status, message, code }` | Non-2xx response not matching a specific variant | Any non-2xx |
| `AuthError(String)` | 401 or 403 from the API | 401, 403 |
| `NotFound(String)` | 404 from the API | 404 |
| `RateLimited { retry_after_secs }` | 429 — parses `Retry-After` header (defaults to 60s) | 429 |
| `ConfigError(String)` | Missing env var in `from_env()` | N/A |
| `SerdeError(serde_json::Error)` | JSON deserialisation failure | N/A |
| `DelegationError(String)` | Invalid delegation chain | N/A |
---
### Adding a New Endpoint to the Rust SDK
When the AgentIdP server adds a new API endpoint, add it to the Rust SDK using this checklist:
**File structure** (`sdk-rust/src/`):
```
sdk-rust/src/
├── lib.rs # Crate root — re-exports and module declarations
├── client.rs # AgentIdPClient struct and new()/from_env() constructors
├── token_manager.rs # TokenManager — async token cache
├── models.rs # All request/response structs (serde Serialize/Deserialize)
├── error.rs # AgentIdPError enum
├── agents.rs # Agent registry methods (impl AgentIdPClient)
├── credentials.rs # Credential management methods
├── oauth2.rs # Token issuance methods
├── audit.rs # Audit log methods
├── marketplace.rs # Marketplace methods
└── delegation.rs # A2A delegation methods
```
**Checklist:**
- [ ] Add request/response structs to `models.rs` with `#[derive(Debug, serde::Serialize, serde::Deserialize)]`
- [ ] Add the method to the appropriate `impl AgentIdPClient` block in the relevant `<domain>.rs` file. If the endpoint belongs to a new domain, create a new file and declare it as `pub mod <domain>;` in `lib.rs`
- [ ] Use `self.get_auth_header().await?` for the `Authorization: Bearer` header
- [ ] Use the shared `parse_response::<T>(resp).await` helper (defined in `agents.rs`) to map HTTP status codes to `AgentIdPError` variants
- [ ] Add a doc comment (`///`) to the method with: the HTTP method + path, the success response type, and `# Errors` listing which `AgentIdPError` variants it can return
- [ ] Re-export new public types from `lib.rs` with `pub use models::{NewRequestType, NewResponseType};`
- [ ] Add a unit test using `mockito::Server` (see `token_manager.rs` tests for the pattern)
- [ ] Run `cargo test` and verify all tests pass
- [ ] Run `cargo doc --no-deps --open` and verify the new method appears with correct documentation
- [ ] Verify `cargo clippy -- -D warnings` exits 0
---
## 7. SDK Contribution Guide — Adding a New Endpoint
When the server adds a new API endpoint, update all four SDKs. The checklist below covers each SDK.
### Node.js SDK (`sdk/`)
```
src/
services/
agents.ts # AgentRegistryClient
credentials.ts # CredentialClient
token.ts # TokenClient
audit.ts # AuditClient
types.ts # All request/response type definitions
token-manager.ts # TokenManager
client.ts # AgentIdPClient (top-level)
errors.ts # AgentIdPError
```
Checklist:
- [ ] Add method to the appropriate service client in `src/services/<client>.ts`
- [ ] Add TypeScript request/response types in `src/types.ts`
- [ ] Add JSDoc with `@param`, `@returns`, and `@throws`
- [ ] Add unit test in `tests/<client>.test.ts`
- [ ] Verify `npx tsc --strict` exits 0
### Python SDK (`sdk-python/`)
```
src/sentryagent_idp/
services/
agents.py # AgentRegistryClient + AsyncAgentRegistryClient
credentials.py # CredentialClient + AsyncCredentialClient
token.py # TokenClient + AsyncTokenClient
audit.py # AuditClient + AsyncAuditClient
client.py # AgentIdPClient + AsyncAgentIdPClient
token_manager.py # TokenManager (sync)
async_token_manager.py # AsyncTokenManager
errors.py # AgentIdPError
types.py # TypedDict / dataclass definitions
```
Checklist:
- [ ] Add method to both the sync and async service clients
- [ ] Add type hints (all parameters and return types)
- [ ] Verify `mypy --strict` passes
- [ ] Add unit test in `tests/`
- [ ] Verify `pytest` passes with >80% coverage
### Go SDK (`sdk-go/`)
```
agentidp/
client.go # AgentIdPClient + AgentIdPClientConfig
agents.go # AgentsClient
credentials.go # CredentialsClient
token_service.go # TokenServiceClient
audit.go # AuditClient
token_manager.go # TokenManager (goroutine-safe)
errors.go # AgentIdPError
types.go # All request/response struct types
request.go # Shared HTTP request helper
```
Checklist:
- [ ] Add method to the appropriate `*Client` type
- [ ] Use `context.Context` as the first parameter
- [ ] Add godoc comment above the method
- [ ] Add request/response struct types in `types.go` if needed
- [ ] Add unit test in `<file>_test.go`
- [ ] Verify `go vet ./... && staticcheck ./...` pass
### Java SDK (`sdk-java/`)
```
src/main/java/ai/sentryagent/idp/
AgentIdPClient.java # Top-level client
services/
AgentServiceClient.java # Agent Registry
CredentialServiceClient.java
TokenServiceClient.java
AuditServiceClient.java
models/ # Request/response POJOs (@JsonProperty)
TokenManager.java # Thread-safe token caching
AgentIdPException.java # Typed exception
```
Checklist:
- [ ] Add sync method to the appropriate service client
- [ ] Add `CompletableFuture<T>` async counterpart with the `Async` suffix
- [ ] Add request/response POJO in `models/` with `@JsonProperty` annotations
- [ ] Add Javadoc on the method
- [ ] Add JUnit 5 test in `src/test/java/`
- [ ] Verify `mvn verify` passes (compiles, tests, and checks coverage)

View File

@@ -0,0 +1,61 @@
# SentryAgent.ai — Engineering Knowledge Base
> Internal reference for engineers contributing to AgentIdP. Read in order if you're new. Jump to the relevant document if you know what you need.
---
## Reading Order (New Engineers Start Here)
| # | Document | What you'll learn | Time |
|---|---------|------------------|------|
| 1 | [Company and Product Overview](01-overview.md) | What SentryAgent.ai builds, why it exists, the product feature set, Phase roadmap | 15 min |
| 2 | [System Architecture](02-architecture.md) | Component diagram, HTTP request lifecycle, OAuth 2.0 data flow, multi-region topology | 20 min |
| 3 | [Technology Stack and ADRs](03-tech-stack.md) | Why each technology was chosen — rationale and alternatives considered | 20 min |
| 4 | [Codebase Structure](04-codebase-structure.md) | Directory map, where to add new code, DRY enforcement rules | 15 min |
| 5 | [Service Deep Dives](05-services.md) | All 17 services/components (incl. Phase 36: AnalyticsService, TierService, ComplianceService, FederationService, DIDService, WebhookService, BillingService, DelegationService, OIDCService) — purpose, interface, schema, error types | 45 min |
| 6 | [Annotated Code Walkthroughs](06-walkthroughs.md) | Step-by-step traces of token issuance, agent registration, credential rotation | 30 min |
| 7 | [Development Environment Setup](07-dev-setup.md) | Clone to running local stack — under 30 minutes | 30 min |
| 8 | [Engineering Workflow](08-workflow.md) | OpenSpec spec-first workflow, branching, PR checklist, commit conventions | 20 min |
| 9 | [Testing Strategy](09-testing.md) | Unit vs integration, coverage gates, how to write tests, OWASP reference | 20 min |
| 10 | [Deployment and Operations](10-deployment.md) | Docker, Terraform, Prometheus/Grafana, operational runbook | 20 min |
| 11 | [SDK Integration Guide](11-sdk-guide.md) | All 5 SDKs (Node.js, Python, Go, Java, Rust) — installation, examples, contribution guide | 25 min |
**Total estimated reading time for new engineers: ~4 hours**
---
## Quick Reference
| I need to... | Go to |
|-------------|-------|
| Understand the codebase layout | [04-codebase-structure.md](04-codebase-structure.md) |
| Run the project locally | [07-dev-setup.md](07-dev-setup.md) |
| Understand how token issuance works end-to-end | [06-walkthroughs.md](06-walkthroughs.md) |
| Add a new API endpoint | [08-workflow.md](08-workflow.md) + [04-codebase-structure.md](04-codebase-structure.md) |
| Write tests | [09-testing.md](09-testing.md) |
| Deploy to production | [10-deployment.md](10-deployment.md) |
| Integrate with the SDK (Node.js, Python, Go, Java, Rust) | [11-sdk-guide.md](11-sdk-guide.md) |
| Understand why a technology was chosen | [03-tech-stack.md](03-tech-stack.md) |
| Understand tier limits and billing | [01-overview.md](01-overview.md) (Section 6) + [03-tech-stack.md](03-tech-stack.md) (ADR-11) |
| Understand AGNTCY compliance reports | [05-services.md](05-services.md) (ComplianceService) |
| Understand the A2A delegation flow | [06-walkthroughs.md](06-walkthroughs.md) (Walkthrough 4) |
| Run the AGNTCY conformance suite | [09-testing.md](09-testing.md) (Section 10.8) |
| Add a new Rust SDK endpoint | [11-sdk-guide.md](11-sdk-guide.md) (Section 6 contribution guide) |
---
## Document Conventions
- **File paths** are always relative to the project root unless otherwise noted.
- **Line numbers** in [06-walkthroughs.md](06-walkthroughs.md) were verified against commit `1f95cfe`.
- **Code examples** are complete and runnable — no ellipses, no placeholders.
- **ADR** stands for Architecture Decision Record — a short document recording a technology choice.
---
## Related Documentation
- `docs/developers/` — End-user API reference (for agents calling the AgentIdP API)
- `docs/devops/` — Operator runbooks and environment variable reference
- `docs/agntcy/` — AGNTCY alignment documentation
- `openspec/` — OpenSpec change management (proposals, designs, specs, tasks, archives)

428
docs/openapi/analytics.yaml Normal file
View File

@@ -0,0 +1,428 @@
openapi: "3.0.3"
info:
title: SentryAgent.ai — Tenant Analytics
version: 1.0.0
description: |
Tenant analytics endpoints for the SentryAgent.ai AgentIdP platform.
Provides usage trend data, agent activity heatmaps, and per-agent usage summaries
scoped to the authenticated organization (tenant).
**All endpoints require a valid Bearer JWT.** Data is always scoped to the
organization identified by the `organization_id` claim in the token.
**Feature flag:** When `ANALYTICS_ENABLED=false` these routes return 404.
**Available endpoints:**
- `GET /analytics/tokens` — Daily token issuance trend (last N days)
- `GET /analytics/agents/activity` — Agent activity heatmap by day-of-week + hour
- `GET /analytics/agents` — Per-agent usage summary for the current month
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: Analytics
description: Tenant-scoped usage analytics and reporting
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via `POST /token`.
Include as `Authorization: Bearer <token>`.
schemas:
TokenTrendDataPoint:
type: object
description: Token issuance count for a single calendar day.
required:
- date
- count
properties:
date:
type: string
format: date
description: Calendar date (UTC) in `YYYY-MM-DD` format.
example: "2026-04-01"
count:
type: integer
description: Number of OAuth 2.0 tokens issued on this date.
minimum: 0
example: 842
TokenTrendResponse:
type: object
description: Daily token issuance trend for the last N days.
required:
- organizationId
- days
- data
properties:
organizationId:
type: string
format: uuid
description: Organization the analytics data belongs to.
example: "org-1234-5678-abcd-ef01"
days:
type: integer
description: Number of days included in the trend window.
example: 30
data:
type: array
description: |
Array of daily data points ordered by date ascending.
Days with no token issuances have `count: 0`.
items:
$ref: '#/components/schemas/TokenTrendDataPoint'
ActivityHeatmapCell:
type: object
description: |
A single cell in the agent activity heatmap, identified by
day-of-week (0 = Sunday) and hour of day (023 UTC).
required:
- dayOfWeek
- hour
- count
properties:
dayOfWeek:
type: integer
description: Day of week (0 = Sunday, 6 = Saturday).
minimum: 0
maximum: 6
example: 1
hour:
type: integer
description: Hour of day in UTC (023).
minimum: 0
maximum: 23
example: 14
count:
type: integer
description: Number of token issuances or API calls in this slot.
minimum: 0
example: 217
AgentActivityResponse:
type: object
description: |
Agent activity heatmap — shows when agents are most active
by day-of-week and hour (UTC). Useful for identifying peak usage patterns.
required:
- organizationId
- data
properties:
organizationId:
type: string
format: uuid
example: "org-1234-5678-abcd-ef01"
data:
type: array
description: |
Array of heatmap cells. Contains only cells with `count > 0`.
Maximum 168 cells (7 days × 24 hours).
items:
$ref: '#/components/schemas/ActivityHeatmapCell'
AgentUsageSummary:
type: object
description: Per-agent usage summary for the current calendar month.
required:
- agentId
- tokensIssued
- apiCalls
properties:
agentId:
type: string
format: uuid
description: UUID of the agent.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
agentEmail:
type: string
format: email
description: Email identifier of the agent.
example: "screener-001@sentryagent.ai"
tokensIssued:
type: integer
description: Number of tokens issued for this agent in the current month.
minimum: 0
example: 1204
apiCalls:
type: integer
description: Total API calls made by this agent in the current month.
minimum: 0
example: 5432
lastActiveAt:
type: string
format: date-time
nullable: true
description: Timestamp of the agent's last API activity. Null if no activity this month.
example: "2026-04-07T08:45:00.000Z"
AgentSummaryResponse:
type: object
description: Per-agent usage summary for the current month, across all agents in the organization.
required:
- organizationId
- month
- data
properties:
organizationId:
type: string
format: uuid
example: "org-1234-5678-abcd-ef01"
month:
type: string
description: Current billing month in `YYYY-MM` format.
example: "2026-04"
data:
type: array
description: Per-agent usage summaries, ordered by `tokensIssued` descending.
items:
$ref: '#/components/schemas/AgentUsageSummary'
ErrorResponse:
type: object
description: Standard error response envelope.
required:
- code
- message
properties:
code:
type: string
example: "UNAUTHORIZED"
message:
type: string
example: "A valid Bearer token is required to access this resource."
details:
type: object
additionalProperties: true
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."
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:
/analytics/tokens:
get:
operationId: getTokenTrend
tags:
- Analytics
summary: Get daily token issuance trend
description: |
Returns a daily breakdown of OAuth 2.0 token issuances for the authenticated
organization over the last N days.
The `days` parameter controls the window size (default: 30, max: 90).
Days with no token activity are included with `count: 0`.
Data is scoped to the `organization_id` from the Bearer token.
parameters:
- name: days
in: query
required: false
description: Number of days to include in the trend window. Default: 30, max: 90.
schema:
type: integer
minimum: 1
maximum: 90
default: 30
example: 30
responses:
'200':
description: Token trend data returned successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/TokenTrendResponse'
example:
organizationId: "org-1234-5678-abcd-ef01"
days: 7
data:
- date: "2026-04-01"
count: 842
- date: "2026-04-02"
count: 967
- date: "2026-04-03"
count: 0
- date: "2026-04-04"
count: 1201
- date: "2026-04-05"
count: 1087
- date: "2026-04-06"
count: 953
- date: "2026-04-07"
count: 412
'400':
description: Invalid `days` parameter.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
tooLarge:
summary: Exceeds maximum
value:
code: "VALIDATION_ERROR"
message: "Query parameter `days` must not exceed 90."
details:
field: "days"
max: 90
provided: 120
invalid:
summary: Non-positive integer
value:
code: "VALIDATION_ERROR"
message: "Query parameter `days` must be a positive integer."
details:
field: "days"
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: Analytics feature is not enabled on this instance.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "NOT_FOUND"
message: "Analytics is not enabled on this instance."
'500':
$ref: '#/components/responses/InternalServerError'
/analytics/agents/activity:
get:
operationId: getAgentActivity
tags:
- Analytics
summary: Get agent activity heatmap
description: |
Returns agent activity aggregated by day-of-week (0 = Sunday) and hour of day (UTC).
The heatmap shows when agents in the organization are most active,
based on token issuances and API calls. Only cells with `count > 0` are returned.
Data is scoped to the `organization_id` from the Bearer token.
The heatmap covers the last 90 days of activity.
responses:
'200':
description: Agent activity heatmap returned successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/AgentActivityResponse'
example:
organizationId: "org-1234-5678-abcd-ef01"
data:
- dayOfWeek: 1
hour: 9
count: 342
- dayOfWeek: 1
hour: 14
count: 217
- dayOfWeek: 2
hour: 10
count: 189
- dayOfWeek: 3
hour: 11
count: 405
- dayOfWeek: 4
hour: 14
count: 278
- dayOfWeek: 5
hour: 9
count: 121
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: Analytics feature is not enabled on this instance.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "NOT_FOUND"
message: "Analytics is not enabled on this instance."
'500':
$ref: '#/components/responses/InternalServerError'
/analytics/agents:
get:
operationId: getAgentSummary
tags:
- Analytics
summary: Get per-agent usage summary
description: |
Returns per-agent token issuance counts and API call totals for the
current calendar month, across all agents in the authenticated organization.
Results are ordered by `tokensIssued` descending (most active agents first).
Data is scoped to the `organization_id` from the Bearer token.
responses:
'200':
description: Per-agent usage summary returned successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/AgentSummaryResponse'
example:
organizationId: "org-1234-5678-abcd-ef01"
month: "2026-04"
data:
- agentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
agentEmail: "screener-001@sentryagent.ai"
tokensIssued: 1204
apiCalls: 5432
lastActiveAt: "2026-04-07T08:45:00.000Z"
- agentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
agentEmail: "classifier-002@sentryagent.ai"
tokensIssued: 876
apiCalls: 3120
lastActiveAt: "2026-04-06T14:30:00.000Z"
- agentId: "c3d4e5f6-a7b8-9012-cdef-123456789012"
agentEmail: "router-003@sentryagent.ai"
tokensIssued: 0
apiCalls: 0
lastActiveAt: null
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: Analytics feature is not enabled on this instance.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "NOT_FOUND"
message: "Analytics is not enabled on this instance."
'500':
$ref: '#/components/responses/InternalServerError'

355
docs/openapi/billing.yaml Normal file
View File

@@ -0,0 +1,355 @@
openapi: "3.0.3"
info:
title: SentryAgent.ai — Billing & Usage Metering
version: 1.0.0
description: |
Billing and usage metering endpoints for the SentryAgent.ai AgentIdP platform.
Integrates with **Stripe** for subscription and payment management.
**Authenticated endpoints** (require Bearer JWT):
- `POST /billing/checkout` — Create a Stripe Checkout Session for plan upgrades
- `GET /billing/usage` — Retrieve today's usage summary
**Unauthenticated endpoint** (Stripe webhook receiver):
- `POST /billing/webhook` — Receives Stripe webhook events (raw body + signature verification)
**Important:** The `/billing/webhook` endpoint uses `express.raw()` middleware
to receive the raw request body as a Buffer. Do not apply `express.json()` to this route.
The `Stripe-Signature` header is required for all webhook deliveries.
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: Billing Checkout
description: Stripe Checkout Session management
- name: Billing Webhook
description: Stripe webhook event receiver (unauthenticated)
- name: Usage
description: Usage metering and reporting
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via `POST /token`.
Include as `Authorization: Bearer <token>`.
schemas:
CheckoutRequest:
type: object
description: |
Optional request body for creating a Stripe Checkout Session.
When `successUrl` or `cancelUrl` are omitted, the platform generates
default redirect URLs pointing to the dashboard.
properties:
successUrl:
type: string
format: uri
description: URL to redirect to after successful payment.
example: "https://my-app.example.com/dashboard?billing=success"
cancelUrl:
type: string
format: uri
description: URL to redirect to if the user cancels checkout.
example: "https://my-app.example.com/dashboard?billing=cancel"
CheckoutResponse:
type: object
description: Stripe Checkout Session URL to redirect the user to.
required:
- checkoutUrl
properties:
checkoutUrl:
type: string
format: uri
description: |
Stripe-hosted Checkout page URL. Redirect the authenticated user
to this URL to complete payment.
example: "https://checkout.stripe.com/pay/cs_test_abcdef1234567890"
UsageSummary:
type: object
description: |
Today's usage summary for the authenticated organization.
Counters reset at UTC midnight.
required:
- organizationId
- date
- tokensIssued
- agentsRegistered
- credentialsGenerated
properties:
organizationId:
type: string
format: uuid
description: Organization the usage data belongs to.
example: "org-1234-5678-abcd-ef01"
date:
type: string
format: date
description: The calendar date (UTC) this summary covers.
example: "2026-04-07"
tokensIssued:
type: integer
description: Number of OAuth 2.0 tokens issued today.
minimum: 0
example: 4201
agentsRegistered:
type: integer
description: Number of new agents registered today.
minimum: 0
example: 3
credentialsGenerated:
type: integer
description: Number of new agent credentials generated today.
minimum: 0
example: 5
apiCallsTotal:
type: integer
description: Total API calls across all endpoints today.
minimum: 0
example: 12450
StripeWebhookResponse:
type: object
description: Acknowledgement response for a received Stripe webhook event.
required:
- received
properties:
received:
type: boolean
description: Always `true` when the webhook was processed successfully.
example: true
ErrorResponse:
type: object
description: Standard error response envelope.
required:
- code
- message
properties:
code:
type: string
example: "VALIDATION_ERROR"
message:
type: string
example: "Missing Stripe-Signature header."
details:
type: object
additionalProperties: true
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."
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:
/billing/checkout:
post:
operationId: createBillingCheckoutSession
tags:
- Billing Checkout
summary: Create a Stripe Checkout Session
description: |
Creates a Stripe Checkout Session for the authenticated organization
to upgrade their subscription plan.
The organization ID is read from the `organization_id` claim in the
Bearer JWT — the caller does not need to provide it in the request body.
The `checkoutUrl` in the response is a Stripe-hosted checkout page.
Redirect the authenticated user to this URL to complete payment.
Requires a valid Bearer JWT. The `organization_id` claim must be present in the token.
security:
- BearerAuth: []
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/CheckoutRequest'
example:
successUrl: "https://my-app.example.com/dashboard?billing=success"
cancelUrl: "https://my-app.example.com/dashboard?billing=cancel"
responses:
'201':
description: Stripe Checkout Session created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/CheckoutResponse'
example:
checkoutUrl: "https://checkout.stripe.com/pay/cs_test_abcdef1234567890"
'400':
description: Validation error — organization_id missing from token.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "organization_id is required in token."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
description: Unexpected error or Stripe API error.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "STRIPE_ERROR"
message: "Failed to create Stripe Checkout Session. Please try again."
/billing/webhook:
post:
operationId: handleStripeWebhook
tags:
- Billing Webhook
summary: Receive Stripe webhook events
description: |
Receives webhook events from Stripe's delivery system. This endpoint is
**unauthenticated** — authentication is provided by Stripe's HMAC signature
in the `Stripe-Signature` header.
**Body format:** The request body MUST be the raw JSON payload as sent by
Stripe (not parsed JSON). The `Content-Type` is `application/json` but
the body is read as a raw `Buffer` for signature verification.
**Signature verification:** The `Stripe-Signature` header is required.
If absent or invalid, the request is rejected with `400`.
**Supported events processed:**
- `checkout.session.completed` — Activates subscription after payment
- `customer.subscription.deleted` — Downgrades plan on cancellation
- `invoice.payment_failed` — Handles failed renewals
security: []
parameters:
- name: Stripe-Signature
in: header
required: true
description: |
HMAC signature from Stripe for payload verification.
Format: `t=<timestamp>,v1=<signature>,...`
schema:
type: string
example: "t=1492774577,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a05bd412fbc2a2bzo..."
requestBody:
required: true
content:
application/json:
schema:
type: object
description: Raw Stripe event payload (read as Buffer internally).
additionalProperties: true
example:
id: "evt_1234567890"
object: "event"
type: "checkout.session.completed"
data:
object:
id: "cs_test_abcdef1234567890"
customer: "cus_abc123"
responses:
'200':
description: Webhook event received and processed successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/StripeWebhookResponse'
example:
received: true
'400':
description: Missing or invalid Stripe-Signature header, or malformed payload.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
missingSignature:
summary: Missing Stripe-Signature header
value:
code: "VALIDATION_ERROR"
message: "Missing Stripe-Signature header."
invalidSignature:
summary: Signature verification failed
value:
code: "STRIPE_SIGNATURE_INVALID"
message: "Webhook signature verification failed."
'500':
$ref: '#/components/responses/InternalServerError'
/billing/usage:
get:
operationId: getBillingUsage
tags:
- Usage
summary: Get today's usage summary
description: |
Returns the usage summary for the authenticated organization
for the current calendar day (UTC).
Usage counters reset at UTC midnight.
The `organization_id` claim is read from the Bearer JWT.
Requires a valid Bearer JWT.
security:
- BearerAuth: []
responses:
'200':
description: Usage summary returned successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/UsageSummary'
example:
organizationId: "org-1234-5678-abcd-ef01"
date: "2026-04-07"
tokensIssued: 4201
agentsRegistered: 3
credentialsGenerated: 5
apiCallsTotal: 12450
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'

View File

@@ -0,0 +1,548 @@
openapi: 3.0.3
info:
title: SentryAgent.ai — Compliance & SOC 2 Type II Service
version: 1.0.0
description: |
The Compliance Service exposes endpoints supporting SentryAgent.ai's
**SOC 2 Type II** audit readiness programme.
Two categories of control are surfaced:
**Audit chain verification** (`GET /audit/verify`) — Confirms cryptographic
integrity of the immutable audit log chain across an optional date range.
This endpoint provides auditors and compliance tooling with a single call to
assert that no audit events have been tampered with, deleted, or reordered
after initial capture.
**SOC 2 control status** (`GET /compliance/controls`) — Returns a live status
snapshot for each of the five in-scope SOC 2 Trust Services Criteria controls
monitored by the platform. Designed as a lightweight, public health-style
endpoint so that monitoring infrastructure can poll without bearer credentials.
**In-scope SOC 2 controls:**
| Control ID | Name | Description |
|------------|------|-------------|
| `CC6.1` | Encryption at Rest | Verifies database and secrets store encryption is active |
| `CC6.7` | TLS Enforcement | Confirms TLS 1.2+ is enforced on all inbound connections |
| `CC7.2` | Audit Log Integrity | Validates audit chain hash continuity |
| `CC9.2` | Secrets Rotation | Checks that all managed secrets are within rotation policy |
| `CC7.1` | Webhook Dead-Letter Monitoring | Asserts dead-letter queue depth is within threshold |
**Required scope (audit chain verify only):** `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 Chain
description: Cryptographic integrity verification of the immutable audit event chain
- name: Compliance Controls
description: SOC 2 Type II control status — public health-style monitoring endpoint
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:
ChainVerificationResult:
type: object
description: |
Result of an audit event chain integrity verification run.
The audit log is structured as a hash-linked chain. Each event stores a
reference to the hash of the preceding event. `verified: true` means every
event in the requested window was checked and no breaks in the chain were
detected.
When `verified` is `false`, `brokenAtEventId` identifies the first event
where the chain integrity check failed, enabling targeted forensic investigation.
required:
- verified
- checkedCount
- brokenAtEventId
properties:
verified:
type: boolean
description: >
`true` if every audit event in the checked range maintains an unbroken
cryptographic hash chain; `false` if at least one chain break was detected.
example: true
checkedCount:
type: integer
description: Total number of audit events examined during this verification run.
minimum: 0
example: 2847
brokenAtEventId:
type: string
format: uuid
nullable: true
description: >
UUID of the first audit event where chain continuity failed, or `null`
when `verified` is `true`. Only the first detected break is reported;
subsequent events are not checked after a break is found.
example: null
fromDate:
type: string
format: date-time
description: >
The ISO 8601 lower bound of the date range that was verified.
Present only when a `fromDate` query parameter was supplied.
example: "2026-03-01T00:00:00.000Z"
toDate:
type: string
format: date-time
description: >
The ISO 8601 upper bound of the date range that was verified.
Present only when a `toDate` query parameter was supplied.
example: "2026-03-31T23:59:59.999Z"
ControlStatus:
type: string
description: Operational status of a SOC 2 control at the time of the last check.
enum:
- passing
- failing
- unknown
example: passing
ComplianceControl:
type: object
description: Status record for a single SOC 2 Trust Services Criteria control.
required:
- id
- name
- status
- lastChecked
properties:
id:
type: string
description: SOC 2 Trust Services Criteria control identifier.
enum:
- CC6.1
- CC6.7
- CC7.2
- CC9.2
- CC7.1
example: "CC6.1"
name:
type: string
description: Human-readable name of the control.
example: "Encryption at Rest"
status:
$ref: '#/components/schemas/ControlStatus'
lastChecked:
type: string
format: date-time
description: ISO 8601 timestamp of the most recent automated check for this control.
example: "2026-03-31T06:00:00.000Z"
ComplianceControlsResponse:
type: object
description: SOC 2 compliance control status summary for all in-scope controls.
required:
- controls
properties:
controls:
type: array
description: Status record for each of the five in-scope SOC 2 controls.
minItems: 5
maxItems: 5
items:
$ref: '#/components/schemas/ComplianceControl'
example:
- id: "CC6.1"
name: "Encryption at Rest"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC6.7"
name: "TLS Enforcement"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.2"
name: "Audit Log Integrity"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC9.2"
name: "Secrets Rotation"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.1"
name: "Webhook Dead-Letter Monitoring"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
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."
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 verify the audit chain."
TooManyRequests:
description: |
Rate limit exceeded. Retry after the reset time indicated in `X-RateLimit-Reset`.
headers:
X-RateLimit-Limit:
schema:
type: integer
description: Maximum requests allowed per minute.
example: 30
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:
/audit/verify:
get:
operationId: verifyAuditChain
tags:
- Audit Chain
summary: Verify audit log chain integrity
description: |
Triggers a full integrity verification pass over the immutable audit event
chain. Each event in the log contains a cryptographic hash of the previous
event; this endpoint traverses the chain and confirms no breaks exist.
**Use cases:**
- Auditor evidence collection for SOC 2 Type II assessment
- Continuous compliance monitoring (cron-driven)
- Incident response — confirm audit log has not been tampered with
**Requires:** Bearer token with `audit:read` scope.
**Rate limit:** 30 requests/minute per `client_id`. Audit chain verification
is a computationally intensive operation and is rate-limited more aggressively
than standard read endpoints. For continuous monitoring, poll no more than
once per minute.
**Date range filtering:** Supply `fromDate` and/or `toDate` to restrict
verification to a specific window. When omitted, the entire retained audit
log is verified. `fromDate` must be before or equal to `toDate` when both
are provided.
**Result interpretation:**
- `verified: true` — chain is intact across all checked events
- `verified: false` — at least one chain break detected; `brokenAtEventId`
identifies the first affected event
security:
- BearerAuth: []
parameters:
- name: fromDate
in: query
description: |
ISO 8601 date-time lower bound for the verification window (inclusive).
When omitted, verification starts from the earliest available audit event.
Must be before or equal to `toDate` when both are supplied.
required: false
schema:
type: string
format: date-time
example: "2026-03-01T00:00:00.000Z"
- name: toDate
in: query
description: |
ISO 8601 date-time upper bound for the verification window (inclusive).
When omitted, verification runs up to and including the most recent
audit event. Must be after or equal to `fromDate` when both are supplied.
required: false
schema:
type: string
format: date-time
example: "2026-03-31T23:59:59.999Z"
responses:
'200':
description: |
Audit chain verification completed. Inspect `verified` to determine
whether chain integrity is intact. A `200` is returned regardless of
whether verification passed or failed — check the response body.
headers:
X-RateLimit-Limit:
schema:
type: integer
description: Maximum requests allowed per minute for this endpoint.
example: 30
X-RateLimit-Remaining:
schema:
type: integer
description: Requests remaining in the current rate limit window.
example: 29
X-RateLimit-Reset:
schema:
type: integer
description: Unix timestamp when the rate limit window resets.
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/ChainVerificationResult'
examples:
chainIntact:
summary: Verification passed — chain is intact
value:
verified: true
checkedCount: 2847
brokenAtEventId: null
fromDate: "2026-03-01T00:00:00.000Z"
toDate: "2026-03-31T23:59:59.999Z"
chainBroken:
summary: Verification failed — chain break detected
value:
verified: false
checkedCount: 1203
brokenAtEventId: "c4d5e6f7-a8b9-0123-cdef-456789012345"
fromDate: "2026-03-01T00:00:00.000Z"
toDate: "2026-03-31T23:59:59.999Z"
noDateRange:
summary: Full log verified (no date range supplied)
value:
verified: true
checkedCount: 18504
brokenAtEventId: null
'400':
description: Invalid query parameter value or date range.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
invalidFromDate:
summary: fromDate is not a valid ISO 8601 date-time
value:
code: "VALIDATION_ERROR"
message: "Invalid query parameter value."
details:
field: "fromDate"
reason: "Must be a valid ISO 8601 date-time string (e.g. 2026-03-01T00:00:00.000Z)."
invalidToDate:
summary: toDate is not a valid ISO 8601 date-time
value:
code: "VALIDATION_ERROR"
message: "Invalid query parameter value."
details:
field: "toDate"
reason: "Must be a valid ISO 8601 date-time string (e.g. 2026-03-31T23:59:59.999Z)."
invalidDateRange:
summary: fromDate is after toDate
value:
code: "VALIDATION_ERROR"
message: "Invalid date range."
details:
reason: "fromDate must be before or equal to toDate."
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'
/compliance/controls:
get:
operationId: getComplianceControls
tags:
- Compliance Controls
summary: Get SOC 2 control status summary
description: |
Returns a live status snapshot for each of the five in-scope SOC 2 Type II
Trust Services Criteria controls monitored by the SentryAgent.ai platform.
**No authentication required.** This endpoint is intentionally public
(analogous to a health check) so that external monitoring infrastructure,
status pages, and audit tooling can poll it without bearer credentials.
**Controls monitored:**
| Control ID | Name | What is checked |
|------------|------|-----------------|
| `CC6.1` | Encryption at Rest | Database and secrets store encryption is active and configured |
| `CC6.7` | TLS Enforcement | TLS 1.2+ is enforced on all platform inbound connections |
| `CC7.2` | Audit Log Integrity | Audit chain hash continuity — shorthand of `/audit/verify` |
| `CC9.2` | Secrets Rotation | All managed secrets are within the rotation policy window |
| `CC7.1` | Webhook Dead-Letter Monitoring | Dead-letter queue depth is within the acceptable threshold |
**Status values:**
- `passing` — control is operating within policy
- `failing` — control has breached policy; immediate attention required
- `unknown` — automated check could not complete (e.g. dependency unavailable)
**Caching note:** Responses may be cached for up to 60 seconds by
intermediate proxies. The `lastChecked` field on each control indicates
the timestamp of the most recent automated evaluation.
**Rate limit:** 120 requests/minute per IP address.
security: []
responses:
'200':
description: SOC 2 control status summary returned successfully.
headers:
Cache-Control:
schema:
type: string
description: >
Downstream caches may serve this response for up to 60 seconds.
example: "public, max-age=60"
X-RateLimit-Limit:
schema:
type: integer
description: Maximum requests allowed per minute for this endpoint.
example: 120
X-RateLimit-Remaining:
schema:
type: integer
description: Requests remaining in the current rate limit window.
example: 119
X-RateLimit-Reset:
schema:
type: integer
description: Unix timestamp when the rate limit window resets.
example: 1743155400
content:
application/json:
schema:
$ref: '#/components/schemas/ComplianceControlsResponse'
examples:
allPassing:
summary: All controls passing
value:
controls:
- id: "CC6.1"
name: "Encryption at Rest"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC6.7"
name: "TLS Enforcement"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.2"
name: "Audit Log Integrity"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC9.2"
name: "Secrets Rotation"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.1"
name: "Webhook Dead-Letter Monitoring"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
oneControlFailing:
summary: One control failing (secrets rotation overdue)
value:
controls:
- id: "CC6.1"
name: "Encryption at Rest"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC6.7"
name: "TLS Enforcement"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.2"
name: "Audit Log Integrity"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC9.2"
name: "Secrets Rotation"
status: "failing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.1"
name: "Webhook Dead-Letter Monitoring"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
unknownControl:
summary: One control in unknown state (dependency unavailable)
value:
controls:
- id: "CC6.1"
name: "Encryption at Rest"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC6.7"
name: "TLS Enforcement"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.2"
name: "Audit Log Integrity"
status: "unknown"
lastChecked: "2026-03-31T05:00:00.000Z"
- id: "CC9.2"
name: "Secrets Rotation"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
- id: "CC7.1"
name: "Webhook Dead-Letter Monitoring"
status: "passing"
lastChecked: "2026-03-31T06:00:00.000Z"
'429':
$ref: '#/components/responses/TooManyRequests'
'500':
$ref: '#/components/responses/InternalServerError'

View File

@@ -0,0 +1,480 @@
openapi: "3.0.3"
info:
title: SentryAgent.ai — A2A Delegation (Agent-to-Agent)
version: 1.0.0
description: |
Agent-to-Agent (A2A) delegation endpoints for the SentryAgent.ai AgentIdP platform.
The delegation subsystem enables an authenticated agent (the *delegator*) to grant
a subset of its own scopes to another agent (the *delegatee*) for a limited time.
This creates a cryptographically-signed delegation chain, suitable for multi-agent
orchestration patterns.
**All endpoints require a valid Bearer JWT.**
**Feature flag:** When `A2A_ENABLED=false` these routes are not registered (return 404).
**Delegation rules:**
- The delegatee must be in the same tenant as the delegator.
- Delegated scopes must be a strict subset of the delegator's own scopes.
- TTL minimum: 60 seconds; maximum: 86400 seconds (24 hours).
- Each delegation chain has a unique `chainId` (UUID).
- Revoking a chain is idempotent — revoking an already-revoked chain succeeds.
servers:
- url: http://localhost:3000/api/v1
description: Local development server
- url: https://api.sentryagent.ai/v1
description: Production server
tags:
- name: A2A Delegation
description: Agent-to-Agent delegation chain management
components:
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: |
JWT access token obtained via `POST /token`.
Include as `Authorization: Bearer <token>`.
schemas:
DelegationChain:
type: object
description: A delegation chain record as returned by the API.
required:
- id
- tenantId
- delegatorAgentId
- delegateeAgentId
- scopes
- delegationToken
- ttlSeconds
- issuedAt
- expiresAt
- createdAt
properties:
id:
type: string
format: uuid
description: Unique identifier of the delegation chain.
readOnly: true
example: "chain-abcd-1234-5678-ef01"
tenantId:
type: string
format: uuid
description: Organization (tenant) that owns this delegation.
readOnly: true
example: "org-1234-5678-abcd-ef01"
delegatorAgentId:
type: string
format: uuid
description: UUID of the agent granting authority.
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
delegateeAgentId:
type: string
format: uuid
description: UUID of the agent receiving delegated authority.
example: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
type: array
items:
type: string
description: OAuth 2.0 scopes granted by this delegation chain.
example:
- "agents:read"
delegationToken:
type: string
description: |
Opaque delegation token string that the delegatee presents to verify authority.
This token encodes the chain metadata and is HMAC-signed.
example: "chain-abcd-1234-5678-ef01.1743151200.1743237600"
signature:
type: string
description: HMAC-SHA256 signature of the delegation token payload.
example: "3a7f2b9c..."
ttlSeconds:
type: integer
description: Delegation lifetime in seconds.
minimum: 60
maximum: 86400
example: 3600
issuedAt:
type: string
format: date-time
readOnly: true
example: "2026-04-07T09:00:00.000Z"
expiresAt:
type: string
format: date-time
readOnly: true
example: "2026-04-07T10:00:00.000Z"
revokedAt:
type: string
format: date-time
nullable: true
description: Timestamp when this chain was revoked. Null if still active or expired naturally.
readOnly: true
example: null
createdAt:
type: string
format: date-time
readOnly: true
example: "2026-04-07T09:00:00.000Z"
CreateDelegationRequest:
type: object
description: Request body for creating a new agent-to-agent delegation chain.
required:
- delegateeAgentId
- scopes
- ttlSeconds
properties:
delegateeAgentId:
type: string
format: uuid
description: |
UUID of the agent to receive delegated authority.
Must be in the same tenant as the delegator (caller).
example: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
type: array
items:
type: string
description: |
Scopes to delegate. Must be a strict subset of the delegator's current token scopes.
At least one scope must be specified.
minItems: 1
example:
- "agents:read"
ttlSeconds:
type: integer
description: Delegation lifetime in seconds. Minimum: 60; Maximum: 86400 (24 hours).
minimum: 60
maximum: 86400
example: 3600
VerifyDelegationRequest:
type: object
description: Request body for verifying a delegation token.
required:
- delegationToken
properties:
delegationToken:
type: string
description: The delegation token string to verify.
example: "chain-abcd-1234-5678-ef01.1743151200.1743237600"
DelegationVerificationResult:
type: object
description: |
Result of verifying a delegation token.
Returns `valid: false` for expired or revoked tokens without throwing.
required:
- valid
- chainId
- delegatorAgentId
- delegateeAgentId
- scopes
- issuedAt
- expiresAt
properties:
valid:
type: boolean
description: Whether the delegation token is currently valid (active, not expired, not revoked).
example: true
chainId:
type: string
format: uuid
description: UUID of the delegation chain.
example: "chain-abcd-1234-5678-ef01"
delegatorAgentId:
type: string
format: uuid
example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
delegateeAgentId:
type: string
format: uuid
example: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
type: array
items:
type: string
example:
- "agents:read"
issuedAt:
type: string
format: date-time
example: "2026-04-07T09:00:00.000Z"
expiresAt:
type: string
format: date-time
example: "2026-04-07T10:00:00.000Z"
revokedAt:
type: string
format: date-time
nullable: true
example: null
ErrorResponse:
type: object
description: Standard error response envelope.
required:
- code
- message
properties:
code:
type: string
example: "DELEGATION_NOT_FOUND"
message:
type: string
example: "Delegation chain with the specified ID was not found."
details:
type: object
additionalProperties: true
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: Delegation chain not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "DELEGATION_NOT_FOUND"
message: "Delegation chain with the specified ID was not found."
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:
/oauth2/token/delegate:
post:
operationId: createDelegation
tags:
- A2A Delegation
summary: Create an A2A delegation chain
description: |
Creates a new agent-to-agent delegation chain. The authenticated agent
(the *delegator*) grants a subset of its own scopes to the `delegateeAgentId`.
A cryptographically-signed `delegationToken` is returned. The delegatee
presents this token to `POST /oauth2/token/verify-delegation` to prove
delegated authority.
**Validation:**
- Delegatee must be in the same organization as the delegator.
- `scopes` must be a strict subset of the delegator's current token scopes.
- `ttlSeconds` must be between 60 and 86400.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateDelegationRequest'
example:
delegateeAgentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
- "agents:read"
ttlSeconds: 3600
responses:
'201':
description: Delegation chain created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/DelegationChain'
example:
id: "chain-abcd-1234-5678-ef01"
tenantId: "org-1234-5678-abcd-ef01"
delegatorAgentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
delegateeAgentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
- "agents:read"
delegationToken: "chain-abcd-1234-5678-ef01.1743151200.1743154800"
signature: "3a7f2b9c..."
ttlSeconds: 3600
issuedAt: "2026-04-07T09:00:00.000Z"
expiresAt: "2026-04-07T10:00:00.000Z"
revokedAt: null
createdAt: "2026-04-07T09:00:00.000Z"
'400':
description: Validation error in request body.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
examples:
scopeExceedsOwn:
summary: Requested scope exceeds delegator's own scopes
value:
code: "SCOPE_EXCEEDS_DELEGATOR"
message: "Delegated scopes must be a subset of the delegator's own token scopes."
details:
requested: ["agents:write"]
available: ["agents:read"]
invalidTtl:
summary: TTL out of range
value:
code: "VALIDATION_ERROR"
message: "ttlSeconds must be between 60 and 86400."
crossTenant:
summary: Delegatee in different tenant
value:
code: "CROSS_TENANT_DELEGATION"
message: "Delegatee agent must be in the same organization as the delegator."
'401':
$ref: '#/components/responses/Unauthorized'
'404':
description: Delegatee agent not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "AGENT_NOT_FOUND"
message: "Delegatee agent with the specified ID was not found."
'500':
$ref: '#/components/responses/InternalServerError'
/oauth2/token/verify-delegation:
post:
operationId: verifyDelegation
tags:
- A2A Delegation
summary: Verify a delegation token
description: |
Verifies a delegation token and returns the chain details if valid.
Returns `valid: true` with full chain metadata when the token is valid
(exists, not expired, not revoked).
Returns `valid: false` when the token is expired or revoked.
Does not throw an error for inactive tokens — always returns `200`.
Requires a valid Bearer JWT (any authenticated agent may verify a delegation).
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/VerifyDelegationRequest'
example:
delegationToken: "chain-abcd-1234-5678-ef01.1743151200.1743154800"
responses:
'200':
description: Delegation verification result returned.
content:
application/json:
schema:
$ref: '#/components/schemas/DelegationVerificationResult'
examples:
valid:
summary: Valid delegation token
value:
valid: true
chainId: "chain-abcd-1234-5678-ef01"
delegatorAgentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
delegateeAgentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
- "agents:read"
issuedAt: "2026-04-07T09:00:00.000Z"
expiresAt: "2026-04-07T10:00:00.000Z"
revokedAt: null
expired:
summary: Expired delegation token
value:
valid: false
chainId: "chain-abcd-1234-5678-ef01"
delegatorAgentId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
delegateeAgentId: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
scopes:
- "agents:read"
issuedAt: "2026-04-06T09:00:00.000Z"
expiresAt: "2026-04-06T10:00:00.000Z"
revokedAt: null
'400':
description: Missing or malformed `delegationToken` field.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
example:
code: "VALIDATION_ERROR"
message: "The 'delegationToken' field is required."
'401':
$ref: '#/components/responses/Unauthorized'
'500':
$ref: '#/components/responses/InternalServerError'
/oauth2/token/delegate/{chainId}:
parameters:
- name: chainId
in: path
required: true
description: UUID of the delegation chain to revoke.
schema:
type: string
format: uuid
example: "chain-abcd-1234-5678-ef01"
delete:
operationId: revokeDelegation
tags:
- A2A Delegation
summary: Revoke a delegation chain
description: |
Immediately revokes a delegation chain.
After revocation, `POST /oauth2/token/verify-delegation` will return
`valid: false` for the revoked chain's token.
**Idempotent** — revoking an already-revoked chain returns `204` without error.
Only the delegator agent or an admin may revoke a chain.
Requires a valid Bearer JWT.
responses:
'204':
description: Delegation chain revoked successfully (or was already revoked). No response body.
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
$ref: '#/components/responses/NotFound'
'500':
$ref: '#/components/responses/InternalServerError'

Some files were not shown because too many files have changed in this diff Show More