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:
SentryAgent.ai Developer
2026-03-31 00:07:41 +00:00
parent 03b5de300c
commit 272b69f18d
20 changed files with 1994 additions and 25 deletions

View File

@@ -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 },
);
}
}

View File

@@ -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 },
);
}
}

View 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}`,
);
}
}
}
}

View File

@@ -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
}

View 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;
}
}