feat(phase-3): workstream 5 — Webhooks & Event Streaming
- DB migrations 016/017: webhook_subscriptions and webhook_deliveries tables - WebhookService: CRUD for subscriptions, Vault-backed secret storage, delivery history - WebhookDeliveryWorker: Bull queue, HMAC-SHA256 signatures, exponential backoff, SSRF protection (RFC 1918 + loopback + link-local rejection), dead-letter handling - EventPublisher: publishes 10 event types (agent/credential/token lifecycle); optional Kafka adapter activated via KAFKA_BROKERS env var - AgentService, CredentialService, OAuth2Service: wired to EventPublisher - WebhookController + routes: 6 endpoints with webhooks:read / webhooks:write scope guards - KafkaAdapter: optional Kafka producer (kafkajs), no-op when KAFKA_BROKERS unset - OAuthScope extended: webhooks:read, webhooks:write - AuditAction extended: webhook.created, webhook.updated, webhook.deleted - Metrics: agentidp_webhook_dead_letters_total counter added to registry - 523 unit tests passing; TypeScript strict throughout, zero `any` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { DIDService } from './DIDService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
@@ -36,12 +37,15 @@ export class AgentService {
|
||||
* @param didService - Optional DIDService. When provided, a W3C DID is generated for each
|
||||
* newly registered agent. When null/undefined, DID generation is skipped
|
||||
* (backward-compatible default).
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, lifecycle events are
|
||||
* published as webhooks and Kafka messages (fire-and-forget).
|
||||
*/
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly didService: DIDService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -96,6 +100,13 @@ export class AgentService {
|
||||
// Instrument: count successful agent registrations
|
||||
agentsRegisteredTotal.inc({ deployment_env: data.deploymentEnv });
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
'agent.created',
|
||||
{ agentId: agent.agentId, email: agent.email, name: agent.owner },
|
||||
);
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
@@ -184,6 +195,33 @@ export class AgentService {
|
||||
{ updatedFields: Object.keys(data) },
|
||||
);
|
||||
|
||||
// Publish lifecycle event (fire-and-forget)
|
||||
if (auditAction === 'agent.updated') {
|
||||
void this.eventPublisher?.publishEvent(
|
||||
updated.organizationId,
|
||||
'agent.updated',
|
||||
{ agentId, changes: data },
|
||||
);
|
||||
} else if (auditAction === 'agent.suspended') {
|
||||
void this.eventPublisher?.publishEvent(
|
||||
updated.organizationId,
|
||||
'agent.suspended',
|
||||
{ agentId },
|
||||
);
|
||||
} else if (auditAction === 'agent.reactivated') {
|
||||
void this.eventPublisher?.publishEvent(
|
||||
updated.organizationId,
|
||||
'agent.reactivated',
|
||||
{ agentId },
|
||||
);
|
||||
} else if (auditAction === 'agent.decommissioned') {
|
||||
void this.eventPublisher?.publishEvent(
|
||||
updated.organizationId,
|
||||
'agent.decommissioned',
|
||||
{ agentId },
|
||||
);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -224,5 +262,12 @@ export class AgentService {
|
||||
userAgent,
|
||||
{},
|
||||
);
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
'agent.decommissioned',
|
||||
{ agentId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import {
|
||||
ICredential,
|
||||
ICredentialWithSecret,
|
||||
@@ -34,12 +35,15 @@ export class CredentialService {
|
||||
* @param auditService - The audit log service.
|
||||
* @param vaultClient - Optional VaultClient. When provided, new credentials are stored in Vault.
|
||||
* When null, bcrypt is used (Phase 1 behaviour).
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, credential events are
|
||||
* published as webhooks and Kafka messages (fire-and-forget).
|
||||
*/
|
||||
constructor(
|
||||
private readonly credentialRepository: CredentialRepository,
|
||||
private readonly agentRepository: AgentRepository,
|
||||
private readonly auditService: AuditService,
|
||||
private readonly vaultClient: VaultClient | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -104,6 +108,13 @@ export class CredentialService {
|
||||
{ credentialId: credential.credentialId },
|
||||
);
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
'credential.generated',
|
||||
{ credentialId: credential.credentialId, agentId },
|
||||
);
|
||||
|
||||
return { ...credential, clientSecret: plainSecret };
|
||||
}
|
||||
|
||||
@@ -205,6 +216,13 @@ export class CredentialService {
|
||||
{ credentialId },
|
||||
);
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
'credential.rotated',
|
||||
{ credentialId, agentId },
|
||||
);
|
||||
|
||||
return { ...updated, clientSecret: plainSecret };
|
||||
}
|
||||
|
||||
@@ -258,5 +276,12 @@ export class CredentialService {
|
||||
userAgent,
|
||||
{ credentialId },
|
||||
);
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
'credential.revoked',
|
||||
{ credentialId, agentId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
130
src/services/EventPublisher.ts
Normal file
130
src/services/EventPublisher.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* EventPublisher — fire-and-forget event bus for agent lifecycle and credential events.
|
||||
*
|
||||
* On each publishEvent call the service:
|
||||
* 1. Builds the IWebhookPayload envelope.
|
||||
* 2. Queries for active webhook subscriptions that match the event type.
|
||||
* 3. Creates a delivery record for each matching subscription and enqueues a Bull job.
|
||||
* 4. Optionally produces the event to the 'agentidp-events' Kafka topic (when configured).
|
||||
*
|
||||
* Kafka errors are caught and logged but never propagate — event publishing must not
|
||||
* block or fail the primary business operation.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import crypto from 'crypto';
|
||||
import { Producer as KafkaProducer } from 'kafkajs';
|
||||
import { WebhookDeliveryWorker } from '../workers/WebhookDeliveryWorker.js';
|
||||
import { IWebhookPayload, WebhookEventType } from '../types/webhook.js';
|
||||
|
||||
/** Minimal subscription row shape needed for event fanout. */
|
||||
interface ActiveSubscriptionRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget event publisher.
|
||||
* Fanout to webhooks and optionally to Kafka on each publishEvent call.
|
||||
*/
|
||||
export class EventPublisher {
|
||||
/**
|
||||
* @param webhookWorker - Worker that enqueues outbound HTTP delivery jobs.
|
||||
* @param pool - PostgreSQL connection pool (for subscription lookup and delivery inserts).
|
||||
* @param kafkaProducer - Optional connected KafkaJS producer. Null when Kafka is not configured.
|
||||
*/
|
||||
constructor(
|
||||
private readonly webhookWorker: WebhookDeliveryWorker,
|
||||
private readonly pool: Pool,
|
||||
private readonly kafkaProducer: KafkaProducer | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Publishes an event to all active matching webhook subscriptions and optionally to Kafka.
|
||||
*
|
||||
* This method is fire-and-forget. Individual delivery failures are logged but not
|
||||
* thrown. Callers should not await this method — use `void this.eventPublisher?.publishEvent(...)`.
|
||||
*
|
||||
* @param orgId - The organization UUID that owns the resource that changed.
|
||||
* @param eventType - The event type identifier.
|
||||
* @param data - Event-specific payload data.
|
||||
*/
|
||||
async publishEvent(
|
||||
orgId: string,
|
||||
eventType: WebhookEventType,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const payload: IWebhookPayload = {
|
||||
id,
|
||||
event: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
organization_id: orgId,
|
||||
data,
|
||||
};
|
||||
|
||||
// ── Webhook fanout ─────────────────────────────────────────────────────
|
||||
try {
|
||||
// Find all active subscriptions for this org that include this event type.
|
||||
// The events column is a JSONB array; we check containment with @>.
|
||||
const subResult = await this.pool.query<ActiveSubscriptionRow>(
|
||||
`SELECT id, organization_id
|
||||
FROM webhook_subscriptions
|
||||
WHERE organization_id = $1
|
||||
AND active = true
|
||||
AND events @> $2::jsonb`,
|
||||
[orgId, JSON.stringify([eventType])],
|
||||
);
|
||||
|
||||
for (const sub of subResult.rows) {
|
||||
try {
|
||||
// Insert a delivery record
|
||||
const deliveryResult = await this.pool.query<{ id: string }>(
|
||||
`INSERT INTO webhook_deliveries
|
||||
(subscription_id, event_type, payload, status, attempt_count)
|
||||
VALUES ($1, $2, $3::jsonb, 'pending', 0)
|
||||
RETURNING id`,
|
||||
[sub.id, eventType, JSON.stringify(payload)],
|
||||
);
|
||||
|
||||
const deliveryId = deliveryResult.rows[0].id;
|
||||
|
||||
// Enqueue the Bull delivery job
|
||||
await this.webhookWorker.enqueue({
|
||||
deliveryId,
|
||||
subscriptionId: sub.id,
|
||||
organizationId: orgId,
|
||||
payload,
|
||||
});
|
||||
} catch (subErr) {
|
||||
console.error(
|
||||
`[EventPublisher] Failed to enqueue delivery for subscription ${sub.id}: ${(subErr as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (webhookErr) {
|
||||
console.error(
|
||||
`[EventPublisher] Webhook fanout error for event ${eventType} (org ${orgId}): ${(webhookErr as Error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Kafka fanout ───────────────────────────────────────────────────────
|
||||
if (this.kafkaProducer !== null) {
|
||||
try {
|
||||
await this.kafkaProducer.send({
|
||||
topic: 'agentidp-events',
|
||||
messages: [
|
||||
{
|
||||
key: orgId,
|
||||
value: JSON.stringify(payload),
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (kafkaErr) {
|
||||
console.error(
|
||||
`[EventPublisher] Kafka produce error for event ${eventType} (org ${orgId}): ${(kafkaErr as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { AgentRepository } from '../repositories/AgentRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { IDTokenService } from './IDTokenService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import {
|
||||
ITokenPayload,
|
||||
ITokenResponse,
|
||||
@@ -49,6 +50,8 @@ export class OAuth2Service {
|
||||
* @param vaultClient - Optional VaultClient for Phase 2 credential verification.
|
||||
* @param idTokenService - Optional IDTokenService; when provided and `openid` scope
|
||||
* is requested, an OIDC ID token is appended to the token response.
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, token.issued and
|
||||
* token.revoked events are published as webhooks and Kafka messages (fire-and-forget).
|
||||
*/
|
||||
constructor(
|
||||
private readonly tokenRepository: TokenRepository,
|
||||
@@ -59,6 +62,7 @@ export class OAuth2Service {
|
||||
private readonly publicKey: string,
|
||||
private readonly vaultClient: VaultClient | null = null,
|
||||
private readonly idTokenService: IDTokenService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -211,6 +215,13 @@ export class OAuth2Service {
|
||||
// Instrument: count successful token issuances
|
||||
tokensIssuedTotal.inc({ scope });
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'token.issued',
|
||||
{ agentId: clientId, scope, jti },
|
||||
);
|
||||
|
||||
const tokenResponse: ITokenResponse = {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
@@ -323,6 +334,13 @@ export class OAuth2Service {
|
||||
userAgent,
|
||||
{ jti: decoded.jti },
|
||||
);
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
callerPayload.organization_id ?? 'org_system',
|
||||
'token.revoked',
|
||||
{ jti: decoded.jti },
|
||||
);
|
||||
}
|
||||
// If token is malformed/undecoded, per RFC 7009 we still return success
|
||||
}
|
||||
|
||||
475
src/services/WebhookService.ts
Normal file
475
src/services/WebhookService.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* WebhookService — manages webhook subscriptions and delivery history.
|
||||
*
|
||||
* HMAC signing secrets are stored in Vault when available (vault_secret_path holds the KV path).
|
||||
* In local mode (no Vault) the secret is bcrypt-hashed and stored in secret_hash, and
|
||||
* vault_secret_path is set to the sentinel value 'local'. The raw secret is never persisted
|
||||
* in PostgreSQL and is only returned once at subscription creation time.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import { RedisClientType } from 'redis';
|
||||
import crypto from 'crypto';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { SentryAgentError } from '../utils/errors.js';
|
||||
import {
|
||||
IWebhookSubscription,
|
||||
ICreateWebhookRequest,
|
||||
IUpdateWebhookRequest,
|
||||
IWebhookDelivery,
|
||||
IPaginatedDeliveriesResponse,
|
||||
WebhookEventType,
|
||||
} from '../types/webhook.js';
|
||||
|
||||
// ============================================================================
|
||||
// Custom errors
|
||||
// ============================================================================
|
||||
|
||||
/** 404 — Referenced webhook subscription was not found (or belongs to another org). */
|
||||
export class WebhookNotFoundError extends SentryAgentError {
|
||||
constructor(subscriptionId?: string) {
|
||||
super(
|
||||
'Webhook subscription with the specified ID was not found.',
|
||||
'WEBHOOK_NOT_FOUND',
|
||||
404,
|
||||
subscriptionId ? { subscriptionId } : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 400 — Webhook subscription request failed validation. */
|
||||
export class WebhookValidationError extends SentryAgentError {
|
||||
constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(message, 'WEBHOOK_VALIDATION_ERROR', 400, details);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal DB row shape
|
||||
// ============================================================================
|
||||
|
||||
/** Internal representation of a webhook_subscriptions row. */
|
||||
interface WebhookSubscriptionRow {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
events: WebhookEventType[];
|
||||
secret_hash: string;
|
||||
vault_secret_path: string;
|
||||
active: boolean;
|
||||
failure_count: number;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
/** Internal representation of a webhook_deliveries row. */
|
||||
interface WebhookDeliveryRow {
|
||||
id: string;
|
||||
subscription_id: string;
|
||||
event_type: string;
|
||||
payload: Record<string, unknown>;
|
||||
status: 'pending' | 'delivered' | 'failed' | 'dead_letter';
|
||||
http_status_code: number | null;
|
||||
attempt_count: number;
|
||||
next_retry_at: Date | null;
|
||||
delivered_at: Date | null;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mappers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Maps a raw DB row to a public IWebhookSubscription (never includes secret fields).
|
||||
*/
|
||||
function mapRowToSubscription(row: WebhookSubscriptionRow): IWebhookSubscription {
|
||||
return {
|
||||
id: row.id,
|
||||
organization_id: row.organization_id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
events: row.events,
|
||||
active: row.active,
|
||||
failure_count: row.failure_count,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw DB row to a public IWebhookDelivery.
|
||||
*/
|
||||
function mapRowToDelivery(row: WebhookDeliveryRow): IWebhookDelivery {
|
||||
return {
|
||||
id: row.id,
|
||||
subscription_id: row.subscription_id,
|
||||
event_type: row.event_type as WebhookEventType,
|
||||
payload: row.payload,
|
||||
status: row.status,
|
||||
http_status_code: row.http_status_code,
|
||||
attempt_count: row.attempt_count,
|
||||
next_retry_at: row.next_retry_at,
|
||||
delivered_at: row.delivered_at,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Service for managing webhook subscriptions and delivery audit records.
|
||||
* Coordinates PostgreSQL persistence and optional Vault secret storage.
|
||||
*/
|
||||
export class WebhookService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param vaultClient - Optional VaultClient. When provided, HMAC secrets are stored in Vault.
|
||||
* @param redis - Redis client (reserved for future caching needs).
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly vaultClient: VaultClient | null,
|
||||
_redis: RedisClientType, // reserved for future subscription caching
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Subscriptions
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a new webhook subscription for the given organization.
|
||||
*
|
||||
* A 32-byte random HMAC signing secret is generated. When Vault is configured the
|
||||
* secret is stored in Vault KV v2 at `secret/data/agentidp/webhooks/{orgId}/{id}/secret`
|
||||
* and `vault_secret_path` is set to that path. In local mode the secret is bcrypt-hashed
|
||||
* and stored in `secret_hash`; the raw secret is returned from this method and must be
|
||||
* delivered to the caller immediately — it cannot be retrieved again.
|
||||
*
|
||||
* @param orgId - The organization UUID that owns this subscription.
|
||||
* @param req - The creation request (name, url, events).
|
||||
* @returns The created IWebhookSubscription plus the one-time `secret` field on the raw object.
|
||||
* @throws WebhookValidationError if the URL is not https:// or events array is empty.
|
||||
*/
|
||||
async createSubscription(
|
||||
orgId: string,
|
||||
req: ICreateWebhookRequest,
|
||||
): Promise<IWebhookSubscription & { secret: string }> {
|
||||
this.validateUrl(req.url);
|
||||
this.validateEvents(req.events);
|
||||
|
||||
const secret = crypto.randomBytes(32).toString('hex');
|
||||
const subscriptionId = crypto.randomUUID();
|
||||
|
||||
let secretHash: string;
|
||||
let vaultSecretPath: string;
|
||||
|
||||
if (this.vaultClient !== null) {
|
||||
// Store raw secret in Vault under a webhook-specific path
|
||||
const vaultPath = `secret/data/agentidp/webhooks/${orgId}/${subscriptionId}/secret`;
|
||||
await this.storeWebhookSecretInVault(vaultPath, secret);
|
||||
secretHash = 'vault';
|
||||
vaultSecretPath = vaultPath;
|
||||
} else {
|
||||
// Local mode: bcrypt-hash the secret; raw secret cannot be recovered later
|
||||
secretHash = await bcrypt.hash(secret, 10);
|
||||
vaultSecretPath = 'local';
|
||||
}
|
||||
|
||||
const result = await this.pool.query<WebhookSubscriptionRow>(
|
||||
`INSERT INTO webhook_subscriptions
|
||||
(id, organization_id, name, url, events, secret_hash, vault_secret_path, active, failure_count)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, true, 0)
|
||||
RETURNING *`,
|
||||
[
|
||||
subscriptionId,
|
||||
orgId,
|
||||
req.name,
|
||||
req.url,
|
||||
JSON.stringify(req.events),
|
||||
secretHash,
|
||||
vaultSecretPath,
|
||||
],
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
return { ...mapRowToSubscription(row), secret };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the raw HMAC signing secret for a subscription (Vault mode only).
|
||||
*
|
||||
* In local mode the secret was only available at creation time and cannot be recovered.
|
||||
*
|
||||
* @param subscriptionId - The subscription UUID.
|
||||
* @param orgId - The organization UUID (ownership verification).
|
||||
* @returns The raw HMAC signing secret.
|
||||
* @throws WebhookNotFoundError if the subscription does not exist or belongs to another org.
|
||||
* @throws WebhookValidationError if the subscription is in local mode (secret not recoverable).
|
||||
*/
|
||||
async getSubscriptionSecret(subscriptionId: string, orgId: string): Promise<string> {
|
||||
const row = await this.fetchRow(subscriptionId, orgId);
|
||||
|
||||
if (row.vault_secret_path === 'local') {
|
||||
throw new WebhookValidationError(
|
||||
'Secret not available — use webhook secret returned at creation time.',
|
||||
{ subscriptionId },
|
||||
);
|
||||
}
|
||||
|
||||
return this.retrieveWebhookSecretFromVault(row.vault_secret_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all webhook subscriptions for the given organization.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns Array of IWebhookSubscription (no secret fields).
|
||||
*/
|
||||
async listSubscriptions(orgId: string): Promise<IWebhookSubscription[]> {
|
||||
const result = await this.pool.query<WebhookSubscriptionRow>(
|
||||
`SELECT * FROM webhook_subscriptions
|
||||
WHERE organization_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[orgId],
|
||||
);
|
||||
return result.rows.map(mapRowToSubscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a single webhook subscription by ID.
|
||||
*
|
||||
* @param id - The subscription UUID.
|
||||
* @param orgId - The organization UUID (ownership verification).
|
||||
* @returns The IWebhookSubscription.
|
||||
* @throws WebhookNotFoundError if not found or belongs to another org.
|
||||
*/
|
||||
async getSubscription(id: string, orgId: string): Promise<IWebhookSubscription> {
|
||||
const row = await this.fetchRow(id, orgId);
|
||||
return mapRowToSubscription(row);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partially updates a webhook subscription.
|
||||
*
|
||||
* Only the fields provided in `req` are changed. If a new URL is provided it must
|
||||
* use the `https://` scheme.
|
||||
*
|
||||
* @param id - The subscription UUID.
|
||||
* @param orgId - The organization UUID (ownership verification).
|
||||
* @param req - Fields to update.
|
||||
* @returns The updated IWebhookSubscription.
|
||||
* @throws WebhookNotFoundError if not found or belongs to another org.
|
||||
* @throws WebhookValidationError if the new URL is not https://.
|
||||
*/
|
||||
async updateSubscription(
|
||||
id: string,
|
||||
orgId: string,
|
||||
req: IUpdateWebhookRequest,
|
||||
): Promise<IWebhookSubscription> {
|
||||
// Verify ownership before mutating
|
||||
await this.fetchRow(id, orgId);
|
||||
|
||||
if (req.url !== undefined) {
|
||||
this.validateUrl(req.url);
|
||||
}
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (req.name !== undefined) {
|
||||
setClauses.push(`name = $${paramIndex++}`);
|
||||
values.push(req.name);
|
||||
}
|
||||
if (req.url !== undefined) {
|
||||
setClauses.push(`url = $${paramIndex++}`);
|
||||
values.push(req.url);
|
||||
}
|
||||
if (req.events !== undefined) {
|
||||
this.validateEvents(req.events);
|
||||
setClauses.push(`events = $${paramIndex++}::jsonb`);
|
||||
values.push(JSON.stringify(req.events));
|
||||
}
|
||||
if (req.active !== undefined) {
|
||||
setClauses.push(`active = $${paramIndex++}`);
|
||||
values.push(req.active);
|
||||
}
|
||||
|
||||
if (setClauses.length === 0) {
|
||||
// Nothing to update — re-fetch and return current state
|
||||
return this.getSubscription(id, orgId);
|
||||
}
|
||||
|
||||
setClauses.push(`updated_at = NOW()`);
|
||||
values.push(id);
|
||||
values.push(orgId);
|
||||
|
||||
const result = await this.pool.query<WebhookSubscriptionRow>(
|
||||
`UPDATE webhook_subscriptions
|
||||
SET ${setClauses.join(', ')}
|
||||
WHERE id = $${paramIndex++} AND organization_id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values,
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new WebhookNotFoundError(id);
|
||||
}
|
||||
|
||||
return mapRowToSubscription(result.rows[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently deletes a webhook subscription (and all its deliveries via CASCADE).
|
||||
*
|
||||
* @param id - The subscription UUID.
|
||||
* @param orgId - The organization UUID (ownership verification).
|
||||
* @throws WebhookNotFoundError if not found or belongs to another org.
|
||||
*/
|
||||
async deleteSubscription(id: string, orgId: string): Promise<void> {
|
||||
const result = await this.pool.query(
|
||||
`DELETE FROM webhook_subscriptions
|
||||
WHERE id = $1 AND organization_id = $2`,
|
||||
[id, orgId],
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
throw new WebhookNotFoundError(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Deliveries
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns a paginated list of delivery records for a subscription.
|
||||
*
|
||||
* Verifies that the subscription belongs to the given organization before querying.
|
||||
*
|
||||
* @param subscriptionId - The subscription UUID.
|
||||
* @param orgId - The organization UUID (ownership verification).
|
||||
* @param limit - Page size (default 20).
|
||||
* @param offset - Row offset (default 0).
|
||||
* @returns Paginated delivery records.
|
||||
* @throws WebhookNotFoundError if the subscription does not exist or belongs to another org.
|
||||
*/
|
||||
async listDeliveries(
|
||||
subscriptionId: string,
|
||||
orgId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<IPaginatedDeliveriesResponse> {
|
||||
// Verify ownership
|
||||
await this.fetchRow(subscriptionId, orgId);
|
||||
|
||||
const countResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM webhook_deliveries WHERE subscription_id = $1`,
|
||||
[subscriptionId],
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count, 10);
|
||||
|
||||
const result = await this.pool.query<WebhookDeliveryRow>(
|
||||
`SELECT * FROM webhook_deliveries
|
||||
WHERE subscription_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[subscriptionId, limit, offset],
|
||||
);
|
||||
|
||||
return {
|
||||
deliveries: result.rows.map(mapRowToDelivery),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Private helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches the full subscription row including secret fields (for internal use only).
|
||||
*
|
||||
* @param id - The subscription UUID.
|
||||
* @param orgId - The organization UUID.
|
||||
* @throws WebhookNotFoundError if not found or org mismatch.
|
||||
*/
|
||||
private async fetchRow(id: string, orgId: string): Promise<WebhookSubscriptionRow> {
|
||||
const result = await this.pool.query<WebhookSubscriptionRow>(
|
||||
`SELECT * FROM webhook_subscriptions WHERE id = $1 AND organization_id = $2`,
|
||||
[id, orgId],
|
||||
);
|
||||
if (result.rowCount === 0) {
|
||||
throw new WebhookNotFoundError(id);
|
||||
}
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a URL uses the https:// scheme.
|
||||
*
|
||||
* @param url - The URL to validate.
|
||||
* @throws WebhookValidationError if the scheme is not https.
|
||||
*/
|
||||
private validateUrl(url: string): void {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new WebhookValidationError(
|
||||
'Webhook URL must use the https:// scheme.',
|
||||
{ url },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof WebhookValidationError) throw err;
|
||||
throw new WebhookValidationError('Webhook URL is not a valid URL.', { url });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the events array is non-empty.
|
||||
*
|
||||
* @param events - The events array to validate.
|
||||
* @throws WebhookValidationError if the array is empty.
|
||||
*/
|
||||
private validateEvents(events: WebhookEventType[]): void {
|
||||
if (events.length === 0) {
|
||||
throw new WebhookValidationError('Webhook subscription must include at least one event type.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a webhook HMAC secret in Vault at the given KV v2 data path.
|
||||
*
|
||||
* @param vaultPath - Full KV v2 data path (e.g. `secret/data/agentidp/webhooks/...`).
|
||||
* @param secret - The raw HMAC secret to store.
|
||||
*/
|
||||
private async storeWebhookSecretInVault(vaultPath: string, secret: string): Promise<void> {
|
||||
await this.vaultClient!.writeArbitrarySecret(vaultPath, { webhookSecret: secret });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a webhook HMAC secret from Vault at the given KV v2 data path.
|
||||
*
|
||||
* @param vaultPath - Full KV v2 data path.
|
||||
* @returns The raw HMAC secret string.
|
||||
* @throws WebhookValidationError if the secret is missing or empty.
|
||||
*/
|
||||
private async retrieveWebhookSecretFromVault(vaultPath: string): Promise<string> {
|
||||
const data = await this.vaultClient!.readArbitrarySecret(vaultPath);
|
||||
const secret = data['webhookSecret'];
|
||||
if (typeof secret !== 'string' || secret.length === 0) {
|
||||
throw new WebhookValidationError('Vault returned an empty or missing webhook secret.', { vaultPath });
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user