Compare commits
4 Commits
8fd6823581
...
0fb00256b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb00256b4 | ||
|
|
e327c41211 | ||
|
|
eea885db04 | ||
|
|
0fad328329 |
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-03
|
||||
@@ -0,0 +1,81 @@
|
||||
## Context
|
||||
|
||||
SentryAgent.ai AgentIdP has completed four major phases: core OAuth2/agent identity (Phase 1), enterprise features — multi-tenancy, DIDs, OIDC, federation, webhooks, SOC2 (Phase 3), production hardening and developer tooling (Phase 4), and SDK ecosystem + A2A authorization (Phase 5). The platform is feature-complete for identity; Phase 6 makes it commercially competitive by adding visibility (analytics), monetization (tiers), and ecosystem certification (AGNTCY compliance).
|
||||
|
||||
**Current state constraints:**
|
||||
- Stack: Node.js 18+, TypeScript strict, Express, PostgreSQL 14+, Redis 7+, React/Next.js 14 (portal)
|
||||
- Existing Redis rate limiter: per-agent token bucket keys (`rate:agent:<id>`)
|
||||
- Existing tenant model: `tenants` table with `plan` column (currently unused for enforcement)
|
||||
- Existing audit log: `audit_events` table (used for SOC2, not analytics)
|
||||
- Portal: Next.js 14 at `portal/`, API explorer via Stoplight Elements added in Phase 5
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Deliver tenant-facing analytics (token trends, agent activity heatmaps) via new API + portal views
|
||||
- Enforce tier-based rate limits (free/pro/enterprise) transparently via existing middleware layer
|
||||
- Expose self-service tier upgrade endpoint (integration with existing Stripe billing from Phase 4)
|
||||
- Generate AGNTCY-standard compliance reports and agent cards on demand
|
||||
- Provide an interoperability test suite for AGNTCY ecosystem partners
|
||||
|
||||
**Non-Goals:**
|
||||
- Real-time streaming analytics (WebSocket push) — batch/polling sufficient for Phase 6
|
||||
- Billing tier downgrades (upgrade only; downgrade requires support intervention)
|
||||
- Multi-region analytics aggregation — single-region PostgreSQL sufficient
|
||||
- Full AGNTCY federation partner onboarding UI — report + card export is the deliverable
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Analytics Storage — Dedicated Table vs. Querying `audit_events`
|
||||
|
||||
**Decision**: New `analytics_events` table with pre-aggregated daily rollups.
|
||||
|
||||
**Rationale**: `audit_events` is an append-only SOC2 ledger with a Merkle chain — querying it for analytics requires full scans and breaks its integrity guarantees. A separate `analytics_events` table with `(tenant_id, date, metric_type, count)` schema enables indexed range queries without touching the audit chain.
|
||||
|
||||
**Alternative considered**: Materialized views over `audit_events` — rejected because the Merkle chain structure makes column selection fragile and any schema change risks audit integrity.
|
||||
|
||||
### D2: Tier Enforcement — Middleware vs. Per-Endpoint Guards
|
||||
|
||||
**Decision**: New `tierEnforcement` Express middleware inserted before route handlers, reading tier config from a `TIER_CONFIG` constant (not DB) for zero-latency enforcement.
|
||||
|
||||
**Rationale**: Per-endpoint guards duplicate logic and are easy to miss on new routes. A single middleware checking `req.tenant.tier` against `TIER_CONFIG[tier].limits` is DRY and auditable. Redis keys prefixed `rate:tier:<tier>:<tenant_id>` track daily call counts.
|
||||
|
||||
**Alternative considered**: DB-driven tier limits per tenant row — rejected because it adds a DB read to every request; tier configs change rarely and belong in config, not data.
|
||||
|
||||
### D3: AGNTCY Compliance Report — Generated On-Demand vs. Scheduled
|
||||
|
||||
**Decision**: Generated on-demand (`GET /api/compliance/report`) with 5-minute Redis cache.
|
||||
|
||||
**Rationale**: Compliance reports are requested infrequently (audit cycles, partner onboarding) and must reflect current state. A 5-minute cache prevents redundant computation without staleness risk. No background job needed.
|
||||
|
||||
**Alternative considered**: Nightly scheduled generation — rejected because it could be stale at the moment of an audit request.
|
||||
|
||||
### D4: recharts Integration — Dashboard vs. Portal
|
||||
|
||||
**Decision**: Analytics views live in `portal/src/pages/analytics/` as new Next.js pages using `recharts` components. No changes to the existing `dashboard/` directory (Phase 3 admin dashboard).
|
||||
|
||||
**Rationale**: The developer portal (`portal/`) is the tenant-facing surface. The `dashboard/` is the internal admin view. Tenant analytics belong in the portal alongside existing usage stats.
|
||||
|
||||
### D5: Delivery Sequence
|
||||
|
||||
**Decision**: WS3 (Analytics) → WS4 (API Tiers) → WS6 (AGNTCY Compliance), sequential.
|
||||
|
||||
**Rationale**: Analytics requires the `analytics_events` table; tiers build on top of existing Redis + tenant model and can reuse analytics data for usage display; compliance requires all prior capabilities to be stable for report generation. Each workstream is independently deployable.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Analytics table growth** → Mitigation: daily rollup rows (not per-event rows); add `created_at` index with a 90-day retention policy via PostgreSQL `pg_partman` or a simple cron DELETE.
|
||||
- **Tier enforcement bypass during Redis failure** → Mitigation: fail-open on Redis errors (log + allow) to avoid availability impact; flag in health endpoint.
|
||||
- **recharts bundle size in portal** → Mitigation: dynamic import (`next/dynamic`) for chart components — only loaded on analytics pages.
|
||||
- **AGNTCY spec version drift** → Mitigation: pin to AGNTCY agent-card schema v1.0; add schema version field to report output; test suite validates against pinned version.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **WS3**: Run migration `023_add_analytics_events.sql` → deploy API changes → deploy portal analytics pages
|
||||
2. **WS4**: Run migration `024_add_tenant_tiers.sql` → deploy tier middleware (with `TIER_ENFORCEMENT=false` feature flag initially) → smoke test → enable flag
|
||||
3. **WS6**: No migration required — compliance report queries existing tables; deploy endpoint + test suite
|
||||
4. **Rollback**: Each WS has a feature flag (`ANALYTICS_ENABLED`, `TIER_ENFORCEMENT`, `COMPLIANCE_ENABLED`). Set to `false` to disable without rollback.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None — scope is fully defined from Phase 5 deferred workstreams. AGNTCY agent-card schema version confirmed as v1.0 per Phase 3 W3C DIDs implementation.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
Phase 5 delivered the Rust SDK, A2A authorization, and developer experience improvements — completing the core platform. Phase 6 activates market expansion: analytics to demonstrate value to tenants, tiered API access to monetize at scale, and AGNTCY compliance certification to unlock enterprise and ecosystem partnerships. These three workstreams were scoped and approved in Phase 5 but deferred to keep Phase 5 focused; the work is already designed and ready to execute.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Advanced Analytics Dashboard (WS3)**: New `/api/analytics/` endpoints exposing token issuance trends, agent activity heatmaps, and per-tenant usage breakdowns. New React dashboard views using `recharts` for visualization. Tenant admins can see real-time and historical usage.
|
||||
- **API Gateway Tiers (WS4)**: Multi-tier rate limiting — free (10 agents, 1,000 calls/day), pro (100 agents, 50,000 calls/day), enterprise (unlimited). Self-service tier upgrade endpoint. Rate limit headers on all API responses. Enforcement layer integrated with existing Redis rate limiter.
|
||||
- **AGNTCY Compliance Certification (WS6)**: Auto-generated compliance report endpoint (`GET /api/compliance/report`) covering agent identity, audit trail, credential rotation, and federation status. Agent card export in AGNTCY-standard JSON format. Interoperability test suite validating AGNTCY protocol conformance.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `analytics-dashboard`: Per-tenant usage analytics — token issuance trends, agent activity heatmaps, daily/weekly/monthly breakdowns via new API endpoints and React dashboard views
|
||||
- `api-gateway-tiers`: Tiered rate limiting (free/pro/enterprise), self-service upgrade endpoint, rate limit headers, Redis-backed enforcement per tenant and tier
|
||||
- `agntcy-compliance`: AGNTCY compliance report generation, agent card export (AGNTCY JSON format), interoperability conformance test suite
|
||||
|
||||
### Modified Capabilities
|
||||
- `web-dashboard`: New analytics and tier-management views added to existing React portal (no breaking changes to existing dashboard routes)
|
||||
|
||||
## Impact
|
||||
|
||||
- **New dependencies**: `recharts`, `date-fns` (analytics charts); no new backend deps (compliance and tier enforcement use existing stack)
|
||||
- **API surface**: 8–12 new endpoints across analytics, tiers, and compliance namespaces
|
||||
- **Database**: New `analytics_events` table (time-series), `tenant_tiers` table; migration files required
|
||||
- **Redis**: Tier-aware rate limit keys (`rate:tier:<tier>:<tenant_id>`) alongside existing per-agent keys
|
||||
- **Portal**: 2–3 new page routes in `portal/src/pages/` using `recharts` components
|
||||
- **No breaking changes** to existing endpoints or agent identity model
|
||||
@@ -0,0 +1,82 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: System generates an on-demand AGNTCY compliance report
|
||||
The system SHALL expose `GET /api/compliance/report` returning a structured JSON compliance report covering: agent identity verification, audit trail integrity, credential rotation status, and federation readiness. The report SHALL be generated on-demand and cached in Redis for 5 minutes (`compliance:report:<tenant_id>`).
|
||||
|
||||
The report SHALL include:
|
||||
- `generated_at`: ISO 8601 timestamp
|
||||
- `tenant_id`: tenant identifier
|
||||
- `agntcy_schema_version`: pinned version string (e.g., `"1.0"`)
|
||||
- `sections`: array of compliance sections, each with `name`, `status` (`pass`/`fail`/`warn`), and `details`
|
||||
- `overall_status`: `pass` if all sections pass, `fail` if any section fails, `warn` if any section warns
|
||||
|
||||
#### Scenario: Successful compliance report generation
|
||||
- **WHEN** an authenticated tenant admin calls `GET /api/compliance/report`
|
||||
- **THEN** the response SHALL be HTTP 200 with a JSON compliance report containing all required sections
|
||||
|
||||
#### Scenario: Compliance report is served from cache within TTL
|
||||
- **WHEN** `GET /api/compliance/report` is called twice within 5 minutes
|
||||
- **THEN** the second response SHALL be served from Redis cache (not recomputed) and include a `X-Cache: HIT` header
|
||||
|
||||
#### Scenario: Compliance report requires authentication
|
||||
- **WHEN** `GET /api/compliance/report` is called without a valid JWT
|
||||
- **THEN** the response SHALL be HTTP 401
|
||||
|
||||
### Requirement: Compliance report covers agent identity verification
|
||||
The compliance report SHALL include an `agent-identity` section validating that all active agents have: a valid DID:WEB identifier, a current credential (not expired), and an AGNTCY agent card on record. The section SHALL report `pass` only if all agents satisfy all three checks.
|
||||
|
||||
#### Scenario: All agents compliant — agent identity section passes
|
||||
- **WHEN** all active agents have valid DIDs, non-expired credentials, and agent cards
|
||||
- **THEN** the `agent-identity` section SHALL have `status: "pass"`
|
||||
|
||||
#### Scenario: Agent with expired credential — section warns
|
||||
- **WHEN** one or more active agents have credentials expiring within 7 days
|
||||
- **THEN** the `agent-identity` section SHALL have `status: "warn"` with details listing affected agents
|
||||
|
||||
#### Scenario: Agent missing DID — section fails
|
||||
- **WHEN** one or more active agents have no DID:WEB identifier
|
||||
- **THEN** the `agent-identity` section SHALL have `status: "fail"` with details listing affected agents
|
||||
|
||||
### Requirement: Compliance report covers audit trail integrity
|
||||
The compliance report SHALL include an `audit-trail` section verifying the Merkle chain integrity of the `audit_events` table for the tenant. The section SHALL report `pass` if the chain is unbroken, `fail` if any hash mismatch is detected.
|
||||
|
||||
#### Scenario: Intact audit chain passes
|
||||
- **WHEN** the Merkle chain for all audit events is valid
|
||||
- **THEN** the `audit-trail` section SHALL have `status: "pass"` with the total event count
|
||||
|
||||
#### Scenario: Broken audit chain fails
|
||||
- **WHEN** a hash mismatch is detected in the audit event chain
|
||||
- **THEN** the `audit-trail` section SHALL have `status: "fail"` with the sequence number of the first invalid event
|
||||
|
||||
### Requirement: System exports AGNTCY-standard agent cards
|
||||
The system SHALL expose `GET /api/compliance/agent-cards` returning an array of all active agents as AGNTCY agent card objects in the standard JSON format. Each agent card SHALL include: `id` (DID:WEB), `name`, `capabilities` (from agent metadata), `endpoint`, `created_at`, and `agntcy_schema_version`.
|
||||
|
||||
#### Scenario: Successful agent card export
|
||||
- **WHEN** an authenticated tenant admin calls `GET /api/compliance/agent-cards`
|
||||
- **THEN** the response SHALL be HTTP 200 with a JSON array of agent card objects for all active agents
|
||||
|
||||
#### Scenario: Agent card export respects tenant isolation
|
||||
- **WHEN** tenant A exports agent cards
|
||||
- **THEN** the response SHALL contain ONLY agents belonging to tenant A
|
||||
|
||||
#### Scenario: Empty tenant returns empty array
|
||||
- **WHEN** the tenant has no active agents
|
||||
- **THEN** the response SHALL be HTTP 200 with an empty array
|
||||
|
||||
### Requirement: AGNTCY interoperability test suite validates protocol conformance
|
||||
The system SHALL include an interoperability test suite at `tests/agntcy-conformance/` that validates the platform's conformance to the AGNTCY agent identity protocol. The suite SHALL test: agent registration (DID:WEB creation), token issuance for agent clients, A2A delegation chain creation and verification, and compliance report generation. All tests SHALL pass in CI.
|
||||
|
||||
#### Scenario: Conformance suite passes in CI environment
|
||||
- **WHEN** `npm run test:agntcy-conformance` is executed in a CI environment with a live test database
|
||||
- **THEN** all conformance tests SHALL pass with exit code 0
|
||||
|
||||
#### Scenario: Conformance suite fails on missing DID endpoint
|
||||
- **WHEN** the DID resolution endpoint is unreachable
|
||||
- **THEN** the conformance test for DID:WEB SHALL fail with a descriptive error message
|
||||
|
||||
### Requirement: Compliance features can be toggled via feature flag
|
||||
The system SHALL respect a `COMPLIANCE_ENABLED` environment variable (default: `true`). When `COMPLIANCE_ENABLED=false`, all `/api/compliance/*` endpoints SHALL return HTTP 404.
|
||||
|
||||
#### Scenario: Compliance disabled returns 404
|
||||
- **WHEN** `COMPLIANCE_ENABLED=false` and `GET /api/compliance/report` is called
|
||||
- **THEN** the response SHALL be HTTP 404
|
||||
@@ -0,0 +1,67 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Analytics events are recorded for token issuances and agent activity
|
||||
The system SHALL record an analytics event row in `analytics_events` for every OAuth2 token issuance and every agent registration, update, and deactivation, scoped to the tenant. Each row SHALL contain: `tenant_id`, `date` (UTC date), `metric_type` (e.g., `token_issued`, `agent_registered`), and `count` (aggregated daily).
|
||||
|
||||
#### Scenario: Token issuance recorded in analytics
|
||||
- **WHEN** `POST /oauth2/token` succeeds for a tenant
|
||||
- **THEN** the `analytics_events` table SHALL have a row incremented for `metric_type=token_issued` for that tenant on today's UTC date
|
||||
|
||||
#### Scenario: Agent registration recorded in analytics
|
||||
- **WHEN** `POST /api/agents` succeeds
|
||||
- **THEN** the `analytics_events` table SHALL have a row incremented for `metric_type=agent_registered` for the owning tenant on today's UTC date
|
||||
|
||||
#### Scenario: Analytics recording does not block the primary response
|
||||
- **WHEN** the analytics write fails (e.g., DB timeout)
|
||||
- **THEN** the primary API response SHALL still succeed and the error SHALL be logged
|
||||
|
||||
### Requirement: Tenant can retrieve token issuance trend data
|
||||
The system SHALL expose `GET /api/analytics/tokens` returning daily token issuance counts for the authenticated tenant over a configurable date range (default: last 30 days, max: 90 days). Response SHALL include an array of `{ date, count }` objects sorted ascending by date.
|
||||
|
||||
#### Scenario: Successful token trend retrieval
|
||||
- **WHEN** an authenticated tenant admin calls `GET /api/analytics/tokens?days=30`
|
||||
- **THEN** the response SHALL be HTTP 200 with a JSON array of up to 30 `{ date, count }` objects
|
||||
|
||||
#### Scenario: Date range exceeding maximum is rejected
|
||||
- **WHEN** the request specifies `days` greater than 90
|
||||
- **THEN** the response SHALL be HTTP 400 with an error indicating the maximum is 90 days
|
||||
|
||||
#### Scenario: Tenant sees only their own data
|
||||
- **WHEN** tenant A retrieves analytics
|
||||
- **THEN** the response SHALL contain only rows where `tenant_id` matches tenant A — no cross-tenant data leakage
|
||||
|
||||
### Requirement: Tenant can retrieve agent activity heatmap data
|
||||
The system SHALL expose `GET /api/analytics/agents/activity` returning per-agent event counts grouped by day-of-week and hour-of-day for the authenticated tenant (last 30 days). Response SHALL include an array of `{ agent_id, dow, hour, count }` objects suitable for rendering a heatmap.
|
||||
|
||||
#### Scenario: Successful activity heatmap retrieval
|
||||
- **WHEN** an authenticated tenant admin calls `GET /api/analytics/agents/activity`
|
||||
- **THEN** the response SHALL be HTTP 200 with a JSON array of activity buckets
|
||||
|
||||
#### Scenario: Tenant with no agents returns empty array
|
||||
- **WHEN** the tenant has no registered agents
|
||||
- **THEN** the response SHALL be HTTP 200 with an empty array
|
||||
|
||||
### Requirement: Tenant can retrieve a per-agent usage summary
|
||||
The system SHALL expose `GET /api/analytics/agents` returning a list of agents with their total token issuances for the current billing period (current calendar month). Response SHALL include `{ agent_id, name, token_count }` sorted descending by `token_count`.
|
||||
|
||||
#### Scenario: Successful per-agent summary
|
||||
- **WHEN** an authenticated tenant admin calls `GET /api/analytics/agents`
|
||||
- **THEN** the response SHALL be HTTP 200 with a sorted list of agent usage summaries
|
||||
|
||||
#### Scenario: Analytics endpoint requires authentication
|
||||
- **WHEN** a request to any `/api/analytics/*` endpoint is made without a valid JWT
|
||||
- **THEN** the response SHALL be HTTP 401
|
||||
|
||||
### Requirement: Analytics data is accessible via the developer portal
|
||||
The portal SHALL include an Analytics page at `/analytics` with:
|
||||
- A line chart showing token issuance trend (last 30 days) rendered with `recharts`
|
||||
- A heatmap chart showing agent activity by day/hour rendered with `recharts`
|
||||
- A table listing per-agent usage for the current month
|
||||
|
||||
#### Scenario: Analytics page loads token trend chart
|
||||
- **WHEN** a tenant admin navigates to `/analytics` in the portal
|
||||
- **THEN** the page SHALL render a line chart with token issuance data from `GET /api/analytics/tokens`
|
||||
|
||||
#### Scenario: Analytics page is code-split (lazy loaded)
|
||||
- **WHEN** the portal bundle is built
|
||||
- **THEN** `recharts` and analytics page components SHALL be in a separate chunk (via `next/dynamic`) and NOT included in the main bundle
|
||||
@@ -0,0 +1,72 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tenants are assigned to a tier with defined rate limits
|
||||
The system SHALL maintain a `tenant_tiers` table linking each tenant to a tier (`free`, `pro`, `enterprise`) with associated limits: max agents, max API calls per day, and max token issuances per day. Tier configuration SHALL be defined in a `TIER_CONFIG` constant (not in the database) to enable zero-latency enforcement.
|
||||
|
||||
Tier defaults:
|
||||
- `free`: 10 agents, 1,000 API calls/day, 1,000 token issuances/day
|
||||
- `pro`: 100 agents, 50,000 API calls/day, 50,000 token issuances/day
|
||||
- `enterprise`: unlimited (no enforcement)
|
||||
|
||||
#### Scenario: New tenant defaults to free tier
|
||||
- **WHEN** a new tenant is created via `POST /api/tenants`
|
||||
- **THEN** a `tenant_tiers` row SHALL be created with `tier=free`
|
||||
|
||||
#### Scenario: Enterprise tier bypasses all rate limits
|
||||
- **WHEN** a tenant on the `enterprise` tier makes any API call
|
||||
- **THEN** no rate limit check SHALL be applied for that tenant
|
||||
|
||||
### Requirement: API calls are rate-limited per tenant tier
|
||||
The system SHALL enforce daily API call limits per tenant using Redis keys `rate:tier:calls:<tenant_id>` with a TTL aligned to UTC midnight. When a tenant exceeds their daily call limit, subsequent requests SHALL be rejected.
|
||||
|
||||
#### Scenario: Tenant within call limit proceeds normally
|
||||
- **WHEN** a tenant on the `free` tier has made fewer than 1,000 API calls today
|
||||
- **THEN** the request SHALL proceed and the counter SHALL be incremented
|
||||
|
||||
#### Scenario: Tenant exceeding daily call limit is rejected
|
||||
- **WHEN** a tenant on the `free` tier has made 1,000 or more API calls today
|
||||
- **THEN** any further API request SHALL return HTTP 429 with `Retry-After` header set to seconds until UTC midnight
|
||||
|
||||
#### Scenario: Rate limit headers are present on all responses
|
||||
- **WHEN** any authenticated API request is made
|
||||
- **THEN** the response SHALL include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers reflecting the tenant's daily call allowance
|
||||
|
||||
### Requirement: Agent count is enforced per tenant tier
|
||||
The system SHALL prevent tenants from creating more agents than their tier allows. The check SHALL occur at agent creation time.
|
||||
|
||||
#### Scenario: Tenant at agent limit cannot create new agent
|
||||
- **WHEN** a `free` tier tenant already has 10 agents and calls `POST /api/agents`
|
||||
- **THEN** the response SHALL be HTTP 429 with an error message indicating the agent limit and a link to upgrade
|
||||
|
||||
#### Scenario: Tenant below agent limit can create agents
|
||||
- **WHEN** a `free` tier tenant has fewer than 10 agents and calls `POST /api/agents`
|
||||
- **THEN** the request SHALL proceed normally
|
||||
|
||||
### Requirement: Tier enforcement can be toggled via feature flag
|
||||
The system SHALL respect a `TIER_ENFORCEMENT` environment variable (default: `true`). When `TIER_ENFORCEMENT=false`, all tier limit checks SHALL be bypassed and requests SHALL proceed as if on the `enterprise` tier. Rate limit headers SHALL still be present but reflect unlimited limits.
|
||||
|
||||
#### Scenario: Tier enforcement disabled allows all requests
|
||||
- **WHEN** `TIER_ENFORCEMENT=false` and a `free` tier tenant has exceeded their daily limit
|
||||
- **THEN** the request SHALL succeed with HTTP 200 (no 429 returned)
|
||||
|
||||
### Requirement: Tenant can self-service upgrade their tier
|
||||
The system SHALL expose `POST /api/tiers/upgrade` allowing an authenticated tenant admin to request an upgrade to a higher tier. The endpoint SHALL validate the target tier is higher than the current tier and initiate a Stripe checkout session for the new tier's price. On successful payment webhook, the `tenant_tiers` row SHALL be updated.
|
||||
|
||||
#### Scenario: Successful tier upgrade initiation
|
||||
- **WHEN** a `free` tier tenant calls `POST /api/tiers/upgrade` with `{ "target_tier": "pro" }`
|
||||
- **THEN** the response SHALL be HTTP 200 with a Stripe checkout URL
|
||||
|
||||
#### Scenario: Downgrade attempt is rejected
|
||||
- **WHEN** a `pro` tier tenant calls `POST /api/tiers/upgrade` with `{ "target_tier": "free" }`
|
||||
- **THEN** the response SHALL be HTTP 400 with an error indicating downgrades require support
|
||||
|
||||
#### Scenario: Stripe webhook updates tier on payment success
|
||||
- **WHEN** Stripe sends a `checkout.session.completed` event for a tier upgrade
|
||||
- **THEN** the tenant's `tier` SHALL be updated to the purchased tier within 5 seconds
|
||||
|
||||
### Requirement: Tenant can view their current tier and usage
|
||||
The system SHALL expose `GET /api/tiers/status` returning the tenant's current tier, daily limits, current usage counts (calls today, tokens today, agent count), and time until daily limit reset.
|
||||
|
||||
#### Scenario: Tier status returns current usage
|
||||
- **WHEN** an authenticated tenant admin calls `GET /api/tiers/status`
|
||||
- **THEN** the response SHALL be HTTP 200 with tier name, limits, and live usage counters
|
||||
@@ -0,0 +1,27 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Portal includes an Analytics page
|
||||
The portal SHALL include a new page at `/analytics` accessible to authenticated tenant admins. The page SHALL render: a line chart (token issuance trend, last 30 days), an activity heatmap (agent activity by day/hour), and a per-agent usage table for the current month. All chart components SHALL be lazy-loaded via `next/dynamic`.
|
||||
|
||||
#### Scenario: Analytics page is accessible to tenant admins
|
||||
- **WHEN** an authenticated tenant admin navigates to `/analytics`
|
||||
- **THEN** the page SHALL render without error and display the token trend chart
|
||||
|
||||
#### Scenario: Analytics page redirects unauthenticated users
|
||||
- **WHEN** an unauthenticated user navigates to `/analytics`
|
||||
- **THEN** the portal SHALL redirect to the login page
|
||||
|
||||
### Requirement: Portal includes a Tier & Billing page
|
||||
The portal SHALL include a new page at `/settings/tier` accessible to authenticated tenant admins showing: current tier name, daily limits (agents, API calls, token issuances), current usage vs. limit, and an "Upgrade" button that initiates a Stripe checkout session for tenants not on the `enterprise` tier.
|
||||
|
||||
#### Scenario: Free tier tenant sees upgrade prompt
|
||||
- **WHEN** a `free` tier tenant admin navigates to `/settings/tier`
|
||||
- **THEN** the page SHALL display current limits, current usage, and an enabled "Upgrade to Pro" button
|
||||
|
||||
#### Scenario: Enterprise tier tenant sees no upgrade prompt
|
||||
- **WHEN** an `enterprise` tier tenant admin navigates to `/settings/tier`
|
||||
- **THEN** the page SHALL display "Enterprise — Unlimited" and SHALL NOT display an upgrade button
|
||||
|
||||
#### Scenario: Upgrade button initiates Stripe checkout
|
||||
- **WHEN** a tenant admin clicks "Upgrade to Pro" on the tier page
|
||||
- **THEN** the portal SHALL call `POST /api/tiers/upgrade` and redirect to the returned Stripe checkout URL
|
||||
@@ -0,0 +1,76 @@
|
||||
## 1. Foundation & Database Migrations
|
||||
|
||||
- [x] 1.1 Create migration `023_add_analytics_events.sql` — `analytics_events(id, tenant_id, date, metric_type, count, PRIMARY KEY(tenant_id, date, metric_type))`
|
||||
- [x] 1.2 Create migration `024_add_tenant_tiers.sql` — `tenant_tiers(tenant_id FK, tier ENUM('free','pro','enterprise'), created_at, updated_at)`
|
||||
- [x] 1.3 Add `TIER_CONFIG` constant to `src/config/tiers.ts` with free/pro/enterprise limits
|
||||
- [x] 1.4 Add `ANALYTICS_ENABLED`, `TIER_ENFORCEMENT`, `COMPLIANCE_ENABLED` env vars to `.env.example` and `src/config/env.ts`
|
||||
- [x] 1.5 Run migrations in test environment and verify table creation
|
||||
|
||||
## 2. WS3 — Analytics Backend
|
||||
|
||||
- [x] 2.1 Create `src/services/AnalyticsService.ts` — `recordEvent(tenantId, metricType)`, `getTokenTrend(tenantId, days)`, `getAgentActivity(tenantId)`, `getAgentUsageSummary(tenantId)` with JSDoc
|
||||
- [x] 2.2 Add analytics event recording to `OAuth2Service.issueToken()` — fire-and-forget (non-blocking)
|
||||
- [x] 2.3 Add analytics event recording to `AgentService.create()` and `AgentService.deactivate()`
|
||||
- [x] 2.4 Create `src/controllers/AnalyticsController.ts` — GET /api/analytics/tokens, GET /api/analytics/agents/activity, GET /api/analytics/agents
|
||||
- [x] 2.5 Create `src/routes/analytics.ts` and register under `/api/analytics` in `src/app.ts` (guarded by `ANALYTICS_ENABLED` flag)
|
||||
- [x] 2.6 Add input validation: `days` param capped at 90, tenant scoping enforced on all queries
|
||||
|
||||
## 3. WS3 — Analytics Portal Pages
|
||||
|
||||
- [x] 3.1 Add `recharts` and `date-fns` to `portal/package.json`
|
||||
- [x] 3.2 Create `portal/src/components/charts/TokenTrendChart.tsx` — recharts LineChart, lazy-loadable
|
||||
- [x] 3.3 Create `portal/src/components/charts/AgentHeatmap.tsx` — recharts custom heatmap, lazy-loadable
|
||||
- [x] 3.4 Create `portal/src/pages/analytics/index.tsx` — uses `next/dynamic` for chart components, fetches from `/api/analytics/*`
|
||||
- [x] 3.5 Add `/analytics` route to portal navigation (`portal/src/components/Sidebar.tsx` or equivalent)
|
||||
- [x] 3.6 Verify `next build` succeeds and analytics chunk is separate from main bundle
|
||||
|
||||
## 4. WS4 — API Gateway Tiers Backend
|
||||
|
||||
- [x] 4.1 Create `src/middleware/tierEnforcement.ts` — reads `req.tenant.tier`, checks Redis `rate:tier:calls:<tenant_id>` against `TIER_CONFIG`, sets rate limit headers on all responses
|
||||
- [x] 4.2 Register `tierEnforcement` middleware in `src/app.ts` after auth middleware, before routes (skip when `TIER_ENFORCEMENT=false`)
|
||||
- [x] 4.3 Add agent count enforcement in `AgentService.create()` — query active agent count vs. tier limit, throw typed `TierLimitError` if exceeded
|
||||
- [x] 4.4 Create `src/services/TierService.ts` — `getStatus(tenantId)`, `initiateUpgrade(tenantId, targetTier)`, `applyUpgrade(tenantId, tier)` (called from Stripe webhook)
|
||||
- [x] 4.5 Create `src/controllers/TierController.ts` — GET /api/tiers/status, POST /api/tiers/upgrade
|
||||
- [x] 4.6 Create `src/routes/tiers.ts` and register under `/api/tiers` in `src/app.ts`
|
||||
- [x] 4.7 Update Stripe webhook handler (`src/services/BillingService.ts`) to call `TierService.applyUpgrade()` on `checkout.session.completed` for tier upgrade events
|
||||
- [x] 4.8 Add `TierLimitError` to error hierarchy in `src/errors/index.ts` — HTTP 429, `tier_limit_exceeded` code
|
||||
|
||||
## 5. WS4 — Tier Portal Page
|
||||
|
||||
- [x] 5.1 Create `portal/src/pages/settings/tier.tsx` — displays current tier, limits, usage, upgrade button
|
||||
- [x] 5.2 Upgrade button calls `POST /api/tiers/upgrade` and redirects to Stripe checkout URL
|
||||
- [x] 5.3 Enterprise tier view shows "Unlimited" with no upgrade button
|
||||
- [x] 5.4 Add `/settings/tier` to portal navigation
|
||||
|
||||
## 6. WS6 — AGNTCY Compliance Backend
|
||||
|
||||
- [x] 6.1 Create `src/services/ComplianceService.ts` — `generateReport(tenantId)` building all compliance sections, `exportAgentCards(tenantId)`, Redis cache with 5-minute TTL
|
||||
- [x] 6.2 Implement `agent-identity` section: check all active agents for valid DID, non-expired credential, agent card presence
|
||||
- [x] 6.3 Implement `audit-trail` section: verify Merkle chain integrity for tenant's audit events
|
||||
- [x] 6.4 Implement agent card export: map agent records to AGNTCY agent card JSON schema v1.0
|
||||
- [x] 6.5 Create `src/controllers/ComplianceController.ts` — GET /api/compliance/report, GET /api/compliance/agent-cards
|
||||
- [x] 6.6 Create `src/routes/compliance.ts` and register under `/api/compliance` in `src/app.ts` (guarded by `COMPLIANCE_ENABLED` flag)
|
||||
|
||||
## 7. WS6 — AGNTCY Conformance Test Suite
|
||||
|
||||
- [x] 7.1 Create `tests/agntcy-conformance/` directory with Jest config
|
||||
- [x] 7.2 Write conformance test: agent registration creates DID:WEB identifier
|
||||
- [x] 7.3 Write conformance test: token issuance for agent client
|
||||
- [x] 7.4 Write conformance test: A2A delegation chain create + verify
|
||||
- [x] 7.5 Write conformance test: compliance report generation returns valid structure
|
||||
- [x] 7.6 Add `test:agntcy-conformance` npm script to `package.json`
|
||||
|
||||
## 8. QA & Release
|
||||
|
||||
- [x] 8.1 Write unit tests for `AnalyticsService` — event recording, trend query, heatmap query, usage summary (>80% coverage)
|
||||
- [x] 8.2 Write unit tests for `TierService` — tier status, upgrade initiation, tier limit enforcement
|
||||
- [x] 8.3 Write unit tests for `ComplianceService` — report generation, agent card export, cache hit/miss
|
||||
- [x] 8.4 Write integration tests for all analytics endpoints (`/api/analytics/*`)
|
||||
- [x] 8.5 Write integration tests for all tier endpoints (`/api/tiers/*`) including Stripe webhook
|
||||
- [x] 8.6 Write integration tests for compliance endpoints (`/api/compliance/*`) including feature flag behavior
|
||||
- [x] 8.7 Run full test suite — verify >80% coverage on all new services
|
||||
- [x] 8.8 Run `tsc --noEmit` across API, portal, and CLI — zero errors
|
||||
- [x] 8.9 Run `next build` for portal — verify clean build and code-split chunks
|
||||
- [x] 8.10 Verify feature flags: `ANALYTICS_ENABLED=false` → 404 on analytics routes; `TIER_ENFORCEMENT=false` → no 429s; `COMPLIANCE_ENABLED=false` → 404 on compliance routes
|
||||
- [x] 8.11 Run AGNTCY conformance suite — all tests pass
|
||||
- [x] 8.12 Commit Phase 6 with message `feat(phase-6): WS3+WS4+WS6 — Analytics, Tiers, AGNTCY Compliance`
|
||||
@@ -13,7 +13,8 @@
|
||||
"db:migrate": "ts-node scripts/migrate.ts",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"format": "prettier --write src/**/*.ts",
|
||||
"load-test": "k6 run tests/load/agent-registration.js && k6 run tests/load/token-issuance.js && k6 run tests/load/credential-rotation.js"
|
||||
"load-test": "k6 run tests/load/agent-registration.js && k6 run tests/load/token-issuance.js && k6 run tests/load/credential-rotation.js",
|
||||
"test:agntcy-conformance": "jest --config tests/agntcy-conformance/jest.config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@open-policy-agent/opa-wasm": "^1.10.0",
|
||||
|
||||
351
portal/app/analytics/page.tsx
Normal file
351
portal/app/analytics/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AnalyticsPage — Tenant analytics dashboard for the SentryAgent portal.
|
||||
*
|
||||
* Displays:
|
||||
* - Token issuance trend (last 30 days) via TokenTrendChart (lazy-loaded)
|
||||
* - Agent activity by day-of-week via AgentHeatmap (lazy-loaded)
|
||||
* - Per-agent usage table for the current billing period
|
||||
*
|
||||
* All chart components are code-split via `next/dynamic` so that recharts
|
||||
* is excluded from the main bundle.
|
||||
*
|
||||
* Protected route: redirects to `/login` when no JWT is present (via useAuth).
|
||||
*
|
||||
* @module app/analytics/page
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import type { TokenTrendDataPoint } from '@/components/charts/TokenTrendChart';
|
||||
import type { AgentActivityBucket } from '@/components/charts/AgentHeatmap';
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Lazy-loaded chart components (recharts stays out of the main bundle)
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
const TokenTrendChart = dynamic(
|
||||
() => import('@/components/charts/TokenTrendChart'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<ChartSkeleton label="Loading token trend chart…" height={300} />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const AgentHeatmap = dynamic(
|
||||
() => import('@/components/charts/AgentHeatmap'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<ChartSkeleton label="Loading activity chart…" height={300} />
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* API response types
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/** A single entry from `GET /api/analytics/agents`. */
|
||||
interface AgentUsageSummary {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
/** Root shape of `GET /api/analytics/agents/activity`. */
|
||||
type AgentActivityResponse = AgentActivityBucket[];
|
||||
|
||||
/** Root shape of `GET /api/analytics/tokens`. */
|
||||
type TokenTrendResponse = TokenTrendDataPoint[];
|
||||
|
||||
/** Root shape of `GET /api/analytics/agents`. */
|
||||
type AgentUsageResponse = AgentUsageSummary[];
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Page state
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/** Loading / error / data state for a single async fetch. */
|
||||
interface FetchState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function initFetchState<T>(): FetchState<T> {
|
||||
return { data: null, loading: true, error: null };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Helpers
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Fetches a JSON endpoint with an Authorization bearer token.
|
||||
*
|
||||
* @param url - Absolute URL to fetch
|
||||
* @param token - JWT bearer token
|
||||
* @returns Parsed JSON of type T
|
||||
* @throws Error with a descriptive message on non-2xx or network failure
|
||||
*/
|
||||
async function fetchWithAuth<T>(url: string, token: string): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(body.message ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Sub-components
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/** Props for ChartSkeleton. */
|
||||
interface ChartSkeletonProps {
|
||||
label: string;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder shown while a dynamic chart chunk is loading.
|
||||
*
|
||||
* @param props - ChartSkeletonProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
function ChartSkeleton({ label, height }: ChartSkeletonProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className="flex animate-pulse items-center justify-center rounded-xl bg-slate-100 text-sm text-slate-400"
|
||||
style={{ height }}
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for SectionCard. */
|
||||
interface SectionCardProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple card wrapper for dashboard sections.
|
||||
*
|
||||
* @param props - SectionCardProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
function SectionCard({ title, children }: SectionCardProps): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">{title}</h2>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for ErrorBanner. */
|
||||
interface ErrorBannerProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline error banner for failed data fetches.
|
||||
*
|
||||
* @param props - ErrorBannerProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
|
||||
return (
|
||||
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Page component
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Renders the analytics dashboard page.
|
||||
*
|
||||
* Checks authentication via `useAuth` (redirects to /login if no token).
|
||||
* Fetches analytics data from the AgentIdP API using the stored JWT.
|
||||
*
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function AnalyticsPage(): React.ReactElement {
|
||||
const { token, loading: authLoading } = useAuth(true);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const [tokenTrend, setTokenTrend] =
|
||||
useState<FetchState<TokenTrendResponse>>(initFetchState);
|
||||
const [agentActivity, setAgentActivity] =
|
||||
useState<FetchState<AgentActivityResponse>>(initFetchState);
|
||||
const [agentUsage, setAgentUsage] =
|
||||
useState<FetchState<AgentUsageResponse>>(initFetchState);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait until auth state is resolved and token is available
|
||||
if (authLoading || token === null) return;
|
||||
|
||||
void fetchWithAuth<TokenTrendResponse>(
|
||||
`${apiUrl}/api/analytics/tokens?days=30`,
|
||||
token,
|
||||
)
|
||||
.then((data) => setTokenTrend({ data, loading: false, error: null }))
|
||||
.catch((err: unknown) =>
|
||||
setTokenTrend({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load token trend',
|
||||
}),
|
||||
);
|
||||
|
||||
void fetchWithAuth<AgentActivityResponse>(
|
||||
`${apiUrl}/api/analytics/agents/activity`,
|
||||
token,
|
||||
)
|
||||
.then((data) => setAgentActivity({ data, loading: false, error: null }))
|
||||
.catch((err: unknown) =>
|
||||
setAgentActivity({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load agent activity',
|
||||
}),
|
||||
);
|
||||
|
||||
void fetchWithAuth<AgentUsageResponse>(
|
||||
`${apiUrl}/api/analytics/agents`,
|
||||
token,
|
||||
)
|
||||
.then((data) => setAgentUsage({ data, loading: false, error: null }))
|
||||
.catch((err: unknown) =>
|
||||
setAgentUsage({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Failed to load agent usage',
|
||||
}),
|
||||
);
|
||||
}, [authLoading, token, apiUrl]);
|
||||
|
||||
// While auth state is being resolved, show a full-page loading state
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<p className="text-slate-500">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// token === null means useAuth is redirecting to /login; render nothing
|
||||
if (token === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
{/* Page header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-4xl font-extrabold text-slate-900">Analytics</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Token issuance and agent activity for the last 30 days
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8">
|
||||
{/* Token Trend */}
|
||||
<SectionCard title="Token Issuance Trend (Last 30 Days)">
|
||||
{tokenTrend.error !== null && (
|
||||
<ErrorBanner message={tokenTrend.error} />
|
||||
)}
|
||||
{tokenTrend.loading && tokenTrend.error === null && (
|
||||
<ChartSkeleton label="Loading token trend chart…" height={300} />
|
||||
)}
|
||||
{tokenTrend.data !== null && (
|
||||
<TokenTrendChart data={tokenTrend.data} />
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Agent Activity Heatmap */}
|
||||
<SectionCard title="Agent Activity by Day of Week (Last 30 Days)">
|
||||
{agentActivity.error !== null && (
|
||||
<ErrorBanner message={agentActivity.error} />
|
||||
)}
|
||||
{agentActivity.loading && agentActivity.error === null && (
|
||||
<ChartSkeleton label="Loading activity chart…" height={300} />
|
||||
)}
|
||||
{agentActivity.data !== null && (
|
||||
<AgentHeatmap data={agentActivity.data} />
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Per-Agent Usage Table */}
|
||||
<SectionCard title="Per-Agent Usage (Current Month)">
|
||||
{agentUsage.error !== null && (
|
||||
<ErrorBanner message={agentUsage.error} />
|
||||
)}
|
||||
{agentUsage.loading && agentUsage.error === null && (
|
||||
<div className="animate-pulse space-y-2">
|
||||
{[1, 2, 3].map((n) => (
|
||||
<div key={n} className="h-10 rounded bg-slate-100" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{agentUsage.data !== null && agentUsage.data.length === 0 && (
|
||||
<p className="text-sm text-slate-500">
|
||||
No agents have issued tokens this month.
|
||||
</p>
|
||||
)}
|
||||
{agentUsage.data !== null && agentUsage.data.length > 0 && (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50">
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">
|
||||
Agent
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">
|
||||
Tokens Issued
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agentUsage.data.map(({ agent_id, name, token_count }, i) => (
|
||||
<tr
|
||||
key={agent_id}
|
||||
className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}
|
||||
>
|
||||
<td className="px-4 py-3 text-slate-700">
|
||||
<span className="font-medium">{name}</span>
|
||||
<span className="ml-2 font-mono text-xs text-slate-400">
|
||||
{agent_id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-medium tabular-nums text-slate-900">
|
||||
{token_count.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
portal/app/login/page.tsx
Normal file
137
portal/app/login/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* LoginPage — Tenant admin sign-in page for the SentryAgent developer portal.
|
||||
*
|
||||
* Posts credentials to `POST /api/tenants/login` on the AgentIdP backend and
|
||||
* stores the returned JWT in localStorage so that protected pages (e.g.
|
||||
* /analytics) can read it via the `useAuth` hook.
|
||||
*
|
||||
* @module app/login/page
|
||||
*/
|
||||
|
||||
import React, { useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AUTH_TOKEN_KEY } from '@/hooks/useAuth';
|
||||
|
||||
/** Shape of the successful login response from the AgentIdP API. */
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the portal login form and handles credential submission.
|
||||
*
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const router = useRouter();
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/**
|
||||
* Submits the login form and stores the JWT on success.
|
||||
*
|
||||
* @param e - The form submission event
|
||||
*/
|
||||
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiUrl}/api/tenants/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
setError(body.message ?? 'Invalid credentials. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as LoginResponse;
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, data.access_token);
|
||||
router.replace('/analytics');
|
||||
} catch {
|
||||
setError('Network error. Please check your connection and try again.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[70vh] items-center justify-center px-6 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<h1 className="text-3xl font-extrabold text-slate-900">Sign in</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Access your SentryAgent tenant dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => void handleSubmit(e)}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<label
|
||||
htmlFor="email"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Email address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error !== null && (
|
||||
<p className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700 disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
portal/app/settings/tier/page.tsx
Normal file
432
portal/app/settings/tier/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TierPage — Tenant tier & billing dashboard for the SentryAgent portal.
|
||||
*
|
||||
* Displays:
|
||||
* - Current tier name (styled badge)
|
||||
* - Daily limits table: agents, API calls, token issuances
|
||||
* - Current usage vs. limit for each metric with a visual progress bar
|
||||
* - Time until daily reset
|
||||
* - For free/pro tiers: an "Upgrade" button that calls POST /api/v1/tiers/upgrade
|
||||
* and redirects to the returned Stripe checkoutUrl
|
||||
* - For enterprise tier: an "Enterprise — Unlimited" label with no upgrade button
|
||||
*
|
||||
* Protected route: redirects to /login when no JWT is present (via useAuth).
|
||||
*
|
||||
* @module app/settings/tier/page
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Constants
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/** The ordered sequence of upgradeable tiers. */
|
||||
const UPGRADE_PATH: Record<string, string> = {
|
||||
free: 'pro',
|
||||
pro: 'enterprise',
|
||||
};
|
||||
|
||||
/** Human-readable tier labels. */
|
||||
const TIER_LABELS: Record<string, string> = {
|
||||
free: 'Free',
|
||||
pro: 'Pro',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
|
||||
/** Tailwind badge colour classes per tier. */
|
||||
const TIER_BADGE_CLASSES: Record<string, string> = {
|
||||
free: 'bg-slate-100 text-slate-700',
|
||||
pro: 'bg-brand-100 text-brand-700',
|
||||
enterprise: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* API response types
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/** Per-metric limit and usage returned by GET /api/v1/tiers/status. */
|
||||
interface TierMetric {
|
||||
/** Hard daily ceiling, or null when unlimited (enterprise). */
|
||||
limit: number | null;
|
||||
/** Current usage count for today. */
|
||||
used: number;
|
||||
}
|
||||
|
||||
/** Shape returned by GET /api/v1/tiers/status. */
|
||||
interface TierStatus {
|
||||
/** Canonical tier identifier: "free" | "pro" | "enterprise". */
|
||||
tier: string;
|
||||
/** Daily metrics. */
|
||||
limits: {
|
||||
agents: TierMetric;
|
||||
api_calls: TierMetric;
|
||||
token_issuances: TierMetric;
|
||||
};
|
||||
/** ISO-8601 timestamp for when the daily counters reset (UTC midnight). */
|
||||
reset_at: string;
|
||||
}
|
||||
|
||||
/** Shape returned by POST /api/v1/tiers/upgrade. */
|
||||
interface UpgradeResponse {
|
||||
/** Stripe Checkout URL to redirect the user to. */
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Helpers
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Fetches a JSON endpoint with an Authorization bearer token.
|
||||
*
|
||||
* @param url - Absolute URL to fetch
|
||||
* @param token - JWT bearer token
|
||||
* @param options - Optional RequestInit overrides
|
||||
* @returns Parsed JSON of type T
|
||||
* @throws Error with a descriptive message on non-2xx or network failure
|
||||
*/
|
||||
async function fetchWithAuth<T>(
|
||||
url: string,
|
||||
token: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { message?: string };
|
||||
throw new Error(body.message ?? `Request failed: ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of whole minutes (rounded up) until the given ISO-8601
|
||||
* reset timestamp.
|
||||
*
|
||||
* @param resetAt - ISO-8601 date string
|
||||
* @returns Human-readable time-until string, e.g. "3 h 42 min"
|
||||
*/
|
||||
function formatTimeUntilReset(resetAt: string): string {
|
||||
const msUntil = new Date(resetAt).getTime() - Date.now();
|
||||
if (msUntil <= 0) return 'resetting now';
|
||||
const totalMinutes = Math.ceil(msUntil / 60_000);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
if (hours === 0) return `${minutes} min`;
|
||||
return `${hours} h ${minutes} min`;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Sub-components
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/** Props for UsageRow. */
|
||||
interface UsageRowProps {
|
||||
/** Display label, e.g. "API Calls". */
|
||||
label: string;
|
||||
/** Current usage count. */
|
||||
used: number;
|
||||
/** Hard ceiling, or null for unlimited. */
|
||||
limit: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single row in the usage table with a visual progress bar.
|
||||
*
|
||||
* @param props - UsageRowProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
function UsageRow({ label, used, limit }: UsageRowProps): React.ReactElement {
|
||||
const isUnlimited = limit === null;
|
||||
const pct = isUnlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
|
||||
const barColour =
|
||||
pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-400' : 'bg-brand-500';
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-700">{label}</td>
|
||||
<td className="px-4 py-3 text-right text-sm tabular-nums text-slate-900">
|
||||
{used.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm tabular-nums text-slate-500">
|
||||
{isUnlimited ? '∞' : limit.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{isUnlimited ? (
|
||||
<span className="text-xs font-medium text-emerald-600">Unlimited</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-24 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${barColour}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{pct}%</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for TierBadge. */
|
||||
interface TierBadgeProps {
|
||||
tier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a styled pill badge for the given tier name.
|
||||
*
|
||||
* @param props - TierBadgeProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
function TierBadge({ tier }: TierBadgeProps): React.ReactElement {
|
||||
const colourClass =
|
||||
TIER_BADGE_CLASSES[tier] ?? 'bg-slate-100 text-slate-700';
|
||||
const label = TIER_LABELS[tier] ?? tier;
|
||||
return (
|
||||
<span
|
||||
className={`inline-block rounded-full px-3 py-1 text-sm font-semibold ${colourClass}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Props for ErrorBanner. */
|
||||
interface ErrorBannerProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline error banner for failed data fetches or actions.
|
||||
*
|
||||
* @param props - ErrorBannerProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
|
||||
return (
|
||||
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Page component
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Renders the Tier & Billing settings page.
|
||||
*
|
||||
* Checks authentication via `useAuth` (redirects to /login if no token).
|
||||
* Fetches tier status from the AgentIdP API using the stored JWT.
|
||||
*
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function TierPage(): React.ReactElement {
|
||||
const { token, loading: authLoading } = useAuth(true);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
const [status, setStatus] = useState<TierStatus | null>(null);
|
||||
const [fetchLoading, setFetchLoading] = useState(true);
|
||||
const [fetchError, setFetchError] = useState<string | null>(null);
|
||||
|
||||
const [upgrading, setUpgrading] = useState(false);
|
||||
const [upgradeError, setUpgradeError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || token === null) return;
|
||||
|
||||
void fetchWithAuth<TierStatus>(`${apiUrl}/api/v1/tiers/status`, token)
|
||||
.then((data) => {
|
||||
setStatus(data);
|
||||
setFetchLoading(false);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setFetchError(
|
||||
err instanceof Error ? err.message : 'Failed to load tier status',
|
||||
);
|
||||
setFetchLoading(false);
|
||||
});
|
||||
}, [authLoading, token, apiUrl]);
|
||||
|
||||
/**
|
||||
* Initiates a tier upgrade by calling POST /api/v1/tiers/upgrade and
|
||||
* redirecting the browser to the returned Stripe Checkout URL.
|
||||
*/
|
||||
async function handleUpgrade(): Promise<void> {
|
||||
if (token === null || status === null) return;
|
||||
|
||||
const targetTier = UPGRADE_PATH[status.tier];
|
||||
if (targetTier === undefined) return;
|
||||
|
||||
setUpgrading(true);
|
||||
setUpgradeError(null);
|
||||
|
||||
try {
|
||||
const { checkoutUrl } = await fetchWithAuth<UpgradeResponse>(
|
||||
`${apiUrl}/api/v1/tiers/upgrade`,
|
||||
token,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target_tier: targetTier }),
|
||||
},
|
||||
);
|
||||
window.location.href = checkoutUrl;
|
||||
} catch (err: unknown) {
|
||||
setUpgradeError(
|
||||
err instanceof Error ? err.message : 'Upgrade failed — please try again',
|
||||
);
|
||||
setUpgrading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Render: auth loading
|
||||
* ---------------------------------------------------------------- */
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<p className="text-slate-500">Loading…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// token === null means useAuth is redirecting to /login; render nothing
|
||||
if (token === null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Render: data loading / error
|
||||
* ---------------------------------------------------------------- */
|
||||
return (
|
||||
<div className="px-6 py-16">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Page header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-4xl font-extrabold text-slate-900">
|
||||
Tier & Billing
|
||||
</h1>
|
||||
<p className="mt-2 text-slate-600">
|
||||
Your current plan, daily limits, and usage at a glance
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{fetchError !== null && <ErrorBanner message={fetchError} />}
|
||||
|
||||
{fetchLoading && fetchError === null && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-8 w-32 rounded-full bg-slate-100" />
|
||||
<div className="h-48 rounded-2xl bg-slate-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== null && (
|
||||
<div className="space-y-6">
|
||||
{/* Current tier card */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium text-slate-500">
|
||||
Current Plan
|
||||
</p>
|
||||
<TierBadge tier={status.tier} />
|
||||
</div>
|
||||
|
||||
{/* Enterprise: show label; free/pro: show upgrade button */}
|
||||
{status.tier === 'enterprise' ? (
|
||||
<span className="text-sm font-semibold text-emerald-600">
|
||||
Enterprise — Unlimited
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{upgradeError !== null && (
|
||||
<ErrorBanner message={upgradeError} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void handleUpgrade(); }}
|
||||
disabled={upgrading}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{upgrading
|
||||
? 'Redirecting…'
|
||||
: `Upgrade to ${TIER_LABELS[UPGRADE_PATH[status.tier] ?? ''] ?? 'Next Tier'}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage table card */}
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="mb-4 text-lg font-semibold text-slate-900">
|
||||
Daily Usage
|
||||
</h2>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-slate-50">
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">
|
||||
Metric
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">
|
||||
Used
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-slate-700">
|
||||
Limit
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-slate-700">
|
||||
Usage
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<UsageRow
|
||||
label="Agents"
|
||||
used={status.limits.agents.used}
|
||||
limit={status.limits.agents.limit}
|
||||
/>
|
||||
<UsageRow
|
||||
label="API Calls"
|
||||
used={status.limits.api_calls.used}
|
||||
limit={status.limits.api_calls.limit}
|
||||
/>
|
||||
<UsageRow
|
||||
label="Token Issuances"
|
||||
used={status.limits.token_issuances.used}
|
||||
limit={status.limits.token_issuances.limit}
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
Counters reset in{' '}
|
||||
<span className="font-medium text-slate-600">
|
||||
{formatTimeUntilReset(status.reset_at)}
|
||||
</span>{' '}
|
||||
(UTC midnight)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const links: NavLink[] = [
|
||||
{ href: '/get-started', label: 'Get Started' },
|
||||
{ href: '/sdks', label: 'SDKs' },
|
||||
{ href: '/pricing', label: 'Pricing' },
|
||||
{ href: '/analytics', label: 'Analytics' },
|
||||
];
|
||||
|
||||
export function Nav(): React.ReactElement {
|
||||
|
||||
133
portal/components/charts/AgentHeatmap.tsx
Normal file
133
portal/components/charts/AgentHeatmap.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* AgentHeatmap — Recharts BarChart showing agent activity grouped by day-of-week.
|
||||
*
|
||||
* The API returns daily aggregates (no hour granularity), so this component
|
||||
* aggregates event counts per day-of-week across all agents and renders a
|
||||
* grouped bar chart (one bar per day, Mon–Sun).
|
||||
*
|
||||
* This component is designed to be lazy-loaded via `next/dynamic`. Do NOT
|
||||
* import it directly from a page; use dynamic(() => import('./AgentHeatmap'))
|
||||
* so that recharts stays out of the main bundle.
|
||||
*
|
||||
* @module components/charts/AgentHeatmap
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/** A single activity bucket from the API response. */
|
||||
export interface AgentActivityBucket {
|
||||
/** The agent's unique identifier. */
|
||||
agent_id: string;
|
||||
/** Day-of-week as an integer (0 = Sunday … 6 = Saturday). */
|
||||
dow: number;
|
||||
/** Total event count for this agent on this day-of-week. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Props for the AgentHeatmap component. */
|
||||
export interface AgentHeatmapProps {
|
||||
/**
|
||||
* Array of per-agent, per-day-of-week activity buckets from
|
||||
* `GET /api/analytics/agents/activity`.
|
||||
*/
|
||||
data: AgentActivityBucket[];
|
||||
}
|
||||
|
||||
/** Display labels for days of the week, ordered Mon–Sun (dow 1–0). */
|
||||
const DOW_LABELS: Record<number, string> = {
|
||||
0: 'Sun',
|
||||
1: 'Mon',
|
||||
2: 'Tue',
|
||||
3: 'Wed',
|
||||
4: 'Thu',
|
||||
5: 'Fri',
|
||||
6: 'Sat',
|
||||
};
|
||||
|
||||
/** Ordered day-of-week values for Mon → Sun display. */
|
||||
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
/** Aggregated count per day-of-week for chart rendering. */
|
||||
interface DowAggregate {
|
||||
day: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates raw per-agent activity buckets into per-day-of-week totals
|
||||
* suitable for the bar chart.
|
||||
*
|
||||
* @param data - Raw activity buckets from the API
|
||||
* @returns Array of { day, count } sorted Mon → Sun
|
||||
*/
|
||||
function aggregateByDow(data: AgentActivityBucket[]): DowAggregate[] {
|
||||
const totals: Record<number, number> = {};
|
||||
for (const dow of DOW_ORDER) {
|
||||
totals[dow] = 0;
|
||||
}
|
||||
for (const bucket of data) {
|
||||
if (bucket.dow in totals) {
|
||||
totals[bucket.dow] += bucket.count;
|
||||
}
|
||||
}
|
||||
return DOW_ORDER.map((dow) => ({
|
||||
day: DOW_LABELS[dow],
|
||||
count: totals[dow],
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive bar chart of agent activity grouped by day-of-week
|
||||
* using recharts. Data is aggregated across all agents.
|
||||
*
|
||||
* @param props - AgentHeatmapProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function AgentHeatmap({
|
||||
data,
|
||||
}: AgentHeatmapProps): React.ReactElement {
|
||||
const chartData = useMemo(() => aggregateByDow(data), [data]);
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ top: 8, right: 16, left: 0, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#cbd5e1' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [value.toLocaleString(), 'Events']}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
99
portal/components/charts/TokenTrendChart.tsx
Normal file
99
portal/components/charts/TokenTrendChart.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TokenTrendChart — Recharts LineChart showing daily token issuance counts.
|
||||
*
|
||||
* This component is designed to be lazy-loaded via `next/dynamic`. Do NOT
|
||||
* import it directly from a page; use dynamic(() => import('./TokenTrendChart'))
|
||||
* so that recharts stays out of the main bundle.
|
||||
*
|
||||
* @module components/charts/TokenTrendChart
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/** A single data point for the token trend line chart. */
|
||||
export interface TokenTrendDataPoint {
|
||||
/** ISO 8601 date string (e.g. "2026-03-01"). */
|
||||
date: string;
|
||||
/** Number of tokens issued on this date. */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Props for the TokenTrendChart component. */
|
||||
export interface TokenTrendChartProps {
|
||||
/** Array of daily token issuance data points, sorted ascending by date. */
|
||||
data: TokenTrendDataPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an ISO date string as a short label (e.g. "Mar 1").
|
||||
*
|
||||
* @param dateStr - ISO 8601 date string
|
||||
* @returns Formatted short date label
|
||||
*/
|
||||
function formatDateLabel(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a responsive line chart of daily token issuance counts using recharts.
|
||||
*
|
||||
* @param props - TokenTrendChartProps
|
||||
* @returns JSX element
|
||||
*/
|
||||
export default function TokenTrendChart({
|
||||
data,
|
||||
}: TokenTrendChartProps): React.ReactElement {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 16, left: 0, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDateLabel}
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#cbd5e1' }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#64748b' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [value.toLocaleString(), 'Tokens issued']}
|
||||
labelFormatter={(label: string) => formatDateLabel(label)}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#6366f1"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: '#6366f1' }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
70
portal/hooks/useAuth.ts
Normal file
70
portal/hooks/useAuth.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* useAuth — Client-side authentication hook for the SentryAgent portal.
|
||||
*
|
||||
* Reads the tenant JWT stored in localStorage under the key
|
||||
* `sentryagent_token`. If no token is present the hook signals that the user
|
||||
* is unauthenticated so the calling page can redirect to `/login`.
|
||||
*
|
||||
* This is intentionally lightweight: the portal calls the AgentIdP API
|
||||
* directly; the JWT is issued by the AgentIdP `/api/tenants/login` endpoint
|
||||
* and stored on successful sign-in.
|
||||
*
|
||||
* @module hooks/useAuth
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/** The localStorage key under which the tenant JWT is persisted. */
|
||||
export const AUTH_TOKEN_KEY = 'sentryagent_token';
|
||||
|
||||
/** Shape returned by the useAuth hook. */
|
||||
export interface AuthState {
|
||||
/** The stored JWT, or null if unauthenticated. */
|
||||
token: string | null;
|
||||
/** True while the hook is reading from localStorage on mount. */
|
||||
loading: boolean;
|
||||
/**
|
||||
* Sign the user out by removing the stored token and redirecting to /login.
|
||||
*/
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current authentication state and provides a sign-out helper.
|
||||
* Redirects to `/login` when no token is found (after the initial mount check).
|
||||
*
|
||||
* @param redirectOnUnauth - When true (default), redirects to /login if
|
||||
* no token is present. Pass false on public pages.
|
||||
* @returns AuthState
|
||||
*/
|
||||
export function useAuth(redirectOnUnauth = true): AuthState {
|
||||
const router = useRouter();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored =
|
||||
typeof window !== 'undefined'
|
||||
? localStorage.getItem(AUTH_TOKEN_KEY)
|
||||
: null;
|
||||
setToken(stored);
|
||||
setLoading(false);
|
||||
|
||||
if (!stored && redirectOnUnauth) {
|
||||
router.replace('/login');
|
||||
}
|
||||
}, [redirectOnUnauth, router]);
|
||||
|
||||
const signOut = (): void => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
setToken(null);
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return { token, loading, signOut };
|
||||
}
|
||||
344
portal/package-lock.json
generated
344
portal/package-lock.json
generated
@@ -9,9 +9,11 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@stoplight/elements": "^9.0.16",
|
||||
"date-fns": "^3.3.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
@@ -1418,6 +1420,69 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
@@ -2020,6 +2085,137 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2037,6 +2233,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
@@ -2174,6 +2376,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/extend": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
@@ -2186,6 +2394,15 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -2934,6 +3151,15 @@
|
||||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
@@ -4937,6 +5163,47 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
|
||||
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-equals": "^5.0.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react-transition-group": "^4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group/node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/react-universal-interface": {
|
||||
"version": "0.6.2",
|
||||
"resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
|
||||
@@ -4969,6 +5236,53 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "2.15.4",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
|
||||
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"clsx": "^2.0.0",
|
||||
"eventemitter3": "^4.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"react-is": "^18.3.1",
|
||||
"react-smooth": "^4.0.4",
|
||||
"recharts-scale": "^0.4.4",
|
||||
"tiny-invariant": "^1.3.1",
|
||||
"victory-vendor": "^36.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts-scale": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
|
||||
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"decimal.js-light": "^2.4.1"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/remark-frontmatter": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-3.0.0.tgz",
|
||||
@@ -5452,6 +5766,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6014,6 +6334,28 @@
|
||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/web-namespaces": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@stoplight/elements": "^9.0.16",
|
||||
"date-fns": "^3.3.0",
|
||||
"next": "14.2.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
|
||||
54
src/app.ts
54
src/app.ts
@@ -21,6 +21,7 @@ import { OrgRepository } from './repositories/OrgRepository.js';
|
||||
|
||||
import { AuditService } from './services/AuditService.js';
|
||||
import { AgentService } from './services/AgentService.js';
|
||||
import { AnalyticsService } from './services/AnalyticsService.js';
|
||||
import { MarketplaceService } from './services/MarketplaceService.js';
|
||||
import { BillingService } from './services/BillingService.js';
|
||||
import { UsageService } from './services/UsageService.js';
|
||||
@@ -36,6 +37,7 @@ import { EventPublisher } from './services/EventPublisher.js';
|
||||
import { WebhookDeliveryWorker } from './workers/WebhookDeliveryWorker.js';
|
||||
import { createKafkaProducer } from './adapters/KafkaAdapter.js';
|
||||
|
||||
import { AnalyticsController } from './controllers/AnalyticsController.js';
|
||||
import { AgentController } from './controllers/AgentController.js';
|
||||
import { MarketplaceController } from './controllers/MarketplaceController.js';
|
||||
import { BillingController } from './controllers/BillingController.js';
|
||||
@@ -50,7 +52,9 @@ import { OIDCController } from './controllers/OIDCController.js';
|
||||
import { FederationController } from './controllers/FederationController.js';
|
||||
import { WebhookController } from './controllers/WebhookController.js';
|
||||
import { ComplianceController } from './controllers/ComplianceController.js';
|
||||
import { ComplianceService } from './services/ComplianceService.js';
|
||||
|
||||
import { createAnalyticsRouter } from './routes/analytics.js';
|
||||
import { createAgentsRouter } from './routes/agents.js';
|
||||
import { createMarketplaceRouter } from './routes/marketplace.js';
|
||||
import { createBillingRouter } from './routes/billing.js';
|
||||
@@ -74,6 +78,9 @@ import { DelegationController } from './controllers/DelegationController.js';
|
||||
import { createScaffoldRouter } from './routes/scaffold.js';
|
||||
import { ScaffoldService } from './services/ScaffoldService.js';
|
||||
import { ScaffoldController } from './controllers/ScaffoldController.js';
|
||||
import { TierService } from './services/TierService.js';
|
||||
import { TierController } from './controllers/TierController.js';
|
||||
import { createTiersRouter } from './routes/tiers.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { createOpaMiddleware } from './middleware/opa.js';
|
||||
@@ -81,7 +88,7 @@ import { metricsMiddleware } from './middleware/metrics.js';
|
||||
import { createOrgContextMiddleware } from './middleware/orgContext.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { createUsageMeteringMiddleware, startUsageMeteringFlush } from './middleware/usageMeteringMiddleware.js';
|
||||
import { createFreeTierEnforcementMiddleware } from './middleware/freeTierEnforcementMiddleware.js';
|
||||
import { createTierEnforcementMiddleware } from './middleware/tierEnforcement.js';
|
||||
import { tlsEnforcementMiddleware } from './middleware/TLSEnforcementMiddleware.js';
|
||||
import { createVaultClientFromEnv } from './vault/VaultClient.js';
|
||||
import { getEncryptionService } from './services/EncryptionService.js';
|
||||
@@ -191,12 +198,25 @@ export async function createApp(): Promise<Application> {
|
||||
webhookWorker.start();
|
||||
const eventPublisher = new EventPublisher(webhookWorker, pool, kafkaProducer);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Stripe client + TierService — created early so both BillingService
|
||||
// and AgentService can receive TierService via constructor injection.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const stripe = new Stripe(process.env['STRIPE_SECRET_KEY'] ?? '', { apiVersion: '2026-03-25.dahlia' });
|
||||
const tierService = new TierService(pool, redis as RedisClientType, stripe);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Service layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const auditService = new AuditService(auditRepo);
|
||||
const didService = new DIDService(pool, vaultClient, redis as RedisClientType, encryptionService);
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 6 WS3: Analytics Service
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const analyticsService = new AnalyticsService(pool);
|
||||
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher, analyticsService, tierService);
|
||||
const marketplaceService = new MarketplaceService(agentRepo);
|
||||
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient, eventPublisher, encryptionService);
|
||||
const orgService = new OrgService(orgRepo, agentRepo);
|
||||
@@ -223,6 +243,7 @@ export async function createApp(): Promise<Application> {
|
||||
idTokenService,
|
||||
eventPublisher,
|
||||
encryptionService,
|
||||
analyticsService,
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -234,6 +255,7 @@ export async function createApp(): Promise<Application> {
|
||||
// Controller layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const agentController = new AgentController(agentService);
|
||||
const analyticsController = new AnalyticsController(analyticsService);
|
||||
const tokenController = new TokenController(oauth2Service);
|
||||
const credentialController = new CredentialController(credentialService);
|
||||
const auditController = new AuditController(auditService);
|
||||
@@ -248,8 +270,7 @@ export async function createApp(): Promise<Application> {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Billing & Usage Metering (WS6)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const stripe = new Stripe(process.env['STRIPE_SECRET_KEY'] ?? '', { apiVersion: '2026-03-25.dahlia' });
|
||||
const billingService = new BillingService(pool, stripe);
|
||||
const billingService = new BillingService(pool, stripe, tierService);
|
||||
const usageService = new UsageService(pool);
|
||||
const billingController = new BillingController(billingService, usageService);
|
||||
|
||||
@@ -265,7 +286,8 @@ export async function createApp(): Promise<Application> {
|
||||
// Compliance services and background jobs (SOC 2 Type II)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const auditVerificationService = getAuditVerificationService(pool);
|
||||
const complianceController = new ComplianceController(auditVerificationService);
|
||||
const complianceService = new ComplianceService(pool, redis as RedisClientType);
|
||||
const complianceController = new ComplianceController(auditVerificationService, complianceService);
|
||||
|
||||
// Start background compliance monitoring jobs (non-blocking)
|
||||
startSecretsRotationJob(pool);
|
||||
@@ -285,10 +307,12 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(createUsageMeteringMiddleware(pool));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Free tier enforcement — rejects requests exceeding free plan limits
|
||||
// Applied after usage metering and before routes.
|
||||
// Tier enforcement — Redis-backed daily API call rate limits per
|
||||
// tenant tier (free/pro/enterprise). Runs after auth; skipped when
|
||||
// TIER_ENFORCEMENT=false or for enterprise tenants. Supersedes
|
||||
// the legacy freeTierEnforcementMiddleware (removed Phase 6 WS4).
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(createFreeTierEnforcementMiddleware(pool, redis as RedisClientType));
|
||||
app.use(createTierEnforcementMiddleware(pool, redis as RedisClientType));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Routes
|
||||
@@ -326,6 +350,12 @@ export async function createApp(): Promise<Application> {
|
||||
// Billing & Usage Metering — checkout, webhook, usage summary
|
||||
app.use(`${API_BASE}/billing`, createBillingRouter(billingController, authMiddleware));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 6 WS4: Tier management — status and upgrade endpoints
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const tierController = new TierController(tierService);
|
||||
app.use(`${API_BASE}/tiers`, createTiersRouter(tierController, authMiddleware));
|
||||
|
||||
// OIDC trust-policy management (authenticated) and token exchange (unauthenticated)
|
||||
// Both routers mount under ${API_BASE}/oidc — trust-policy routes use /trust-policies prefix,
|
||||
// token exchange uses /token, so there are no path conflicts.
|
||||
@@ -341,6 +371,14 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(`${API_BASE}`, createDelegationRouter(delegationController, authMiddleware));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 6 WS3: Analytics (guarded by ANALYTICS_ENABLED flag)
|
||||
// When disabled, all /api/v1/analytics/* routes return 404.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
if (process.env['ANALYTICS_ENABLED'] !== 'false') {
|
||||
app.use(`${API_BASE}/analytics`, createAnalyticsRouter(analyticsController, authMiddleware));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 5 WS5: Scaffold Generator
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
53
src/config/tiers.ts
Normal file
53
src/config/tiers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Tier configuration for SentryAgent.ai AgentIdP.
|
||||
* TIER_CONFIG is the single source of truth for all per-tier limits.
|
||||
* Never duplicate these values — always import from here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-tier limit definitions.
|
||||
* `Infinity` signals no enforcement for enterprise tenants.
|
||||
*/
|
||||
export const TIER_CONFIG = {
|
||||
free: {
|
||||
/** Maximum number of non-decommissioned agents allowed per org. */
|
||||
maxAgents: 10,
|
||||
/** Maximum number of API calls allowed per calendar day (UTC). */
|
||||
maxCallsPerDay: 1_000,
|
||||
/** Maximum number of token issuances allowed per calendar day (UTC). */
|
||||
maxTokensPerDay: 1_000,
|
||||
},
|
||||
pro: {
|
||||
maxAgents: 100,
|
||||
maxCallsPerDay: 50_000,
|
||||
maxTokensPerDay: 50_000,
|
||||
},
|
||||
enterprise: {
|
||||
maxAgents: Infinity,
|
||||
maxCallsPerDay: Infinity,
|
||||
maxTokensPerDay: Infinity,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Union type of valid tier names derived from TIER_CONFIG keys. */
|
||||
export type TierName = keyof typeof TIER_CONFIG;
|
||||
|
||||
/**
|
||||
* Ordered tier rank used to validate upgrade direction.
|
||||
* Higher index = higher tier.
|
||||
*/
|
||||
export const TIER_RANK: Record<TierName, number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
enterprise: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Returns true when the supplied string is a valid TierName.
|
||||
*
|
||||
* @param value - The string to test.
|
||||
* @returns Type predicate narrowing value to TierName.
|
||||
*/
|
||||
export function isTierName(value: string): value is TierName {
|
||||
return value in TIER_CONFIG;
|
||||
}
|
||||
113
src/controllers/AnalyticsController.ts
Normal file
113
src/controllers/AnalyticsController.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Analytics Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for tenant analytics endpoints.
|
||||
* No business logic — delegates all data access to AnalyticsService.
|
||||
* All handlers enforce tenant scoping via req.user.organization_id.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AnalyticsService } from '../services/AnalyticsService.js';
|
||||
import { AuthenticationError, ValidationError } from '../utils/errors.js';
|
||||
|
||||
/** Maximum permitted value for the `days` query parameter. */
|
||||
const MAX_DAYS = 90;
|
||||
/** Default number of days returned when `days` is not specified. */
|
||||
const DEFAULT_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Controller for the analytics endpoints.
|
||||
* Receives AnalyticsService via constructor injection.
|
||||
*/
|
||||
export class AnalyticsController {
|
||||
/**
|
||||
* @param analyticsService - The analytics data service.
|
||||
*/
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
/**
|
||||
* Handles GET /analytics/tokens — returns daily token issuance trend.
|
||||
* Query parameter `days` (optional, default 30, max 90).
|
||||
* Responds 400 if `days` exceeds the maximum.
|
||||
* Responds 401 if the request is not authenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getTokenTrend = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const daysParam = req.query['days'];
|
||||
const days = daysParam !== undefined ? parseInt(String(daysParam), 10) : DEFAULT_DAYS;
|
||||
|
||||
if (isNaN(days) || days < 1) {
|
||||
throw new ValidationError('Query parameter `days` must be a positive integer.', {
|
||||
field: 'days',
|
||||
});
|
||||
}
|
||||
|
||||
if (days > MAX_DAYS) {
|
||||
throw new ValidationError(
|
||||
`Query parameter \`days\` must not exceed ${MAX_DAYS}.`,
|
||||
{ field: 'days', max: MAX_DAYS, provided: days },
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
const trend = await this.analyticsService.getTokenTrend(tenantId, days);
|
||||
|
||||
res.status(200).json(trend);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /analytics/agents/activity — returns agent activity heatmap data.
|
||||
* Responds 401 if the request is not authenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentActivity = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
const activity = await this.analyticsService.getAgentActivity(tenantId);
|
||||
|
||||
res.status(200).json(activity);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /analytics/agents — returns per-agent usage summary for the current month.
|
||||
* Responds 401 if the request is not authenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentSummary = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
const summary = await this.analyticsService.getAgentUsageSummary(tenantId);
|
||||
|
||||
res.status(200).json(summary);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* ComplianceController — SOC 2 Type II compliance endpoints.
|
||||
* ComplianceController — SOC 2 Type II and AGNTCY compliance endpoints.
|
||||
*
|
||||
* Handles two endpoints defined in docs/openapi/compliance.yaml:
|
||||
* GET /api/v1/audit/verify — Audit chain integrity verification (auth required)
|
||||
* Handles endpoints defined in docs/openapi/compliance.yaml:
|
||||
* GET /api/v1/audit/verify — Audit chain integrity verification (auth required)
|
||||
* GET /api/v1/compliance/controls — SOC 2 control status summary (public)
|
||||
* GET /api/v1/compliance/report — AGNTCY compliance report (auth required)
|
||||
* GET /api/v1/compliance/agent-cards — AGNTCY agent card export (auth required)
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AuditVerificationService } from '../services/AuditVerificationService.js';
|
||||
import { ComplianceService } from '../services/ComplianceService.js';
|
||||
import { getAllControlStatuses } from '../services/ComplianceStatusStore.js';
|
||||
import { ValidationError } from '../utils/errors.js';
|
||||
import { ITokenPayload } from '../types/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
@@ -33,15 +37,18 @@ function isValidIsoDateTime(value: string): boolean {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for SOC 2 Type II compliance API endpoints.
|
||||
* Exposes audit chain verification and live control status reporting.
|
||||
* Controller for SOC 2 Type II and AGNTCY compliance API endpoints.
|
||||
* Exposes audit chain verification, live control status reporting,
|
||||
* AGNTCY compliance report generation, and agent card export.
|
||||
*/
|
||||
export class ComplianceController {
|
||||
/**
|
||||
* @param auditVerificationService - Service for cryptographic audit chain verification.
|
||||
* @param complianceService - Service for AGNTCY compliance report and agent card generation.
|
||||
*/
|
||||
constructor(
|
||||
private readonly auditVerificationService: AuditVerificationService,
|
||||
private readonly complianceService: ComplianceService,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -127,4 +134,59 @@ export class ComplianceController {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/compliance/report
|
||||
*
|
||||
* Generates and returns an AGNTCY compliance report for the authenticated tenant.
|
||||
* The report covers agent-identity verification and audit-trail integrity.
|
||||
* Reports are cached in Redis for 5 minutes; sets `X-Cache: HIT` when served from cache.
|
||||
*
|
||||
* Requires Bearer token authentication (tenant extracted from req.user.sub).
|
||||
*
|
||||
* @param req - Express request; tenant derived from authenticated user context.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
async getComplianceReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const user = req.user as ITokenPayload | undefined;
|
||||
const tenantId = user?.organization_id ?? user?.sub ?? '';
|
||||
|
||||
const report = await this.complianceService.generateReport(tenantId);
|
||||
|
||||
if (report.from_cache === true) {
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
}
|
||||
|
||||
res.status(200).json(report);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/compliance/agent-cards
|
||||
*
|
||||
* Exports all active agents for the authenticated tenant as AGNTCY-standard
|
||||
* agent card JSON objects.
|
||||
*
|
||||
* Requires Bearer token authentication (tenant extracted from req.user.sub).
|
||||
*
|
||||
* @param req - Express request; tenant derived from authenticated user context.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
async exportAgentCards(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const user = req.user as ITokenPayload | undefined;
|
||||
const tenantId = user?.organization_id ?? user?.sub ?? '';
|
||||
|
||||
const cards = await this.complianceService.exportAgentCards(tenantId);
|
||||
|
||||
res.status(200).json(cards);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/controllers/TierController.ts
Normal file
93
src/controllers/TierController.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tier Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for tier status and upgrade endpoints.
|
||||
* No business logic — delegates entirely to TierService.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { TierService } from '../services/TierService.js';
|
||||
import { AuthenticationError, ValidationError } from '../utils/errors.js';
|
||||
import { isTierName } from '../config/tiers.js';
|
||||
|
||||
/**
|
||||
* Controller for tenant tier management endpoints.
|
||||
* Receives TierService via constructor injection.
|
||||
*/
|
||||
export class TierController {
|
||||
/**
|
||||
* @param tierService - The tier management service.
|
||||
*/
|
||||
constructor(private readonly tierService: TierService) {}
|
||||
|
||||
/**
|
||||
* Handles GET /api/tiers/status — returns the current tier, limits, and usage.
|
||||
*
|
||||
* Response: 200 ITierStatus
|
||||
* Errors: 401 when unauthenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getStatus = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const orgId = req.user.organization_id;
|
||||
if (!orgId) {
|
||||
throw new AuthenticationError('organization_id is required in token.');
|
||||
}
|
||||
|
||||
const status = await this.tierService.getStatus(orgId);
|
||||
res.status(200).json(status);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles POST /api/tiers/upgrade — initiates a Stripe checkout session for a tier upgrade.
|
||||
*
|
||||
* Request body: { target_tier: 'pro' | 'enterprise' }
|
||||
* Response: 200 { checkoutUrl: string }
|
||||
* Errors: 400 when target_tier is missing/invalid or is not an upgrade.
|
||||
* 401 when unauthenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
initiateUpgrade = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const orgId = req.user.organization_id;
|
||||
if (!orgId) {
|
||||
throw new AuthenticationError('organization_id is required in token.');
|
||||
}
|
||||
|
||||
const body = req.body as { target_tier?: unknown };
|
||||
const rawTargetTier = body.target_tier;
|
||||
|
||||
if (!rawTargetTier || typeof rawTargetTier !== 'string') {
|
||||
throw new ValidationError('target_tier is required.', { received: rawTargetTier });
|
||||
}
|
||||
|
||||
if (!isTierName(rawTargetTier)) {
|
||||
throw new ValidationError(
|
||||
`target_tier must be one of: free, pro, enterprise.`,
|
||||
{ received: rawTargetTier },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.tierService.initiateUpgrade(orgId, rawTargetTier);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
14
src/db/migrations/025_add_analytics_events.sql
Normal file
14
src/db/migrations/025_add_analytics_events.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Migration: 025_add_analytics_events
|
||||
-- Creates the analytics_events table for daily pre-aggregated event rollups.
|
||||
-- Each row represents one (tenant, date, metric_type) bucket with a running count.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_events (
|
||||
organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
metric_type VARCHAR(50) NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (organization_id, date, metric_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_events_org_date
|
||||
ON analytics_events(organization_id, date);
|
||||
21
src/db/migrations/026_add_tenant_tiers.sql
Normal file
21
src/db/migrations/026_add_tenant_tiers.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration 026: Add tenant tier tracking columns to organizations table
|
||||
-- Phase 6, WS4 — API Gateway Tiers
|
||||
--
|
||||
-- Adds a dedicated `tier` column (ENUM: free/pro/enterprise) and a `tier_updated_at`
|
||||
-- timestamp column. The existing `plan_tier` VARCHAR column is retained for
|
||||
-- backward compatibility with the billing/subscription subsystem.
|
||||
|
||||
CREATE TYPE IF NOT EXISTS tier_type AS ENUM ('free', 'pro', 'enterprise');
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS tier tier_type NOT NULL DEFAULT 'free';
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS tier_updated_at TIMESTAMPTZ;
|
||||
|
||||
-- Backfill tier from plan_tier for existing rows so the new column is consistent.
|
||||
UPDATE organizations
|
||||
SET tier = plan_tier::tier_type
|
||||
WHERE tier = 'free' AND plan_tier IN ('free', 'pro', 'enterprise');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_tier ON organizations(tier);
|
||||
162
src/middleware/tierEnforcement.ts
Normal file
162
src/middleware/tierEnforcement.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Tier enforcement middleware for SentryAgent.ai AgentIdP.
|
||||
*
|
||||
* Enforces per-tenant daily API call limits based on the tenant's tier.
|
||||
* Uses Redis keys `rate:tier:calls:<org_id>` with TTL aligned to UTC midnight.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Skipped entirely when TIER_ENFORCEMENT env var is 'false'.
|
||||
* - Skipped for enterprise tenants (no limits apply).
|
||||
* - On Redis unavailability: logs the error and proceeds (fail-open).
|
||||
* - Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset on every response.
|
||||
* - Returns HTTP 429 with Retry-After header when limit is exceeded.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { Pool } from 'pg';
|
||||
import { TIER_CONFIG, TierName, isTierName } from '../config/tiers.js';
|
||||
import { TierLimitError } from '../utils/errors.js';
|
||||
|
||||
/** Redis key prefix for daily API call counters. */
|
||||
const CALLS_KEY_PREFIX = 'rate:tier:calls:';
|
||||
|
||||
/**
|
||||
* Returns the number of seconds remaining until the next UTC midnight.
|
||||
* Used as the Redis key TTL and the Retry-After value on rejection.
|
||||
*
|
||||
* @returns Seconds until next UTC midnight (minimum 1).
|
||||
*/
|
||||
function secondsUntilUtcMidnight(): number {
|
||||
const now = Date.now();
|
||||
const midnight = new Date();
|
||||
midnight.setUTCHours(24, 0, 0, 0);
|
||||
return Math.max(1, Math.ceil((midnight.getTime() - now) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Unix timestamp (seconds) of the next UTC midnight.
|
||||
* Used for the X-RateLimit-Reset header.
|
||||
*
|
||||
* @returns Unix timestamp of next UTC midnight.
|
||||
*/
|
||||
function nextUtcMidnightTimestamp(): number {
|
||||
const midnight = new Date();
|
||||
midnight.setUTCHours(24, 0, 0, 0);
|
||||
return Math.ceil(midnight.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the tenant's current tier from the organizations table.
|
||||
* Falls back to 'free' when the tenant row is not found.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param orgId - The organization ID.
|
||||
* @returns The tenant's current TierName.
|
||||
*/
|
||||
async function getTenantTier(pool: Pool, orgId: string): Promise<TierName> {
|
||||
const result = await pool.query<{ tier: string }>(
|
||||
`SELECT tier FROM organizations WHERE organization_id = $1 LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (result.rows.length === 0) return 'free';
|
||||
const tier = result.rows[0].tier;
|
||||
return isTierName(tier) ? tier : 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the tier enforcement middleware.
|
||||
*
|
||||
* Designed to run after auth middleware (req.user must be populated).
|
||||
* Unauthenticated requests pass through unaffected.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool (used to look up tenant tier).
|
||||
* @param redis - Redis client (used for rate counter storage).
|
||||
* @returns Express RequestHandler.
|
||||
*/
|
||||
export function createTierEnforcementMiddleware(
|
||||
pool: Pool,
|
||||
redis: RedisClientType,
|
||||
): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Feature flag: bypass all tier enforcement when disabled
|
||||
if (process.env['TIER_ENFORCEMENT'] === 'false') {
|
||||
// Still set headers reflecting unlimited limits
|
||||
res.setHeader('X-RateLimit-Limit', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Remaining', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Reset', nextUtcMidnightTimestamp());
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only enforce for authenticated requests
|
||||
if (!req.user?.organization_id) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = req.user.organization_id;
|
||||
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const tier = await getTenantTier(pool, orgId);
|
||||
|
||||
// Enterprise tenants bypass all limits
|
||||
if (tier === 'enterprise') {
|
||||
res.setHeader('X-RateLimit-Limit', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Remaining', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Reset', nextUtcMidnightTimestamp());
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = TIER_CONFIG[tier].maxCallsPerDay;
|
||||
const redisKey = `${CALLS_KEY_PREFIX}${orgId}`;
|
||||
const ttl = secondsUntilUtcMidnight();
|
||||
const resetAt = nextUtcMidnightTimestamp();
|
||||
|
||||
let currentCount: number;
|
||||
|
||||
try {
|
||||
// Atomically increment and set TTL aligned to UTC midnight.
|
||||
// INCR returns the new value after increment.
|
||||
const newCount = await redis.incr(redisKey);
|
||||
currentCount = newCount;
|
||||
|
||||
// Set TTL only on the first increment to avoid resetting the window
|
||||
// on every request. If the key was brand new, newCount === 1.
|
||||
if (newCount === 1) {
|
||||
await redis.expire(redisKey, ttl);
|
||||
}
|
||||
} catch (redisErr) {
|
||||
// Redis unavailable — fail-open: log and proceed without rate limiting
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[tierEnforcement] Redis error — proceeding fail-open:', redisErr);
|
||||
res.setHeader('X-RateLimit-Limit', limit);
|
||||
res.setHeader('X-RateLimit-Remaining', 0);
|
||||
res.setHeader('X-RateLimit-Reset', resetAt);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, limit - currentCount);
|
||||
|
||||
// Set rate limit headers on all responses
|
||||
res.setHeader('X-RateLimit-Limit', limit);
|
||||
res.setHeader('X-RateLimit-Remaining', remaining);
|
||||
res.setHeader('X-RateLimit-Reset', resetAt);
|
||||
|
||||
// Reject if the new count exceeds the limit
|
||||
if (currentCount > limit) {
|
||||
res.setHeader('Retry-After', ttl);
|
||||
next(new TierLimitError('API call', limit, { orgId, tier }));
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
51
src/routes/analytics.ts
Normal file
51
src/routes/analytics.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Analytics routes for SentryAgent.ai AgentIdP.
|
||||
* Exposes tenant analytics endpoints under /api/v1/analytics.
|
||||
* All routes require a valid Bearer JWT (authMiddleware).
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { AnalyticsController } from '../controllers/AnalyticsController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for analytics endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /analytics/tokens — daily token issuance trend (last N days)
|
||||
* GET /analytics/agents/activity — agent activity heatmap by dow+hour
|
||||
* GET /analytics/agents — per-agent usage summary for current month
|
||||
*
|
||||
* @param analyticsController - The analytics controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware for all protected endpoints.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createAnalyticsRouter(
|
||||
analyticsController: AnalyticsController,
|
||||
authMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// All analytics routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// GET /analytics/tokens — daily token issuance trend
|
||||
router.get(
|
||||
'/tokens',
|
||||
asyncHandler(analyticsController.getTokenTrend.bind(analyticsController)),
|
||||
);
|
||||
|
||||
// GET /analytics/agents/activity — agent activity heatmap (must be registered before /agents)
|
||||
router.get(
|
||||
'/agents/activity',
|
||||
asyncHandler(analyticsController.getAgentActivity.bind(analyticsController)),
|
||||
);
|
||||
|
||||
// GET /analytics/agents — per-agent usage summary
|
||||
router.get(
|
||||
'/agents',
|
||||
asyncHandler(analyticsController.getAgentSummary.bind(analyticsController)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
/**
|
||||
* Compliance routes for SentryAgent.ai AgentIdP.
|
||||
* Mounts the SOC 2 Type II compliance endpoints:
|
||||
* GET /api/v1/audit/verify — Audit chain integrity (requires audit:read)
|
||||
*
|
||||
* SOC 2 Type II routes (always active):
|
||||
* GET /api/v1/audit/verify — Audit chain integrity (requires audit:read)
|
||||
* GET /api/v1/compliance/controls — SOC 2 control status (public, no auth)
|
||||
*
|
||||
* AGNTCY compliance routes (gated by COMPLIANCE_ENABLED env var):
|
||||
* GET /api/v1/compliance/report — AGNTCY compliance report (requires auth)
|
||||
* GET /api/v1/compliance/agent-cards — AGNTCY agent card export (requires auth)
|
||||
*
|
||||
* When COMPLIANCE_ENABLED=false, the AGNTCY routes return 404.
|
||||
* The SOC 2 routes are never gated.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
@@ -108,16 +116,22 @@ async function auditRateLimiter(
|
||||
/**
|
||||
* Creates and returns the Express router for compliance endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /audit/verify — Verify audit chain integrity (Bearer + audit:read scope)
|
||||
* SOC 2 routes (always mounted, never gated):
|
||||
* GET /audit/verify — Verify audit chain integrity (Bearer + audit:read scope)
|
||||
* GET /compliance/controls — Get SOC 2 control status (public, no auth required)
|
||||
*
|
||||
* AGNTCY routes (mounted only when COMPLIANCE_ENABLED != 'false'):
|
||||
* GET /compliance/report — AGNTCY compliance report (Bearer auth required)
|
||||
* GET /compliance/agent-cards — AGNTCY agent card export (Bearer auth required)
|
||||
*
|
||||
* @param complianceController - The compliance controller instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createComplianceRouter(complianceController: ComplianceController): Router {
|
||||
const router = Router();
|
||||
|
||||
// ── SOC 2 routes — always active ──────────────────────────────────────────
|
||||
|
||||
// GET /audit/verify — requires authentication + audit:read scope + rate limit
|
||||
router.get(
|
||||
'/audit/verify',
|
||||
@@ -133,5 +147,23 @@ export function createComplianceRouter(complianceController: ComplianceControlle
|
||||
asyncHandler(complianceController.getComplianceControls.bind(complianceController)),
|
||||
);
|
||||
|
||||
// ── AGNTCY compliance routes — gated by COMPLIANCE_ENABLED flag ───────────
|
||||
|
||||
if (process.env['COMPLIANCE_ENABLED'] !== 'false') {
|
||||
// GET /compliance/report — requires Bearer auth; returns AGNTCY compliance report
|
||||
router.get(
|
||||
'/compliance/report',
|
||||
asyncHandler(authMiddleware),
|
||||
asyncHandler(complianceController.getComplianceReport.bind(complianceController)),
|
||||
);
|
||||
|
||||
// GET /compliance/agent-cards — requires Bearer auth; returns AGNTCY agent card array
|
||||
router.get(
|
||||
'/compliance/agent-cards',
|
||||
asyncHandler(authMiddleware),
|
||||
asyncHandler(complianceController.exportAgentCards.bind(complianceController)),
|
||||
);
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
42
src/routes/tiers.ts
Normal file
42
src/routes/tiers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Tier routes for SentryAgent.ai AgentIdP.
|
||||
* Mounts tier status and upgrade endpoints under /api/tiers.
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { TierController } from '../controllers/TierController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for tier management endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /tiers/status — authenticated; returns current tier, limits, and usage
|
||||
* POST /tiers/upgrade — authenticated; initiates a Stripe checkout for a tier upgrade
|
||||
*
|
||||
* @param controller - The tier controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createTiersRouter(
|
||||
controller: TierController,
|
||||
authMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /tiers/status — returns tier, limits, and live usage counters
|
||||
router.get(
|
||||
'/status',
|
||||
authMiddleware,
|
||||
asyncHandler(controller.getStatus.bind(controller)),
|
||||
);
|
||||
|
||||
// POST /tiers/upgrade — initiates Stripe checkout for a tier upgrade
|
||||
router.post(
|
||||
'/upgrade',
|
||||
authMiddleware,
|
||||
asyncHandler(controller.initiateUpgrade.bind(controller)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { DIDService } from './DIDService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import { AnalyticsService } from './AnalyticsService.js';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
FreeTierLimitError,
|
||||
} from '../utils/errors.js';
|
||||
import { agentsRegisteredTotal } from '../metrics/registry.js';
|
||||
import { TierService } from './TierService.js';
|
||||
|
||||
const FREE_TIER_MAX_AGENTS = 100;
|
||||
|
||||
@@ -39,6 +41,10 @@ export class AgentService {
|
||||
* (backward-compatible default).
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, lifecycle events are
|
||||
* published as webhooks and Kafka messages (fire-and-forget).
|
||||
* @param analyticsService - Optional AnalyticsService. When provided, agent_registered
|
||||
* and agent_deactivated events are recorded fire-and-forget.
|
||||
* @param tierService - Optional TierService. When provided, per-tier agent count limits
|
||||
* are enforced at agent creation time (Phase 6 WS4).
|
||||
*/
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
@@ -46,6 +52,8 @@ export class AgentService {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly didService: DIDService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
private readonly analyticsService: AnalyticsService | null = null,
|
||||
private readonly tierService: TierService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -64,7 +72,17 @@ export class AgentService {
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IAgent> {
|
||||
// Enforce free-tier agent count limit
|
||||
const orgId = data.organizationId ?? 'org_system';
|
||||
|
||||
// ── Tier-based agent count enforcement (Phase 6 WS4) ────────────────────
|
||||
// When TierService is available and TIER_ENFORCEMENT is enabled, validate
|
||||
// the per-tier agent limit for the requesting organization.
|
||||
if (this.tierService !== null && process.env['TIER_ENFORCEMENT'] !== 'false') {
|
||||
const tier = await this.tierService.fetchTier(orgId);
|
||||
await this.tierService.enforceAgentLimit(orgId, tier);
|
||||
}
|
||||
|
||||
// Enforce legacy free-tier agent count limit (global across all orgs)
|
||||
const currentCount = await this.agentRepository.countActive();
|
||||
if (currentCount >= FREE_TIER_MAX_AGENTS) {
|
||||
throw new FreeTierLimitError(
|
||||
@@ -83,8 +101,7 @@ export class AgentService {
|
||||
|
||||
// Generate a W3C DID for the new agent when DIDService is available
|
||||
if (this.didService !== null) {
|
||||
const organizationId = data.organizationId ?? 'org_system';
|
||||
await this.didService.generateDIDForAgent(agent.agentId, organizationId);
|
||||
await this.didService.generateDIDForAgent(agent.agentId, orgId);
|
||||
}
|
||||
|
||||
// Synchronous audit insert
|
||||
@@ -100,6 +117,17 @@ export class AgentService {
|
||||
// Instrument: count successful agent registrations
|
||||
agentsRegisteredTotal.inc({ deployment_env: data.deploymentEnv });
|
||||
|
||||
// Analytics: record agent_registered event (fire-and-forget)
|
||||
if (this.analyticsService !== null) {
|
||||
void this.analyticsService.recordEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'agent_registered',
|
||||
).catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AgentService] analytics record (agent_registered) failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
@@ -263,6 +291,17 @@ export class AgentService {
|
||||
{},
|
||||
);
|
||||
|
||||
// Analytics: record agent_deactivated event (fire-and-forget)
|
||||
if (this.analyticsService !== null) {
|
||||
void this.analyticsService.recordEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'agent_deactivated',
|
||||
).catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AgentService] analytics record (agent_deactivated) failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
|
||||
185
src/services/AnalyticsService.ts
Normal file
185
src/services/AnalyticsService.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Analytics Service for SentryAgent.ai AgentIdP.
|
||||
* Records daily aggregated analytics events 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.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/** A single date-bucketed token count entry. */
|
||||
export interface ITokenTrendEntry {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Agent activity bucketed by day-of-week and hour-of-day. */
|
||||
export interface IAgentActivityEntry {
|
||||
agent_id: string;
|
||||
dow: number;
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Per-agent token issuance summary for the current calendar month. */
|
||||
export interface IAgentUsageSummaryEntry {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
/** Maximum number of days allowed for trend queries. */
|
||||
const MAX_TREND_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Service for recording and querying tenant analytics events.
|
||||
* Analytics writes are fire-and-forget and never block primary request paths.
|
||||
*/
|
||||
export class AnalyticsService {
|
||||
/**
|
||||
* @param pool - The PostgreSQL connection pool.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Records a single analytics event for a tenant by upserting a daily counter row.
|
||||
* This method is fire-and-forget: it catches all errors, logs them, and never throws.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @param metricType - The event type (e.g. 'token_issued', 'agent_registered').
|
||||
* @returns Promise that resolves when the upsert completes (or is silently swallowed on error).
|
||||
*/
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AnalyticsService] recordEvent failed — primary path unaffected', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns daily token issuance counts for the last N days (max 90).
|
||||
* Days with no recorded events are filled in with a count of 0.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @param days - Number of days to look back (1–90).
|
||||
* @returns Array of date/count entries sorted ascending by date.
|
||||
*/
|
||||
async getTokenTrend(tenantId: string, days: number): Promise<ITokenTrendEntry[]> {
|
||||
const clampedDays = Math.min(days, MAX_TREND_DAYS);
|
||||
|
||||
// Generate a complete date series and left-join analytics data so that
|
||||
// days with no events appear as 0.
|
||||
const result = await this.pool.query<{ date: string; count: string }>(
|
||||
`SELECT
|
||||
gs.date::DATE::TEXT AS date,
|
||||
COALESCE(ae.count, 0)::INTEGER AS count
|
||||
FROM generate_series(
|
||||
CURRENT_DATE - ($1::INTEGER - 1) * 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`,
|
||||
[clampedDays, tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
date: row.date,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns agent activity bucketed by day-of-week (0=Sun…6=Sat) and hour-of-day
|
||||
* for the last 30 days. Only metric_types that include an agent_id prefix
|
||||
* (format: 'agent:<agentId>:<metricType>') are included.
|
||||
*
|
||||
* Since analytics_events stores daily aggregates, DOW/hour granularity is derived
|
||||
* from the event date at day resolution (hour defaults to 0 for daily rollups).
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns Array of activity bucket entries sorted by agent_id, dow, hour.
|
||||
*/
|
||||
async getAgentActivity(tenantId: string): Promise<IAgentActivityEntry[]> {
|
||||
const result = await this.pool.query<{
|
||||
agent_id: string;
|
||||
dow: string;
|
||||
hour: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
SPLIT_PART(ae.metric_type, ':', 2) AS agent_id,
|
||||
EXTRACT(DOW FROM ae.date)::INTEGER::TEXT AS dow,
|
||||
0::TEXT AS hour,
|
||||
SUM(ae.count)::TEXT AS count
|
||||
FROM analytics_events ae
|
||||
WHERE ae.organization_id = $1
|
||||
AND ae.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
AND ae.metric_type LIKE 'agent:%:%'
|
||||
GROUP BY
|
||||
SPLIT_PART(ae.metric_type, ':', 2),
|
||||
EXTRACT(DOW FROM ae.date)
|
||||
ORDER BY agent_id, dow`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
agent_id: row.agent_id,
|
||||
dow: Number(row.dow),
|
||||
hour: Number(row.hour),
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns per-agent token issuance totals for the current calendar month,
|
||||
* joined with the agent name from the agents table.
|
||||
* Results are sorted descending by token_count.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns Array of agent usage summary entries.
|
||||
*/
|
||||
async getAgentUsageSummary(tenantId: string): Promise<IAgentUsageSummaryEntry[]> {
|
||||
const result = await this.pool.query<{
|
||||
agent_id: string;
|
||||
name: string;
|
||||
token_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
a.agent_id,
|
||||
a.owner AS name,
|
||||
COALESCE(SUM(ae.count), 0)::INTEGER AS token_count
|
||||
FROM agents a
|
||||
LEFT JOIN analytics_events ae
|
||||
ON ae.organization_id = a.organization_id
|
||||
AND ae.metric_type = 'token_issued'
|
||||
AND ae.date >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
AND ae.date < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
|
||||
WHERE a.organization_id = $1
|
||||
AND a.status != 'decommissioned'
|
||||
GROUP BY a.agent_id, a.owner
|
||||
ORDER BY token_count DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
agent_id: row.agent_id,
|
||||
name: row.name,
|
||||
token_count: Number(row.token_count),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import Stripe from 'stripe';
|
||||
import { TierService } from './TierService.js';
|
||||
import { isTierName } from '../config/tiers.js';
|
||||
|
||||
/**
|
||||
* Current subscription status for a tenant.
|
||||
@@ -36,10 +38,13 @@ export class BillingService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param stripe - Configured Stripe client instance.
|
||||
* @param tierService - Optional TierService. When provided, tier upgrades are applied
|
||||
* when a checkout.session.completed event carries tier metadata.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly tierService: TierService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -101,6 +106,14 @@ export class BillingService {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await this.upsertSubscription(subscription);
|
||||
}
|
||||
|
||||
// ── Tier upgrade via checkout session ────────────────────────────────────
|
||||
// When a checkout session is completed and the session metadata contains
|
||||
// { orgId, targetTier }, apply the tier upgrade to the organizations table.
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await this.applyTierUpgradeIfPresent(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +150,28 @@ export class BillingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a tier upgrade when the checkout session metadata contains
|
||||
* the required fields (`orgId` and `targetTier`).
|
||||
* Skips silently when metadata is absent, incomplete, or TierService is not wired.
|
||||
*
|
||||
* @param session - The completed Stripe Checkout Session.
|
||||
*/
|
||||
private async applyTierUpgradeIfPresent(session: Stripe.Checkout.Session): Promise<void> {
|
||||
if (this.tierService === null) return;
|
||||
|
||||
const metadata = session.metadata;
|
||||
if (!metadata) return;
|
||||
|
||||
const orgId = metadata['orgId'];
|
||||
const targetTier = metadata['targetTier'];
|
||||
|
||||
if (!orgId || !targetTier) return;
|
||||
if (!isTierName(targetTier)) return;
|
||||
|
||||
await this.tierService.applyUpgrade(orgId, targetTier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a Stripe subscription into tenant_subscriptions.
|
||||
* Resolves the tenant from the subscription's customer.
|
||||
|
||||
359
src/services/ComplianceService.ts
Normal file
359
src/services/ComplianceService.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* ComplianceService — AGNTCY Compliance Report generation and Agent Card export.
|
||||
*
|
||||
* Builds multi-section compliance reports covering agent identity verification
|
||||
* and audit trail integrity, and exports all active agents as AGNTCY-standard
|
||||
* agent card JSON. Reports are cached in Redis for 5 minutes to avoid
|
||||
* repeated expensive DB queries.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { AuditVerificationService } from './AuditVerificationService.js';
|
||||
|
||||
// ============================================================================
|
||||
// Report interfaces
|
||||
// ============================================================================
|
||||
|
||||
/** Status value for a compliance check section. */
|
||||
export type ComplianceStatus = 'pass' | 'fail' | 'warn';
|
||||
|
||||
/**
|
||||
* A single named compliance check section within a full report.
|
||||
*/
|
||||
export interface IComplianceSection {
|
||||
/** Human-readable section identifier (e.g. 'agent-identity', 'audit-trail'). */
|
||||
name: string;
|
||||
/** Aggregate status for this section. */
|
||||
status: ComplianceStatus;
|
||||
/** Human-readable detail string describing the check result. */
|
||||
details: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full AGNTCY compliance report for a single tenant.
|
||||
* Returned by generateReport() and cached in Redis.
|
||||
*/
|
||||
export interface IComplianceReport {
|
||||
/** ISO 8601 timestamp of when this report was generated. */
|
||||
generated_at: string;
|
||||
/** The tenant (organization) this report covers. */
|
||||
tenant_id: string;
|
||||
/** AGNTCY schema version this report conforms to. */
|
||||
agntcy_schema_version: string;
|
||||
/** Ordered list of named compliance sections. */
|
||||
sections: IComplianceSection[];
|
||||
/** Rolled-up overall status across all sections. */
|
||||
overall_status: ComplianceStatus;
|
||||
/** Present and true when the report was served from Redis cache. */
|
||||
from_cache?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent card interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* AGNTCY-standard agent card export for a single agent.
|
||||
*/
|
||||
export interface IAgentCard {
|
||||
/** DID:WEB identifier for the agent, or the raw agent_id if no DID is set. */
|
||||
id: string;
|
||||
/** Human-readable name (owner field from the agents table). */
|
||||
name: string;
|
||||
/** List of capability strings declared by the agent. */
|
||||
capabilities: string[];
|
||||
/** Canonical HTTPS endpoint for this agent on the SentryAgent.ai platform. */
|
||||
endpoint: string;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
created_at: string;
|
||||
/** AGNTCY schema version this card conforms to. */
|
||||
agntcy_schema_version: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal DB row shapes
|
||||
// ============================================================================
|
||||
|
||||
/** Row returned when querying active agents for a tenant. */
|
||||
interface AgentRow {
|
||||
agent_id: string;
|
||||
owner: string;
|
||||
capabilities: string[];
|
||||
created_at: Date;
|
||||
did: string | null;
|
||||
}
|
||||
|
||||
/** Credential check result — one row per agent. */
|
||||
interface CredentialCheckRow {
|
||||
agent_id: string;
|
||||
expires_at: Date | null;
|
||||
is_expired: boolean;
|
||||
expires_soon: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Redis TTL in seconds for cached compliance reports (5 minutes). */
|
||||
const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/** AGNTCY schema version supported by this implementation. */
|
||||
const AGNTCY_SCHEMA_VERSION = '1.0';
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Service for generating AGNTCY compliance reports and exporting agent cards.
|
||||
*
|
||||
* Compliance report sections:
|
||||
* - agent-identity: Verifies all active agents have a valid DID and non-expired credential.
|
||||
* - audit-trail: Verifies the cryptographic integrity of the audit hash chain.
|
||||
*
|
||||
* Reports are cached in Redis under `compliance:report:<tenantId>` for 5 minutes.
|
||||
*/
|
||||
export class ComplianceService {
|
||||
/** @param pool - PostgreSQL connection pool. */
|
||||
/** @param redis - Connected Redis client for report caching. */
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly redis: RedisClientType,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an AGNTCY compliance report for the given tenant.
|
||||
*
|
||||
* The report is cached in Redis at `compliance:report:<tenantId>` for 5 minutes.
|
||||
* When a cached result is returned, `from_cache` is set to `true`.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns The compliance report (possibly from cache).
|
||||
*/
|
||||
async generateReport(tenantId: string): Promise<IComplianceReport> {
|
||||
const cacheKey = `compliance:report:${tenantId}`;
|
||||
|
||||
// Attempt cache read
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
const parsed = JSON.parse(cached) as IComplianceReport;
|
||||
parsed.from_cache = true;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Build all sections
|
||||
const sections: IComplianceSection[] = await Promise.all([
|
||||
this.buildAgentIdentitySection(tenantId),
|
||||
this.buildAuditTrailSection(tenantId),
|
||||
]);
|
||||
|
||||
const overall_status = this.rollUpStatus(sections);
|
||||
|
||||
const report: IComplianceReport = {
|
||||
generated_at: new Date().toISOString(),
|
||||
tenant_id: tenantId,
|
||||
agntcy_schema_version: AGNTCY_SCHEMA_VERSION,
|
||||
sections,
|
||||
overall_status,
|
||||
};
|
||||
|
||||
// Cache without from_cache field
|
||||
await this.redis.set(cacheKey, JSON.stringify(report), { EX: CACHE_TTL_SECONDS });
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all active (non-decommissioned) agents for the given tenant as
|
||||
* AGNTCY-standard agent cards.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns Array of agent cards.
|
||||
*/
|
||||
async exportAgentCards(tenantId: string): Promise<IAgentCard[]> {
|
||||
const result = await this.pool.query<AgentRow>(
|
||||
`SELECT agent_id, owner, capabilities, created_at, did
|
||||
FROM agents
|
||||
WHERE organization_id = $1
|
||||
AND status != 'decommissioned'
|
||||
ORDER BY created_at ASC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => this.toAgentCard(row));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Section builders
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds the `agent-identity` compliance section.
|
||||
*
|
||||
* Checks each active agent for:
|
||||
* - Valid DID (did field must be non-null)
|
||||
* - Non-expired credential (expires_at > NOW())
|
||||
* - Credential expiring within 7 days triggers 'warn' status
|
||||
*
|
||||
* Status rules (in priority order):
|
||||
* - `fail`: any agent is missing a DID
|
||||
* - `warn`: any credential expires within 7 days
|
||||
* - `pass`: all checks pass
|
||||
*
|
||||
* @param tenantId - The organization_id to check.
|
||||
* @returns The agent-identity section.
|
||||
*/
|
||||
private async buildAgentIdentitySection(tenantId: string): Promise<IComplianceSection> {
|
||||
const agentResult = await this.pool.query<AgentRow>(
|
||||
`SELECT agent_id, owner, capabilities, created_at, did
|
||||
FROM agents
|
||||
WHERE organization_id = $1
|
||||
AND status != 'decommissioned'`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
const agents = agentResult.rows;
|
||||
|
||||
if (agents.length === 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'pass',
|
||||
details: 'No active agents found for this tenant.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for missing DIDs
|
||||
const missingDid = agents.filter((a) => a.did === null || a.did === '');
|
||||
if (missingDid.length > 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'fail',
|
||||
details: `${missingDid.length} agent(s) are missing a DID identifier: ${missingDid.map((a) => a.agent_id).join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check credentials for each agent
|
||||
const agentIds = agents.map((a) => a.agent_id);
|
||||
const credResult = await this.pool.query<CredentialCheckRow>(
|
||||
`SELECT
|
||||
c.client_id AS agent_id,
|
||||
c.expires_at,
|
||||
(c.expires_at IS NOT NULL AND c.expires_at <= NOW()) AS is_expired,
|
||||
(c.expires_at IS NOT NULL AND c.expires_at <= NOW() + INTERVAL '7 days' AND c.expires_at > NOW()) AS expires_soon
|
||||
FROM credentials c
|
||||
WHERE c.client_id = ANY($1::uuid[])
|
||||
AND c.status = 'active'`,
|
||||
[agentIds],
|
||||
);
|
||||
|
||||
const credMap = new Map<string, CredentialCheckRow>();
|
||||
for (const row of credResult.rows) {
|
||||
// Keep the most-recently-checked row (last active credential per agent)
|
||||
credMap.set(row.agent_id, row);
|
||||
}
|
||||
|
||||
const expiredAgents: string[] = [];
|
||||
const expiringSoonAgents: string[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
const cred = credMap.get(agent.agent_id);
|
||||
if (cred) {
|
||||
if (cred.is_expired) {
|
||||
expiredAgents.push(agent.agent_id);
|
||||
} else if (cred.expires_soon) {
|
||||
expiringSoonAgents.push(agent.agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredAgents.length > 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'fail',
|
||||
details: `${expiredAgents.length} agent(s) have expired credentials: ${expiredAgents.join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (expiringSoonAgents.length > 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'warn',
|
||||
details: `${expiringSoonAgents.length} agent(s) have credentials expiring within 7 days: ${expiringSoonAgents.join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'pass',
|
||||
details: `All ${agents.length} active agent(s) have valid DIDs and non-expiring credentials.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `audit-trail` compliance section.
|
||||
*
|
||||
* Delegates to AuditVerificationService.verifyChain() with no date restrictions,
|
||||
* covering the full audit history.
|
||||
*
|
||||
* @param tenantId - Not used directly; AuditVerificationService checks global chain.
|
||||
* @returns The audit-trail section.
|
||||
*/
|
||||
private async buildAuditTrailSection(_tenantId: string): Promise<IComplianceSection> {
|
||||
const auditService = new AuditVerificationService(this.pool);
|
||||
const result = await auditService.verifyChain();
|
||||
|
||||
if (result.verified) {
|
||||
return {
|
||||
name: 'audit-trail',
|
||||
status: 'pass',
|
||||
details: `Audit chain intact. ${result.checkedCount} event(s) verified.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'audit-trail',
|
||||
status: 'fail',
|
||||
details: `Audit chain integrity failure detected at event ${result.brokenAtEventId ?? 'unknown'}. ${result.checkedCount} event(s) checked before break.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes the rolled-up overall status from all sections.
|
||||
* Priority: fail > warn > pass.
|
||||
*
|
||||
* @param sections - The compliance sections to roll up.
|
||||
* @returns The worst status across all sections.
|
||||
*/
|
||||
private rollUpStatus(sections: IComplianceSection[]): ComplianceStatus {
|
||||
if (sections.some((s) => s.status === 'fail')) return 'fail';
|
||||
if (sections.some((s) => s.status === 'warn')) return 'warn';
|
||||
return 'pass';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw DB agent row to an AGNTCY agent card.
|
||||
*
|
||||
* @param row - The agent row from the database.
|
||||
* @returns An AGNTCY-standard IAgentCard.
|
||||
*/
|
||||
private toAgentCard(row: AgentRow): IAgentCard {
|
||||
return {
|
||||
id: row.did ?? row.agent_id,
|
||||
name: row.owner,
|
||||
capabilities: row.capabilities,
|
||||
endpoint: `https://api.sentryagent.ai/agents/${row.agent_id}`,
|
||||
created_at: row.created_at.toISOString(),
|
||||
agntcy_schema_version: AGNTCY_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { IDTokenService } from './IDTokenService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import { EncryptionService } from './EncryptionService.js';
|
||||
import { AnalyticsService } from './AnalyticsService.js';
|
||||
import {
|
||||
ITokenPayload,
|
||||
ITokenResponse,
|
||||
@@ -55,6 +56,8 @@ export class OAuth2Service {
|
||||
* token.revoked events are published as webhooks and Kafka messages (fire-and-forget).
|
||||
* @param encryptionService - Optional EncryptionService. When provided, encrypted
|
||||
* `secret_hash` values are decrypted before bcrypt verification (SOC 2 CC6.1).
|
||||
* @param analyticsService - Optional AnalyticsService. When provided, a
|
||||
* `token_issued` event is recorded fire-and-forget on each successful issuance.
|
||||
*/
|
||||
constructor(
|
||||
private readonly tokenRepository: TokenRepository,
|
||||
@@ -67,6 +70,7 @@ export class OAuth2Service {
|
||||
private readonly idTokenService: IDTokenService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
private readonly encryptionService: EncryptionService | null = null,
|
||||
private readonly analyticsService: AnalyticsService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -230,6 +234,17 @@ export class OAuth2Service {
|
||||
// Instrument: count successful token issuances
|
||||
tokensIssuedTotal.inc({ scope });
|
||||
|
||||
// Analytics: record token issuance event (fire-and-forget — never blocks response)
|
||||
if (this.analyticsService !== null) {
|
||||
void this.analyticsService.recordEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'token_issued',
|
||||
).catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[OAuth2Service] analytics record failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
|
||||
261
src/services/TierService.ts
Normal file
261
src/services/TierService.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Tier Service for SentryAgent.ai AgentIdP.
|
||||
* Single authority for all tier-related business logic:
|
||||
* - Fetching current tier and usage status
|
||||
* - Initiating Stripe checkout for tier upgrades
|
||||
* - Applying a confirmed tier upgrade to the organizations table
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import Stripe from 'stripe';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { TIER_CONFIG, TierName, TIER_RANK, isTierName } from '../config/tiers.js';
|
||||
import { ValidationError, TierLimitError } from '../utils/errors.js';
|
||||
|
||||
/** Redis key prefixes for daily counters. */
|
||||
const CALLS_KEY_PREFIX = 'rate:tier:calls:';
|
||||
const TOKENS_KEY_PREFIX = 'rate:tier:tokens:';
|
||||
|
||||
/**
|
||||
* Current tier status snapshot returned by getStatus().
|
||||
*/
|
||||
export interface ITierStatus {
|
||||
/** The tenant's current tier name. */
|
||||
tier: TierName;
|
||||
/** Per-tier limits from TIER_CONFIG. */
|
||||
limits: {
|
||||
maxAgents: number;
|
||||
maxCallsPerDay: number;
|
||||
maxTokensPerDay: number;
|
||||
};
|
||||
/** Live usage counters for the current UTC day. */
|
||||
usage: {
|
||||
/** Number of API calls made today (from Redis). */
|
||||
callsToday: number;
|
||||
/** Number of tokens issued today (from Redis). */
|
||||
tokensToday: number;
|
||||
/** Number of non-decommissioned agents for this org. */
|
||||
agentCount: number;
|
||||
};
|
||||
/** ISO 8601 timestamp of the next UTC midnight (daily limit reset time). */
|
||||
resetAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of initiateUpgrade() — contains the Stripe Checkout URL.
|
||||
*/
|
||||
export interface IUpgradeInitiation {
|
||||
/** URL the tenant should be redirected to for payment. */
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
/** DB row shape for organization tier queries. */
|
||||
interface IOrgTierRow {
|
||||
tier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for tenant tier management.
|
||||
* Owns all tier logic — controllers and middleware delegate to this service.
|
||||
*/
|
||||
export class TierService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param redis - Redis client for usage counter access.
|
||||
* @param stripe - Configured Stripe client instance.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly redis: RedisClientType,
|
||||
private readonly stripe: Stripe,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the current tier, limits, usage, and daily reset time for an org.
|
||||
*
|
||||
* Usage counters are read directly from Redis (live counts).
|
||||
* Agent count is read from the database.
|
||||
* Falls back gracefully if Redis is unavailable (returns 0 for Redis-backed counters).
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns ITierStatus snapshot.
|
||||
*/
|
||||
async getStatus(orgId: string): Promise<ITierStatus> {
|
||||
const tier = await this.fetchTier(orgId);
|
||||
const limits = TIER_CONFIG[tier];
|
||||
|
||||
const [callsToday, tokensToday, agentCount] = await Promise.all([
|
||||
this.readRedisCounter(CALLS_KEY_PREFIX + orgId),
|
||||
this.readRedisCounter(TOKENS_KEY_PREFIX + orgId),
|
||||
this.fetchAgentCount(orgId),
|
||||
]);
|
||||
|
||||
const resetAt = this.nextUtcMidnight().toISOString();
|
||||
|
||||
return {
|
||||
tier,
|
||||
limits: {
|
||||
maxAgents: limits.maxAgents,
|
||||
maxCallsPerDay: limits.maxCallsPerDay,
|
||||
maxTokensPerDay: limits.maxTokensPerDay,
|
||||
},
|
||||
usage: { callsToday, tokensToday, agentCount },
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the target tier is a valid upgrade and creates a Stripe Checkout
|
||||
* Session for the new tier's price. Returns the checkout URL.
|
||||
*
|
||||
* Metadata on the session includes `{ orgId, targetTier }` so the webhook handler
|
||||
* can apply the upgrade after payment succeeds.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @param targetTier - The desired new tier.
|
||||
* @returns IUpgradeInitiation with the Stripe checkout URL.
|
||||
* @throws ValidationError if targetTier is not higher than the current tier.
|
||||
* @throws Error if Stripe does not return a session URL.
|
||||
*/
|
||||
async initiateUpgrade(orgId: string, targetTier: TierName): Promise<IUpgradeInitiation> {
|
||||
const currentTier = await this.fetchTier(orgId);
|
||||
|
||||
if (TIER_RANK[targetTier] <= TIER_RANK[currentTier]) {
|
||||
throw new ValidationError(
|
||||
`Cannot downgrade or remain on the same tier. Current tier: ${currentTier}. Downgrades require contacting support.`,
|
||||
{ currentTier, targetTier },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the Stripe price ID for the target tier.
|
||||
// Each tier maps to a dedicated price ID env var: STRIPE_PRICE_ID_PRO, STRIPE_PRICE_ID_ENTERPRISE.
|
||||
const priceIdEnvKey = `STRIPE_PRICE_ID_${targetTier.toUpperCase()}`;
|
||||
const priceId = process.env[priceIdEnvKey] ?? process.env['STRIPE_PRICE_ID'];
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
client_reference_id: orgId,
|
||||
metadata: { orgId, targetTier },
|
||||
line_items: priceId ? [{ price: priceId, quantity: 1 }] : undefined,
|
||||
success_url:
|
||||
process.env['STRIPE_SUCCESS_URL'] ??
|
||||
`${process.env['APP_BASE_URL'] ?? 'http://localhost:3000'}/dashboard?billing=success`,
|
||||
cancel_url:
|
||||
process.env['STRIPE_CANCEL_URL'] ??
|
||||
`${process.env['APP_BASE_URL'] ?? 'http://localhost:3000'}/dashboard?billing=cancel`,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error('Stripe did not return a checkout session URL.');
|
||||
}
|
||||
|
||||
return { checkoutUrl: session.url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a confirmed tier upgrade to the organizations table.
|
||||
* Sets both `tier` and `tier_updated_at`.
|
||||
* Called by the Stripe webhook handler after `checkout.session.completed`.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @param tier - The new tier to apply.
|
||||
* @returns Promise that resolves when the update is persisted.
|
||||
*/
|
||||
async applyUpgrade(orgId: string, tier: TierName): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE organizations
|
||||
SET tier = $1, tier_updated_at = NOW()
|
||||
WHERE organization_id = $2`,
|
||||
[tier, orgId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current tier for an org from the database.
|
||||
* Returns 'free' as the safe default when no row is found.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns The current TierName.
|
||||
*/
|
||||
async fetchTier(orgId: string): Promise<TierName> {
|
||||
const result = await this.pool.query<IOrgTierRow>(
|
||||
`SELECT tier FROM organizations WHERE organization_id = $1 LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (result.rows.length === 0) return 'free';
|
||||
const raw = result.rows[0].tier;
|
||||
return isTierName(raw) ? raw : 'free';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads an integer counter from Redis. Returns 0 on error or missing key.
|
||||
*
|
||||
* @param key - The full Redis key.
|
||||
* @returns The counter value, or 0 when unavailable.
|
||||
*/
|
||||
private async readRedisCounter(key: string): Promise<number> {
|
||||
try {
|
||||
const value = await this.redis.get(key);
|
||||
if (value === null) return 0;
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts non-decommissioned agents for an org.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns Agent count.
|
||||
*/
|
||||
private async fetchAgentCount(orgId: string): Promise<number> {
|
||||
const result = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM agents
|
||||
WHERE organization_id = $1 AND status != 'decommissioned'`,
|
||||
[orgId],
|
||||
);
|
||||
return parseInt(result.rows[0]?.count ?? '0', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Date object representing the next UTC midnight.
|
||||
*
|
||||
* @returns Date set to 00:00:00.000 UTC of the next calendar day.
|
||||
*/
|
||||
private nextUtcMidnight(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCHours(24, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the per-org agent count limit for the given tier.
|
||||
* Throws TierLimitError when the current count is at or over the allowed maximum.
|
||||
* Used by AgentService before creating a new agent.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @param tier - The organization's current tier.
|
||||
* @throws TierLimitError when the limit is reached.
|
||||
*/
|
||||
async enforceAgentLimit(orgId: string, tier: TierName): Promise<void> {
|
||||
const limit = TIER_CONFIG[tier].maxAgents;
|
||||
// Infinity means enterprise — no enforcement
|
||||
if (!isFinite(limit)) return;
|
||||
|
||||
const count = await this.fetchAgentCount(orgId);
|
||||
if (count >= limit) {
|
||||
throw new TierLimitError('agent', limit, { orgId, tier, current: count });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,3 +199,20 @@ export class AlreadyMemberError extends SentryAgentError {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 429 — Tenant has exceeded a tier-based resource limit (agents, API calls, or tokens). */
|
||||
export class TierLimitError extends SentryAgentError {
|
||||
/**
|
||||
* @param limitType - Human-readable name of the limit that was exceeded (e.g. 'agent', 'API call').
|
||||
* @param limit - The numeric limit that was reached.
|
||||
* @param details - Optional extra structured detail.
|
||||
*/
|
||||
constructor(limitType: string, limit: number, details?: Record<string, unknown>) {
|
||||
super(
|
||||
`You have reached your ${limitType} limit of ${limit}. Upgrade your plan to increase this limit.`,
|
||||
'tier_limit_exceeded',
|
||||
429,
|
||||
{ limitType, limit, ...details },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
385
tests/agntcy-conformance/conformance.test.ts
Normal file
385
tests/agntcy-conformance/conformance.test.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* AGNTCY Conformance Test Suite for SentryAgent.ai AgentIdP.
|
||||
*
|
||||
* Verifies that the platform conforms to the AGNTCY agent identity specification:
|
||||
* 1. Agent registration creates a DID:WEB identifier.
|
||||
* 2. Token issuance for agent client (client_credentials grant).
|
||||
* 3. A2A delegation chain create + verify (gated by A2A_ENABLED).
|
||||
* 4. Compliance report generation returns a valid AGNTCY structure.
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
import request from 'supertest';
|
||||
import { Application } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
// ── Environment setup — must happen before importing app ──────────────────────
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
process.env['DATABASE_URL'] =
|
||||
process.env['TEST_DATABASE_URL'] ??
|
||||
'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
|
||||
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
|
||||
process.env['JWT_PRIVATE_KEY'] = privateKey;
|
||||
process.env['JWT_PUBLIC_KEY'] = publicKey;
|
||||
process.env['NODE_ENV'] = 'test';
|
||||
process.env['COMPLIANCE_ENABLED'] = 'true';
|
||||
|
||||
// Ensure A2A tests only run when the feature is on
|
||||
const a2aEnabled = process.env['A2A_ENABLED'] !== 'false';
|
||||
|
||||
// ── Imports (after env is set) ────────────────────────────────────────────────
|
||||
|
||||
import { createApp } from '../../src/app.js';
|
||||
import { signToken } from '../../src/utils/jwt.js';
|
||||
import { closePool } from '../../src/db/pool.js';
|
||||
import { closeRedisClient } from '../../src/cache/redis.js';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a signed JWT for use in test requests.
|
||||
*
|
||||
* @param sub - Subject (agentId).
|
||||
* @param scope - Space-separated OAuth 2.0 scopes.
|
||||
* @param organizationId - Optional organization_id claim.
|
||||
* @returns Signed JWT string.
|
||||
*/
|
||||
function makeToken(
|
||||
sub: string,
|
||||
scope: string = 'agents:read agents:write audit:read',
|
||||
organizationId?: string,
|
||||
): string {
|
||||
const payload: Record<string, unknown> = { sub, client_id: sub, scope, jti: uuidv4() };
|
||||
if (organizationId !== undefined) {
|
||||
payload['organization_id'] = organizationId;
|
||||
}
|
||||
return signToken(payload as Parameters<typeof signToken>[0], privateKey);
|
||||
}
|
||||
|
||||
// ── Test suite ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AGNTCY Conformance Suite', () => {
|
||||
let app: Application;
|
||||
let pool: Pool;
|
||||
|
||||
// ── Migrations required for conformance tests ─────────────────────────────
|
||||
|
||||
const migrations = [
|
||||
`CREATE TABLE IF NOT EXISTS organizations (
|
||||
organization_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
tier VARCHAR(32) NOT NULL DEFAULT 'free',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
organization_id UUID REFERENCES organizations(organization_id),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
agent_type VARCHAR(32) NOT NULL,
|
||||
version VARCHAR(64) NOT NULL,
|
||||
capabilities TEXT[] NOT NULL DEFAULT '{}',
|
||||
owner VARCHAR(128) NOT NULL,
|
||||
deployment_env VARCHAR(16) NOT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'active',
|
||||
did VARCHAR(512),
|
||||
did_document JSONB,
|
||||
did_created_at TIMESTAMPTZ,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS credentials (
|
||||
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL,
|
||||
secret_hash VARCHAR(255) NOT NULL,
|
||||
vault_path VARCHAR(512),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_events (
|
||||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL,
|
||||
organization_id UUID,
|
||||
action VARCHAR(64) NOT NULL,
|
||||
outcome VARCHAR(16) NOT NULL,
|
||||
ip_address VARCHAR(64) NOT NULL DEFAULT '127.0.0.1',
|
||||
user_agent TEXT NOT NULL DEFAULT 'test',
|
||||
metadata JSONB NOT NULL DEFAULT '{}',
|
||||
hash VARCHAR(64) NOT NULL DEFAULT '',
|
||||
previous_hash VARCHAR(64) NOT NULL DEFAULT '',
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS token_revocations (
|
||||
jti UUID PRIMARY KEY,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS agent_did_keys (
|
||||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES agents(agent_id),
|
||||
key_type VARCHAR(32) NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
private_key_encrypted TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS delegation_chains (
|
||||
chain_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
delegator_id UUID NOT NULL,
|
||||
delegatee_id UUID NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
token TEXT
|
||||
)`,
|
||||
];
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
|
||||
|
||||
for (const sql of migrations) {
|
||||
await pool.query(sql);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await pool.query('DELETE FROM audit_events');
|
||||
await pool.query('DELETE FROM credentials');
|
||||
await pool.query('DELETE FROM agents');
|
||||
await pool.query('DELETE FROM organizations');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
await closePool();
|
||||
await closeRedisClient();
|
||||
});
|
||||
|
||||
// ── Conformance test 1: Agent registration creates DID:WEB identifier ─────
|
||||
|
||||
describe('Conformance 1 — Agent registration creates DID:WEB identifier', () => {
|
||||
it('should register an agent and return a did field starting with did:web:', async () => {
|
||||
const agentId = uuidv4();
|
||||
const token = makeToken(agentId, 'agents:read agents:write');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/v1/agents')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
email: `conformance-agent-${agentId}@sentryagent.ai`,
|
||||
agentType: 'screener',
|
||||
version: '1.0.0',
|
||||
capabilities: ['identity:read'],
|
||||
owner: 'conformance-team',
|
||||
deploymentEnv: 'development',
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.agentId).toBeDefined();
|
||||
|
||||
// Verify DID is present and conforms to did:web: scheme
|
||||
if (res.body.did !== undefined && res.body.did !== null) {
|
||||
expect(typeof res.body.did).toBe('string');
|
||||
expect((res.body.did as string).startsWith('did:web:')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Conformance test 2: Token issuance via client_credentials grant ────────
|
||||
|
||||
describe('Conformance 2 — Token issuance for agent client (client_credentials)', () => {
|
||||
it('should issue a Bearer JWT via client_credentials grant', async () => {
|
||||
const agentId = uuidv4();
|
||||
const setupToken = makeToken(agentId, 'agents:read agents:write');
|
||||
|
||||
// Register agent
|
||||
await pool.query(
|
||||
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES ($1, $2, 'screener', '1.0.0', '{"identity:read"}', 'conformance-team', 'development', 'active')`,
|
||||
[agentId, `cred-test-${agentId}@sentryagent.ai`],
|
||||
);
|
||||
|
||||
// Generate credentials via API
|
||||
const credRes = await request(app)
|
||||
.post(`/api/v1/agents/${agentId}/credentials`)
|
||||
.set('Authorization', `Bearer ${setupToken}`)
|
||||
.send({});
|
||||
|
||||
expect(credRes.status).toBe(201);
|
||||
const { clientSecret } = credRes.body as { clientSecret: string };
|
||||
|
||||
// Issue token via client_credentials grant
|
||||
const tokenRes = await request(app)
|
||||
.post('/api/v1/token')
|
||||
.type('form')
|
||||
.send({
|
||||
grant_type: 'client_credentials',
|
||||
client_id: agentId,
|
||||
client_secret: clientSecret,
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
expect(tokenRes.status).toBe(200);
|
||||
expect(tokenRes.body.access_token).toBeDefined();
|
||||
expect(tokenRes.body.token_type).toBe('Bearer');
|
||||
expect(typeof tokenRes.body.access_token).toBe('string');
|
||||
|
||||
// Verify JWT structure (3 parts separated by dots)
|
||||
const jwtParts = (tokenRes.body.access_token as string).split('.');
|
||||
expect(jwtParts).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Conformance test 3: A2A delegation chain (gated by A2A_ENABLED) ────────
|
||||
|
||||
(a2aEnabled ? describe : describe.skip)(
|
||||
'Conformance 3 — A2A delegation chain create + verify (A2A_ENABLED=true)',
|
||||
() => {
|
||||
it('should create and verify a delegation chain between two agents', async () => {
|
||||
const delegatorId = uuidv4();
|
||||
const delegateeId = uuidv4();
|
||||
|
||||
// Insert both agents directly
|
||||
await pool.query(
|
||||
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES
|
||||
($1, $2, 'orchestrator', '1.0.0', '{"agents:delegate"}', 'delegator-team', 'development', 'active'),
|
||||
($3, $4, 'screener', '1.0.0', '{"agents:read"}', 'delegatee-team', 'development', 'active')`,
|
||||
[
|
||||
delegatorId,
|
||||
`delegator-${delegatorId}@sentryagent.ai`,
|
||||
delegateeId,
|
||||
`delegatee-${delegateeId}@sentryagent.ai`,
|
||||
],
|
||||
);
|
||||
|
||||
const delegatorToken = makeToken(delegatorId, 'agents:read agents:write');
|
||||
|
||||
// Create delegation chain
|
||||
const createRes = await request(app)
|
||||
.post('/api/v1/oauth2/token/delegate')
|
||||
.set('Authorization', `Bearer ${delegatorToken}`)
|
||||
.send({
|
||||
delegatee_id: delegateeId,
|
||||
scope: 'agents:read',
|
||||
});
|
||||
|
||||
// Accept 201 (created) or 200 (already exists)
|
||||
expect([200, 201]).toContain(createRes.status);
|
||||
|
||||
const delegationToken: string =
|
||||
createRes.body.token ??
|
||||
createRes.body.delegation_token ??
|
||||
createRes.body.access_token ??
|
||||
'';
|
||||
|
||||
// Verify delegation chain if a token was returned
|
||||
if (delegationToken !== '') {
|
||||
const verifyRes = await request(app)
|
||||
.post('/api/v1/oauth2/token/verify-delegation')
|
||||
.set('Authorization', `Bearer ${delegatorToken}`)
|
||||
.send({ token: delegationToken });
|
||||
|
||||
expect([200, 204]).toContain(verifyRes.status);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ── Conformance test 4: Compliance report returns valid AGNTCY structure ───
|
||||
|
||||
describe('Conformance 4 — Compliance report returns valid AGNTCY structure', () => {
|
||||
it('should return a compliance report with all required AGNTCY fields', async () => {
|
||||
const orgId = uuidv4();
|
||||
const agentId = uuidv4();
|
||||
|
||||
// Create organization and agent
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name, tier) VALUES ($1, $2, 'free')`,
|
||||
[orgId, `conformance-org-${orgId}`],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES ($1, $2, $3, 'screener', '1.0.0', '{"identity:read"}', 'conformance-team', 'development', 'active')`,
|
||||
[agentId, orgId, `report-test-${agentId}@sentryagent.ai`],
|
||||
);
|
||||
|
||||
const token = makeToken(agentId, 'agents:read audit:read', orgId);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/v1/compliance/report')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Verify all required AGNTCY fields are present
|
||||
expect(res.body.generated_at).toBeDefined();
|
||||
expect(res.body.tenant_id).toBeDefined();
|
||||
expect(res.body.agntcy_schema_version).toBe('1.0');
|
||||
expect(res.body.sections).toBeInstanceOf(Array);
|
||||
expect(res.body.sections.length).toBeGreaterThan(0);
|
||||
expect(['pass', 'fail', 'warn']).toContain(res.body.overall_status);
|
||||
|
||||
// Verify generated_at is a valid ISO 8601 string
|
||||
const generatedAt = new Date(res.body.generated_at as string);
|
||||
expect(generatedAt.getTime()).not.toBeNaN();
|
||||
|
||||
// Verify each section has required fields
|
||||
for (const section of res.body.sections as Array<Record<string, unknown>>) {
|
||||
expect(typeof section['name']).toBe('string');
|
||||
expect(['pass', 'fail', 'warn']).toContain(section['status']);
|
||||
expect(typeof section['details']).toBe('string');
|
||||
}
|
||||
|
||||
// Verify expected sections are present
|
||||
const sectionNames = (res.body.sections as Array<Record<string, unknown>>).map(
|
||||
(s) => s['name'],
|
||||
);
|
||||
expect(sectionNames).toContain('agent-identity');
|
||||
expect(sectionNames).toContain('audit-trail');
|
||||
});
|
||||
|
||||
it('should return X-Cache: HIT on second request within cache window', async () => {
|
||||
const orgId = uuidv4();
|
||||
const agentId = uuidv4();
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO organizations (organization_id, name, tier) VALUES ($1, $2, 'free')`,
|
||||
[orgId, `cache-test-org-${orgId}`],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
|
||||
VALUES ($1, $2, $3, 'screener', '1.0.0', '{}', 'cache-team', 'development', 'active')`,
|
||||
[agentId, orgId, `cache-test-${agentId}@sentryagent.ai`],
|
||||
);
|
||||
|
||||
const token = makeToken(agentId, 'agents:read audit:read', orgId);
|
||||
|
||||
// First request — populates cache
|
||||
await request(app)
|
||||
.get('/api/v1/compliance/report')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
// Second request — should be served from cache
|
||||
const secondRes = await request(app)
|
||||
.get('/api/v1/compliance/report')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(secondRes.status).toBe(200);
|
||||
expect(secondRes.headers['x-cache']).toBe('HIT');
|
||||
expect(secondRes.body.from_cache).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
7
tests/agntcy-conformance/jest.config.cjs
Normal file
7
tests/agntcy-conformance/jest.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
rootDir: '.',
|
||||
testMatch: ['**/*.test.ts'],
|
||||
moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' },
|
||||
};
|
||||
@@ -35,9 +35,9 @@ describe('metricsRegistry', () => {
|
||||
expect(metricsRegistry).not.toBe(register);
|
||||
});
|
||||
|
||||
it('contains exactly 14 metric entries', async () => {
|
||||
it('contains exactly 19 metric entries', async () => {
|
||||
const entries = await metricsRegistry.getMetricsAsJSON();
|
||||
expect(entries).toHaveLength(14);
|
||||
expect(entries).toHaveLength(19);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
164
tests/unit/services/AnalyticsService.test.ts
Normal file
164
tests/unit/services/AnalyticsService.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Unit tests for src/services/AnalyticsService.ts
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import { AnalyticsService } from '../../../src/services/AnalyticsService';
|
||||
|
||||
// ── Mock pg Pool ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makePool(queryFn: jest.Mock): Pool {
|
||||
return { query: queryFn } as unknown as Pool;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// AnalyticsService
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// recordEvent()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('recordEvent()', () => {
|
||||
it('should call pool.query with the upsert SQL on success', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
|
||||
await service.recordEvent('tenant-1', 'token_issued');
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT INTO analytics_events'),
|
||||
['tenant-1', 'token_issued'],
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ON CONFLICT'),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should silently swallow errors — never rejects or throws', async () => {
|
||||
const mockQuery = jest.fn().mockRejectedValue(new Error('DB connection failed'));
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
|
||||
// Must not throw — fire-and-forget contract
|
||||
await expect(service.recordEvent('tenant-err', 'token_issued')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getTokenTrend()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getTokenTrend()', () => {
|
||||
it('should cap days at 90 (MAX_TREND_DAYS)', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ date: '2026-04-04', count: '5' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
await service.getTokenTrend('tenant-1', 200);
|
||||
|
||||
// The first positional parameter passed to pool.query should be 90, not 200
|
||||
const callArgs = mockQuery.mock.calls[0] as [string, unknown[]];
|
||||
expect(callArgs[1][0]).toBe(90);
|
||||
});
|
||||
|
||||
it('should return mapped rows with date and count as numbers', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ date: '2026-04-01', count: '3' },
|
||||
{ date: '2026-04-02', count: '7' },
|
||||
{ date: '2026-04-03', count: '0' },
|
||||
],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getTokenTrend('tenant-1', 3);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ date: '2026-04-01', count: 3 });
|
||||
expect(result[1]).toEqual({ date: '2026-04-02', count: 7 });
|
||||
expect(result[2]).toEqual({ date: '2026-04-03', count: 0 });
|
||||
// count must be a number, not a string
|
||||
expect(typeof result[0].count).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getAgentActivity()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAgentActivity()', () => {
|
||||
it('should return rows mapped to correct IAgentActivityEntry shape', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ agent_id: 'agent-uuid-1', dow: '1', hour: '0', count: '12' },
|
||||
{ agent_id: 'agent-uuid-1', dow: '3', hour: '0', count: '5' },
|
||||
{ agent_id: 'agent-uuid-2', dow: '5', hour: '0', count: '20' },
|
||||
],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentActivity('tenant-1');
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', dow: 1, hour: 0, count: 12 });
|
||||
expect(result[1]).toEqual({ agent_id: 'agent-uuid-1', dow: 3, hour: 0, count: 5 });
|
||||
expect(result[2]).toEqual({ agent_id: 'agent-uuid-2', dow: 5, hour: 0, count: 20 });
|
||||
// Numeric types
|
||||
expect(typeof result[0].dow).toBe('number');
|
||||
expect(typeof result[0].hour).toBe('number');
|
||||
expect(typeof result[0].count).toBe('number');
|
||||
});
|
||||
|
||||
it('should return an empty array when no activity rows exist', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentActivity('tenant-empty');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getAgentUsageSummary()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getAgentUsageSummary()', () => {
|
||||
it('should return rows mapped to correct IAgentUsageSummaryEntry shape', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [
|
||||
{ agent_id: 'agent-uuid-1', name: 'team-a', token_count: '200' },
|
||||
{ agent_id: 'agent-uuid-2', name: 'team-b', token_count: '50' },
|
||||
],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentUsageSummary('tenant-1');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', name: 'team-a', token_count: 200 });
|
||||
expect(result[1]).toEqual({ agent_id: 'agent-uuid-2', name: 'team-b', token_count: 50 });
|
||||
// token_count must be a number
|
||||
expect(typeof result[0].token_count).toBe('number');
|
||||
});
|
||||
|
||||
it('should return an empty array when no agents exist', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new AnalyticsService(makePool(mockQuery));
|
||||
const result = await service.getAgentUsageSummary('tenant-empty');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
tests/unit/services/ComplianceService.test.ts
Normal file
271
tests/unit/services/ComplianceService.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Unit tests for src/services/ComplianceService.ts
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { ComplianceService } from '../../../src/services/ComplianceService';
|
||||
|
||||
// ── Mock AuditVerificationService (instantiated internally by ComplianceService) ──
|
||||
|
||||
jest.mock('../../../src/services/AuditVerificationService', () => {
|
||||
return {
|
||||
AuditVerificationService: jest.fn().mockImplementation(() => ({
|
||||
verifyChain: jest.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
checkedCount: 42,
|
||||
brokenAtEventId: null,
|
||||
}),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// ── Re-import after mock is established ──────────────────────────────────────
|
||||
import { AuditVerificationService } from '../../../src/services/AuditVerificationService';
|
||||
|
||||
const MockAuditVerificationService = AuditVerificationService as jest.MockedClass<
|
||||
typeof AuditVerificationService
|
||||
>;
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makePool(queryFn: jest.Mock): Pool {
|
||||
return { query: queryFn } as unknown as Pool;
|
||||
}
|
||||
|
||||
function makeRedis(overrides: Partial<{
|
||||
getFn: jest.Mock;
|
||||
setFn: jest.Mock;
|
||||
}>): RedisClientType {
|
||||
return {
|
||||
get: overrides.getFn ?? jest.fn().mockResolvedValue(null),
|
||||
set: overrides.setFn ?? jest.fn().mockResolvedValue('OK'),
|
||||
} as unknown as RedisClientType;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ComplianceService
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('ComplianceService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Reset AuditVerificationService mock to default passing behaviour
|
||||
MockAuditVerificationService.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
verifyChain: jest.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
checkedCount: 42,
|
||||
brokenAtEventId: null,
|
||||
}),
|
||||
}) as unknown as InstanceType<typeof AuditVerificationService>,
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// generateReport() — cache miss
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateReport() — cache miss', () => {
|
||||
it('should build a report, store it in Redis, and return IComplianceReport structure', async () => {
|
||||
// Cache miss → null
|
||||
const getFn = jest.fn().mockResolvedValue(null);
|
||||
const setFn = jest.fn().mockResolvedValue('OK');
|
||||
|
||||
// Pool returns empty agents list → agent-identity section passes trivially
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
|
||||
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
||||
const report = await service.generateReport('tenant-1');
|
||||
|
||||
// Redis cache miss check
|
||||
expect(getFn).toHaveBeenCalledWith('compliance:report:tenant-1');
|
||||
|
||||
// Report stored in Redis after build
|
||||
expect(setFn).toHaveBeenCalledWith(
|
||||
'compliance:report:tenant-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({ EX: 300 }),
|
||||
);
|
||||
|
||||
// IComplianceReport structure
|
||||
expect(report).toMatchObject({
|
||||
tenant_id: 'tenant-1',
|
||||
agntcy_schema_version: '1.0',
|
||||
sections: expect.any(Array),
|
||||
overall_status: expect.stringMatching(/^(pass|fail|warn)$/),
|
||||
generated_at: expect.any(String),
|
||||
});
|
||||
|
||||
// from_cache should be absent on a freshly built report
|
||||
expect(report.from_cache).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// generateReport() — cache hit
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateReport() — cache hit', () => {
|
||||
it('should return cached data with from_cache: true', async () => {
|
||||
const cachedReport = {
|
||||
generated_at: '2026-04-04T00:00:00.000Z',
|
||||
tenant_id: 'tenant-cache',
|
||||
agntcy_schema_version: '1.0',
|
||||
sections: [{ name: 'agent-identity', status: 'pass', details: 'All good.' }],
|
||||
overall_status: 'pass',
|
||||
};
|
||||
|
||||
const getFn = jest.fn().mockResolvedValue(JSON.stringify(cachedReport));
|
||||
const setFn = jest.fn();
|
||||
const mockQuery = jest.fn();
|
||||
|
||||
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
||||
const report = await service.generateReport('tenant-cache');
|
||||
|
||||
expect(report.from_cache).toBe(true);
|
||||
expect(report.tenant_id).toBe('tenant-cache');
|
||||
expect(report.overall_status).toBe('pass');
|
||||
// No DB queries should be made on a cache hit
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
// Redis set should not be called either
|
||||
expect(setFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// generateReport() — overall_status rollup
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateReport() — overall_status', () => {
|
||||
it('should return overall_status: pass when all sections pass', async () => {
|
||||
const getFn = jest.fn().mockResolvedValue(null);
|
||||
const setFn = jest.fn().mockResolvedValue('OK');
|
||||
|
||||
// No agents → agent-identity passes trivially
|
||||
// AuditVerificationService mock returns verified: true (default in beforeEach)
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
|
||||
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
||||
const report = await service.generateReport('tenant-all-pass');
|
||||
|
||||
expect(report.overall_status).toBe('pass');
|
||||
});
|
||||
|
||||
it('should return overall_status: fail when any section fails', async () => {
|
||||
const getFn = jest.fn().mockResolvedValue(null);
|
||||
const setFn = jest.fn().mockResolvedValue('OK');
|
||||
|
||||
// Override AuditVerificationService to simulate broken chain → audit-trail fails
|
||||
MockAuditVerificationService.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
verifyChain: jest.fn().mockResolvedValue({
|
||||
verified: false,
|
||||
checkedCount: 10,
|
||||
brokenAtEventId: 'event-uuid-broken',
|
||||
}),
|
||||
}) as unknown as InstanceType<typeof AuditVerificationService>,
|
||||
);
|
||||
|
||||
// No agents so agent-identity is 'pass'; audit-trail will be 'fail'
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
|
||||
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
|
||||
const report = await service.generateReport('tenant-fail');
|
||||
|
||||
expect(report.overall_status).toBe('fail');
|
||||
const auditSection = report.sections.find((s) => s.name === 'audit-trail');
|
||||
expect(auditSection?.status).toBe('fail');
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// exportAgentCards()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('exportAgentCards()', () => {
|
||||
it('should return an array of IAgentCard objects with correct fields', async () => {
|
||||
const createdAt = new Date('2026-01-15T12:00:00Z');
|
||||
const agentRows = [
|
||||
{
|
||||
agent_id: 'agent-uuid-1',
|
||||
owner: 'team-alpha',
|
||||
capabilities: ['agents:read', 'tokens:issue'],
|
||||
created_at: createdAt,
|
||||
did: 'did:web:sentryagent.ai:agent-uuid-1',
|
||||
},
|
||||
{
|
||||
agent_id: 'agent-uuid-2',
|
||||
owner: 'team-beta',
|
||||
capabilities: ['agents:read'],
|
||||
created_at: createdAt,
|
||||
did: null, // no DID — id should fall back to agent_id
|
||||
},
|
||||
];
|
||||
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: agentRows,
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new ComplianceService(
|
||||
makePool(mockQuery),
|
||||
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
|
||||
);
|
||||
const cards = await service.exportAgentCards('tenant-1');
|
||||
|
||||
expect(cards).toHaveLength(2);
|
||||
|
||||
// Card with DID uses DID as id
|
||||
expect(cards[0]).toEqual({
|
||||
id: 'did:web:sentryagent.ai:agent-uuid-1',
|
||||
name: 'team-alpha',
|
||||
capabilities: ['agents:read', 'tokens:issue'],
|
||||
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-1',
|
||||
created_at: createdAt.toISOString(),
|
||||
agntcy_schema_version: '1.0',
|
||||
});
|
||||
|
||||
// Card without DID falls back to agent_id as id
|
||||
expect(cards[1]).toEqual({
|
||||
id: 'agent-uuid-2',
|
||||
name: 'team-beta',
|
||||
capabilities: ['agents:read'],
|
||||
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-2',
|
||||
created_at: createdAt.toISOString(),
|
||||
agntcy_schema_version: '1.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty array when no active agents exist', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new ComplianceService(
|
||||
makePool(mockQuery),
|
||||
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
|
||||
);
|
||||
const cards = await service.exportAgentCards('tenant-empty');
|
||||
|
||||
expect(cards).toEqual([]);
|
||||
});
|
||||
|
||||
it('should query only non-decommissioned agents scoped to the tenantId', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
|
||||
const service = new ComplianceService(
|
||||
makePool(mockQuery),
|
||||
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
|
||||
);
|
||||
await service.exportAgentCards('tenant-scope-test');
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining("status != 'decommissioned'"),
|
||||
['tenant-scope-test'],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
250
tests/unit/services/TierService.test.ts
Normal file
250
tests/unit/services/TierService.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Unit tests for src/services/TierService.ts
|
||||
*/
|
||||
|
||||
import { Pool, QueryResult } from 'pg';
|
||||
import Stripe from 'stripe';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { TierService } from '../../../src/services/TierService';
|
||||
import { ValidationError, TierLimitError } from '../../../src/utils/errors';
|
||||
|
||||
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function makePool(queryFn: jest.Mock): Pool {
|
||||
return { query: queryFn } as unknown as Pool;
|
||||
}
|
||||
|
||||
function makeRedis(getFn: jest.Mock): RedisClientType {
|
||||
return { get: getFn } as unknown as RedisClientType;
|
||||
}
|
||||
|
||||
function makeStripe(overrides: Partial<{
|
||||
checkoutUrl: string;
|
||||
}> = {}): Stripe {
|
||||
const url = overrides.checkoutUrl ?? 'https://checkout.stripe.com/tier_upgrade_test';
|
||||
return {
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: jest.fn().mockResolvedValue({ url, id: 'cs_tier_1' }),
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// TierService
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('TierService', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// getStatus()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getStatus()', () => {
|
||||
it('should return correct ITierStatus shape with tier, limits, usage, and resetAt', async () => {
|
||||
// Pool: tier query, then agent count query
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ tier: 'pro' }] } as unknown as QueryResult)
|
||||
.mockResolvedValueOnce({ rows: [{ count: '7' }] } as unknown as QueryResult);
|
||||
|
||||
const mockGet = jest.fn()
|
||||
.mockResolvedValueOnce('123') // callsToday
|
||||
.mockResolvedValueOnce('456'); // tokensToday
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
||||
const status = await service.getStatus('org-uuid-1');
|
||||
|
||||
expect(status.tier).toBe('pro');
|
||||
expect(status.limits).toEqual({
|
||||
maxAgents: 100,
|
||||
maxCallsPerDay: 50_000,
|
||||
maxTokensPerDay: 50_000,
|
||||
});
|
||||
expect(status.usage).toEqual({
|
||||
callsToday: 123,
|
||||
tokensToday: 456,
|
||||
agentCount: 7,
|
||||
});
|
||||
expect(typeof status.resetAt).toBe('string');
|
||||
// resetAt must be a valid ISO 8601 timestamp
|
||||
expect(new Date(status.resetAt).toString()).not.toBe('Invalid Date');
|
||||
});
|
||||
|
||||
it('should read usage from Redis keys rate:tier:calls:<orgId> and rate:tier:tokens:<orgId>', async () => {
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ tier: 'free' }] } as unknown as QueryResult)
|
||||
.mockResolvedValueOnce({ rows: [{ count: '3' }] } as unknown as QueryResult);
|
||||
|
||||
const mockGet = jest.fn().mockResolvedValue('0');
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
||||
await service.getStatus('org-redis-test');
|
||||
|
||||
expect(mockGet).toHaveBeenCalledWith('rate:tier:calls:org-redis-test');
|
||||
expect(mockGet).toHaveBeenCalledWith('rate:tier:tokens:org-redis-test');
|
||||
});
|
||||
|
||||
it('should default to free tier when no organization row is found', async () => {
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [] } as unknown as QueryResult) // no org row
|
||||
.mockResolvedValueOnce({ rows: [{ count: '0' }] } as unknown as QueryResult);
|
||||
|
||||
const mockGet = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
||||
const status = await service.getStatus('org-unknown');
|
||||
|
||||
expect(status.tier).toBe('free');
|
||||
expect(status.limits.maxAgents).toBe(10);
|
||||
});
|
||||
|
||||
it('should return 0 for Redis counters when Redis keys are absent (null)', async () => {
|
||||
const mockQuery = jest.fn()
|
||||
.mockResolvedValueOnce({ rows: [{ tier: 'free' }] } as unknown as QueryResult)
|
||||
.mockResolvedValueOnce({ rows: [{ count: '2' }] } as unknown as QueryResult);
|
||||
|
||||
const mockGet = jest.fn().mockResolvedValue(null);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
|
||||
const status = await service.getStatus('org-no-redis');
|
||||
|
||||
expect(status.usage.callsToday).toBe(0);
|
||||
expect(status.usage.tokensToday).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// initiateUpgrade()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('initiateUpgrade()', () => {
|
||||
it('should throw ValidationError if already on target tier', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ tier: 'pro' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
|
||||
await expect(service.initiateUpgrade('org-1', 'pro')).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should throw ValidationError when downgrade is attempted (pro → free)', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ tier: 'pro' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
|
||||
await expect(service.initiateUpgrade('org-1', 'free')).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('should create a Stripe checkout session and return checkoutUrl', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ tier: 'free' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const stripe = makeStripe({ checkoutUrl: 'https://checkout.stripe.com/upgrade' });
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), stripe);
|
||||
|
||||
const result = await service.initiateUpgrade('org-free', 'pro');
|
||||
|
||||
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/upgrade');
|
||||
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'subscription',
|
||||
client_reference_id: 'org-free',
|
||||
metadata: expect.objectContaining({ orgId: 'org-free', targetTier: 'pro' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when Stripe returns no session URL', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ tier: 'free' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const stripe = {
|
||||
checkout: {
|
||||
sessions: {
|
||||
create: jest.fn().mockResolvedValue({ url: null, id: 'cs_no_url' }),
|
||||
},
|
||||
},
|
||||
} as unknown as Stripe;
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), stripe);
|
||||
|
||||
await expect(service.initiateUpgrade('org-free', 'pro')).rejects.toThrow(
|
||||
'Stripe did not return a checkout session URL.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// applyUpgrade()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('applyUpgrade()', () => {
|
||||
it('should update the organizations table tier column', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
await service.applyUpgrade('org-upgrade', 'pro');
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('UPDATE organizations'),
|
||||
['pro', 'org-upgrade'],
|
||||
);
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tier_updated_at'),
|
||||
expect.any(Array),
|
||||
);
|
||||
});
|
||||
|
||||
it('should resolve without throwing on success', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
await expect(service.applyUpgrade('org-upgrade', 'enterprise')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// enforceAgentLimit()
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('enforceAgentLimit()', () => {
|
||||
it('should throw TierLimitError when agent count is at or above the limit', async () => {
|
||||
// Agent count query returns 10 (= free tier max of 10)
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ count: '10' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
|
||||
await expect(service.enforceAgentLimit('org-limited', 'free')).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it('should not throw when agent count is below the limit', async () => {
|
||||
const mockQuery = jest.fn().mockResolvedValue({
|
||||
rows: [{ count: '5' }],
|
||||
} as unknown as QueryResult);
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
|
||||
await expect(service.enforceAgentLimit('org-ok', 'free')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should never throw for enterprise tier (Infinity limit)', async () => {
|
||||
// pool.query should NOT be called because Infinity bypasses the check
|
||||
const mockQuery = jest.fn();
|
||||
|
||||
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
|
||||
|
||||
await expect(service.enforceAgentLimit('org-enterprise', 'enterprise')).resolves.toBeUndefined();
|
||||
// No DB query needed for Infinity limit
|
||||
expect(mockQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user