diff --git a/.actor/actor.json b/.actor/actor.json index 446ff7a..c65182f 100644 --- a/.actor/actor.json +++ b/.actor/actor.json @@ -3,7 +3,7 @@ "name": "webhook-debugger-logger", "title": "Webhook Debugger, Logger & API Mocking Suite", "description": "Enterprise-grade tool to test, debug, and mock webhooks. Features real-time SSE streaming, request replay, HTTP forwarding, and JSON schema validation. Perfect for Stripe, GitHub, and Shopify integrations.", - "version": "3.0.0", + "version": "3.0.1", "output": "./output_schema.json", "input": "./input_schema.json", "webServerSchema": "./web_server_schema.json", diff --git a/.actor/input_schema.json b/.actor/input_schema.json index effeaec..7bfac2e 100644 --- a/.actor/input_schema.json +++ b/.actor/input_schema.json @@ -134,6 +134,13 @@ "example": "my-secret-key-123", "editor": "textfield" }, + "signatureVerificationSecret": { + "type": "string", + "title": "Webhook Signing Secret", + "description": "Signing secret used by the webhook provider selected in the 'Webhook Signature Verification' settings below.", + "isSecret": true, + "editor": "textfield" + }, "allowedIps": { "type": "array", "title": "IP Whitelist (CIDR)", @@ -143,22 +150,15 @@ "signatureVerification": { "type": "object", "title": "Webhook Signature Verification", - "description": "Verify incoming webhook signatures from providers like Stripe, Shopify, GitHub, or Slack.", + "description": "Verify incoming webhook signatures from providers like Stripe, Shopify, GitHub, or Slack. Choose the provider here, then enter its shared secret in the top-level 'Webhook Signing Secret' field above.", "properties": { "provider": { "type": "string", "title": "Provider", - "description": "Webhook provider for signature verification.", + "description": "Webhook provider for signature verification. Its shared secret is configured in the top-level 'Webhook Signing Secret' field above.", "enum": ["stripe", "shopify", "github", "slack", "custom"], "editor": "select" }, - "secret": { - "type": "string", - "title": "Signing Secret", - "description": "The webhook signing secret from your provider.", - "isSecret": true, - "editor": "textfield" - }, "headerName": { "type": "string", "title": "Custom Header Name", diff --git a/.actor/web_server_schema.json b/.actor/web_server_schema.json index 915279d..df66a63 100644 --- a/.actor/web_server_schema.json +++ b/.actor/web_server_schema.json @@ -1,1488 +1,1420 @@ { - "openapi": "3.0.3", - "info": { - "title": "Webhook Debugger & Logger API", - "description": "OpenAPI description for the Webhook Debugger & Logger Actor web server. Authentication is configuration-driven: when authKey is configured, management routes require either a bearer token or the key query parameter; when authKey is unset, those routes remain accessible without credentials.", - "version": "3.0.0" + "openapi": "3.0.3", + "info": { + "title": "Webhook Debugger & Logger API", + "description": "OpenAPI description for the Webhook Debugger & Logger Actor web server. Authentication is configuration-driven: when authKey is configured, management routes require either a bearer token or the key query parameter; when authKey is unset, those routes remain accessible without credentials.", + "version": "3.0.1" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Local or self-hosted instance" }, - "servers": [ - { - "url": "http://localhost:8080", - "description": "Local or self-hosted instance" - }, - { - "url": "https://{runId}.runs.apify.net", - "description": "Apify run web server", - "variables": { - "runId": { - "default": "example-run-id", - "description": "Actor run identifier" - } - } + { + "url": "https://{runId}.runs.apify.net", + "description": "Apify run web server", + "variables": { + "runId": { + "default": "example-run-id", + "description": "Actor run identifier" } - ], - "tags": [ - { - "name": "Dashboard", - "description": "Human-facing dashboard and discovery surface" - }, - { - "name": "Webhooks", - "description": "Webhook capture endpoints" - }, - { - "name": "Logs", - "description": "DuckDB-backed log retrieval endpoints" - }, - { - "name": "Replay", - "description": "Replay captured webhook traffic to a new target" - }, - { - "name": "Streaming", - "description": "Server-Sent Events stream of live webhook ingestion" - }, - { - "name": "System", - "description": "Operational and sync-service metrics" - }, - { - "name": "Health", - "description": "Liveness and readiness probes" - } - ], - "paths": { - "/": { - "get": { - "tags": [ - "Dashboard" - ], - "summary": "Get dashboard", - "description": "Returns the dashboard HTML page. If the Accept header includes text/plain, the route returns a compact plain-text summary instead.", - "operationId": "getDashboard", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Dashboard HTML or plain-text summary", - "content": { - "text/html": { - "schema": { - "type": "string" - } - }, - "text/plain": { - "schema": { - "type": "string" - }, - "example": "Webhook Debugger & Logger (v3.0.0)\nActive Webhooks: 1\nSignature Verification: STRIPE" - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - }, - "500": { - "$ref": "#/components/responses/InternalServerErrorResponse" - } + } + } + ], + "tags": [ + { + "name": "Dashboard", + "description": "Human-facing dashboard and discovery surface" + }, + { + "name": "Webhooks", + "description": "Webhook capture endpoints" + }, + { + "name": "Logs", + "description": "DuckDB-backed log retrieval endpoints" + }, + { + "name": "Replay", + "description": "Replay captured webhook traffic to a new target" + }, + { + "name": "Streaming", + "description": "Server-Sent Events stream of live webhook ingestion" + }, + { + "name": "System", + "description": "Operational and sync-service metrics" + }, + { + "name": "Health", + "description": "Liveness and readiness probes" + } + ], + "paths": { + "/": { + "get": { + "tags": ["Dashboard"], + "summary": "Get dashboard", + "description": "Returns the dashboard HTML page. If the Accept header includes text/plain, the route returns a compact plain-text summary instead.", + "operationId": "getDashboard", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Dashboard HTML or plain-text summary", + "content": { + "text/html": { + "schema": { + "type": "string" } - } - }, - "/webhook/{id}": { - "post": { - "tags": [ - "Webhooks" - ], - "summary": "Capture webhook request", - "description": "Captures incoming webhook traffic. The Express route accepts any HTTP method; this OpenAPI operation documents the common POST workflow. If authKey is configured, this route also accepts bearer token or key query authentication.", - "operationId": "captureWebhook", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/WebhookId" - }, - { - "$ref": "#/components/parameters/ForcedStatus" - } - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/xml": { - "schema": { - "type": "string" - } - }, - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } + }, + "text/plain": { + "schema": { + "type": "string" }, - "responses": { - "200": { - "description": "Captured successfully using default or custom response behavior", - "content": { - "text/plain": { - "schema": { - "type": "string" - }, - "example": "OK" - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookStatusResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/ErrorResponse" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "403": { - "$ref": "#/components/responses/ErrorResponse" - }, - "413": { - "$ref": "#/components/responses/ErrorResponse" - }, - "422": { - "$ref": "#/components/responses/ErrorResponse" - }, - "429": { - "$ref": "#/components/responses/WebhookRateLimitedResponse" - }, - "500": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, - "/info": { - "get": { - "tags": [ - "Dashboard" - ], - "summary": "Get runtime information", - "description": "Returns runtime metadata, active webhook state, discoverable endpoints, and the feature list.", - "operationId": "getRuntimeInfo", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Runtime and discovery metadata", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/InfoResponse" - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - } - } - } - }, - "/logs": { - "get": { - "tags": [ - "Logs" - ], - "summary": "List logs", - "description": "Queries captured webhook events from the DuckDB read model using offset-based or cursor-based pagination.", - "operationId": "listLogs", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/LogIdFilter" - }, - { - "$ref": "#/components/parameters/WebhookIdFilter" - }, - { - "$ref": "#/components/parameters/MethodFilter" - }, - { - "$ref": "#/components/parameters/RequestUrlFilter" - }, - { - "$ref": "#/components/parameters/StatusCodeExact" - }, - { - "$ref": "#/components/parameters/StatusCodeGte" - }, - { - "$ref": "#/components/parameters/StatusCodeLte" - }, - { - "$ref": "#/components/parameters/ContentTypeFilter" - }, - { - "$ref": "#/components/parameters/RequestIdFilter" - }, - { - "$ref": "#/components/parameters/RemoteIpFilter" - }, - { - "$ref": "#/components/parameters/UserAgentFilter" - }, - { - "$ref": "#/components/parameters/SignatureValidFilter" - }, - { - "$ref": "#/components/parameters/SignatureProviderFilter" - }, - { - "$ref": "#/components/parameters/SignatureErrorFilter" - }, - { - "$ref": "#/components/parameters/StartTimeFilter" - }, - { - "$ref": "#/components/parameters/EndTimeFilter" - }, - { - "$ref": "#/components/parameters/Limit" - }, - { - "$ref": "#/components/parameters/Offset" - }, - { - "$ref": "#/components/parameters/Cursor" - }, - { - "$ref": "#/components/parameters/Sort" - } - ], - "responses": { - "200": { - "description": "Paginated log results", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogListResponse" - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - }, - "500": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, - "/logs/{logId}": { - "get": { - "tags": [ - "Logs" - ], - "summary": "Get log detail", - "description": "Returns a single log entry. Use the optional fields parameter for sparse responses.", - "operationId": "getLogById", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/LogId" - }, - { - "$ref": "#/components/parameters/Fields" - } - ], - "responses": { - "200": { - "description": "Detailed log entry", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LogDetailResponse" - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "404": { - "$ref": "#/components/responses/ErrorResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - } - } - } - }, - "/logs/{logId}/payload": { - "get": { - "tags": [ - "Logs" - ], - "summary": "Get original payload", - "description": "Returns the original captured payload. If the payload was offloaded to Apify KVS, it is hydrated on demand.", - "operationId": "getLogPayload", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/LogId" - } - ], - "responses": { - "200": { - "description": "Original payload in its stored form", - "content": { - "application/json": { - "schema": { - "type": "object", - "additionalProperties": true - } - }, - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/octet-stream": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "404": { - "$ref": "#/components/responses/ErrorResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - } - } - } - }, - "/replay/{webhookId}/{itemId}": { - "post": { - "tags": [ - "Replay" - ], - "summary": "Replay captured request", - "description": "Replays a captured webhook event to a new destination URL after SSRF and DNS validation.", - "operationId": "replayCapturedRequest", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/WebhookId" - }, - { - "$ref": "#/components/parameters/ItemId" - }, - { - "$ref": "#/components/parameters/ReplayUrl" - } - ], - "responses": { - "200": { - "description": "Replay result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReplayResponse" - } - } - } - }, - "400": { - "$ref": "#/components/responses/ErrorResponse" - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "404": { - "$ref": "#/components/responses/ErrorResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - }, - "500": { - "$ref": "#/components/responses/ErrorResponse" - }, - "504": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, - "/log-stream": { - "get": { - "tags": [ - "Streaming" - ], - "summary": "Stream live logs", - "description": "Opens a Server-Sent Events stream for live webhook monitoring. The current implementation broadcasts plain data frames and keepalive comments rather than named event frames.", - "operationId": "streamLogs", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "SSE stream", - "content": { - "text/event-stream": { - "schema": { - "type": "string" - }, - "example": ": connected\n\ndata: {\"id\":\"evt_123\",\"webhookId\":\"wh_abc123\",\"method\":\"POST\",\"statusCode\":200}\n\n: heartbeat\n\n" - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - }, - "503": { - "$ref": "#/components/responses/ErrorResponse" - } - } - } - }, - "/system/metrics": { - "get": { - "tags": [ - "System" - ], - "summary": "Get system metrics", - "description": "Returns sync-service metrics for the Dataset-to-DuckDB replication loop.", - "operationId": "getSystemMetrics", - "security": [ - {}, - { - "bearerAuth": [] - }, - { - "queryKeyAuth": [] - } - ], - "responses": { - "200": { - "description": "Current sync metrics", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SystemMetricsResponse" - } - } - } - }, - "401": { - "$ref": "#/components/responses/UnauthorizedResponse" - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - } - } - } - }, - "/health": { - "get": { - "tags": [ - "Health" - ], - "summary": "Get liveness status", - "description": "Liveness probe for container and uptime monitoring.", - "operationId": "getHealth", - "responses": { - "200": { - "description": "Process is healthy", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - } - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - } - } - } - }, - "/ready": { - "get": { - "tags": [ - "Health" - ], - "summary": "Get readiness status", - "description": "Readiness probe for orchestrators and load balancers.", - "operationId": "getReadiness", - "responses": { - "200": { - "description": "Service is ready", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadyResponse" - } - } - } - }, - "429": { - "$ref": "#/components/responses/RateLimitedResponse" - }, - "503": { - "$ref": "#/components/responses/NotReadyResponse" - } - } + "example": "Webhook Debugger & Logger (v3.0.1)\nActive Webhooks: 1\nSignature Verification: STRIPE" + } } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + }, + "500": { + "$ref": "#/components/responses/InternalServerErrorResponse" + } } + } }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "API key", - "description": "Use the configured authKey as a bearer token when authKey protection is enabled." - }, - "queryKeyAuth": { - "type": "apiKey", - "in": "query", - "name": "key", - "description": "Use the configured authKey in the key query parameter when authKey protection is enabled." - } - }, - "parameters": { - "WebhookId": { - "name": "id", - "in": "path", - "required": true, - "description": "Active webhook identifier.", - "schema": { - "type": "string", - "example": "wh_abc123" - } - }, - "WebhookIdFilter": { - "name": "webhookId", - "in": "query", - "required": false, - "description": "Filter by exact webhook ID.", - "schema": { - "type": "string" - } - }, - "LogId": { - "name": "logId", - "in": "path", - "required": true, - "description": "Captured log identifier.", - "schema": { - "type": "string", - "example": "evt_8m2L5p9xR" - } - }, - "LogIdFilter": { - "name": "id", - "in": "query", - "required": false, - "description": "Filter by exact log ID.", - "schema": { - "type": "string" - } - }, - "ItemId": { - "name": "itemId", - "in": "path", - "required": true, - "description": "Log ID to replay. If it parses as a timestamp and no log ID is found, the server attempts a timestamp fallback lookup.", - "schema": { - "type": "string", - "example": "evt_8m2L5p9xR" - } - }, - "ReplayUrl": { - "name": "url", - "in": "query", - "required": true, - "description": "Replay destination URL. The server validates it against SSRF and DNS safety rules.", - "schema": { - "type": "string", - "format": "uri", - "example": "https://target.example/webhook" - } - }, - "ForcedStatus": { - "name": "__status", - "in": "query", - "required": false, - "description": "Override the response status code for the current webhook request.", - "schema": { - "type": "integer", - "minimum": 100, - "maximum": 599 - } - }, - "MethodFilter": { - "name": "method", - "in": "query", - "required": false, - "schema": { - "type": "string", - "example": "POST" - } - }, - "RequestUrlFilter": { - "name": "requestUrl", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - "StatusCodeExact": { - "name": "statusCode", - "in": "query", - "required": false, - "description": "Exact status code filter.", - "schema": { - "type": "integer" - } - }, - "StatusCodeGte": { - "name": "statusCode[gte]", - "in": "query", - "required": false, - "description": "Lower bound status code filter.", - "schema": { - "type": "integer" - } + "/webhook/{id}": { + "post": { + "tags": ["Webhooks"], + "summary": "Capture webhook request", + "description": "Captures incoming webhook traffic. The Express route accepts any HTTP method; this OpenAPI operation documents the common POST workflow. If authKey is configured, this route also accepts bearer token or key query authentication.", + "operationId": "captureWebhook", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WebhookId" + }, + { + "$ref": "#/components/parameters/ForcedStatus" + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true + } }, - "StatusCodeLte": { - "name": "statusCode[lte]", - "in": "query", - "required": false, - "description": "Upper bound status code filter.", - "schema": { - "type": "integer" - } + "text/plain": { + "schema": { + "type": "string" + } }, - "ContentTypeFilter": { - "name": "contentType", - "in": "query", - "required": false, - "schema": { - "type": "string" - } + "application/xml": { + "schema": { + "type": "string" + } }, - "RequestIdFilter": { - "name": "requestId", - "in": "query", - "required": false, + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "Captured successfully using default or custom response behavior", + "content": { + "text/plain": { "schema": { - "type": "string" - } - }, - "RemoteIpFilter": { - "name": "remoteIp", - "in": "query", - "required": false, - "description": "Exact IP or CIDR filter.", + "type": "string" + }, + "example": "OK" + }, + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/WebhookStatusResponse" } - }, - "UserAgentFilter": { - "name": "userAgent", - "in": "query", - "required": false, + } + } + }, + "400": { + "$ref": "#/components/responses/ErrorResponse" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "403": { + "$ref": "#/components/responses/ErrorResponse" + }, + "413": { + "$ref": "#/components/responses/ErrorResponse" + }, + "422": { + "$ref": "#/components/responses/ErrorResponse" + }, + "429": { + "$ref": "#/components/responses/WebhookRateLimitedResponse" + }, + "500": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/info": { + "get": { + "tags": ["Dashboard"], + "summary": "Get runtime information", + "description": "Returns runtime metadata, active webhook state, discoverable endpoints, and the feature list.", + "operationId": "getRuntimeInfo", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Runtime and discovery metadata", + "content": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/InfoResponse" } - }, - "SignatureValidFilter": { - "name": "signatureValid", - "in": "query", - "required": false, + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + } + } + } + }, + "/logs": { + "get": { + "tags": ["Logs"], + "summary": "List logs", + "description": "Queries captured webhook events from the DuckDB read model using offset-based or cursor-based pagination.", + "operationId": "listLogs", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/LogIdFilter" + }, + { + "$ref": "#/components/parameters/WebhookIdFilter" + }, + { + "$ref": "#/components/parameters/MethodFilter" + }, + { + "$ref": "#/components/parameters/RequestUrlFilter" + }, + { + "$ref": "#/components/parameters/StatusCodeExact" + }, + { + "$ref": "#/components/parameters/StatusCodeGte" + }, + { + "$ref": "#/components/parameters/StatusCodeLte" + }, + { + "$ref": "#/components/parameters/ContentTypeFilter" + }, + { + "$ref": "#/components/parameters/RequestIdFilter" + }, + { + "$ref": "#/components/parameters/RemoteIpFilter" + }, + { + "$ref": "#/components/parameters/UserAgentFilter" + }, + { + "$ref": "#/components/parameters/SignatureValidFilter" + }, + { + "$ref": "#/components/parameters/SignatureProviderFilter" + }, + { + "$ref": "#/components/parameters/SignatureErrorFilter" + }, + { + "$ref": "#/components/parameters/StartTimeFilter" + }, + { + "$ref": "#/components/parameters/EndTimeFilter" + }, + { + "$ref": "#/components/parameters/Limit" + }, + { + "$ref": "#/components/parameters/Offset" + }, + { + "$ref": "#/components/parameters/Cursor" + }, + { + "$ref": "#/components/parameters/Sort" + } + ], + "responses": { + "200": { + "description": "Paginated log results", + "content": { + "application/json": { "schema": { - "type": "boolean" + "$ref": "#/components/schemas/LogListResponse" } - }, - "SignatureProviderFilter": { - "name": "signatureProvider", - "in": "query", - "required": false, + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + }, + "500": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/logs/{logId}": { + "get": { + "tags": ["Logs"], + "summary": "Get log detail", + "description": "Returns a single log entry. Use the optional fields parameter for sparse responses.", + "operationId": "getLogById", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/LogId" + }, + { + "$ref": "#/components/parameters/Fields" + } + ], + "responses": { + "200": { + "description": "Detailed log entry", + "content": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/LogDetailResponse" } - }, - "SignatureErrorFilter": { - "name": "signatureError", - "in": "query", - "required": false, + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "404": { + "$ref": "#/components/responses/ErrorResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + } + } + } + }, + "/logs/{logId}/payload": { + "get": { + "tags": ["Logs"], + "summary": "Get original payload", + "description": "Returns the original captured payload. If the payload was offloaded to Apify KVS, it is hydrated on demand.", + "operationId": "getLogPayload", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/LogId" + } + ], + "responses": { + "200": { + "description": "Original payload in its stored form", + "content": { + "application/json": { "schema": { - "type": "string" + "type": "object", + "additionalProperties": true } - }, - "StartTimeFilter": { - "name": "startTime", - "in": "query", - "required": false, + }, + "text/plain": { "schema": { - "type": "string", - "format": "date-time" + "type": "string" } - }, - "EndTimeFilter": { - "name": "endTime", - "in": "query", - "required": false, + }, + "application/octet-stream": { "schema": { - "type": "string", - "format": "date-time" + "type": "string", + "format": "binary" } - }, - "Limit": { - "name": "limit", - "in": "query", - "required": false, + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "404": { + "$ref": "#/components/responses/ErrorResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + } + } + } + }, + "/replay/{webhookId}/{itemId}": { + "post": { + "tags": ["Replay"], + "summary": "Replay captured request", + "description": "Replays a captured webhook event to a new destination URL after SSRF and DNS validation.", + "operationId": "replayCapturedRequest", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WebhookId" + }, + { + "$ref": "#/components/parameters/ItemId" + }, + { + "$ref": "#/components/parameters/ReplayUrl" + } + ], + "responses": { + "200": { + "description": "Replay result", + "content": { + "application/json": { "schema": { - "type": "integer", - "minimum": 1, - "default": 10000 + "$ref": "#/components/schemas/ReplayResponse" } - }, - "Offset": { - "name": "offset", - "in": "query", - "required": false, + } + } + }, + "400": { + "$ref": "#/components/responses/ErrorResponse" + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "404": { + "$ref": "#/components/responses/ErrorResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + }, + "500": { + "$ref": "#/components/responses/ErrorResponse" + }, + "504": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/log-stream": { + "get": { + "tags": ["Streaming"], + "summary": "Stream live logs", + "description": "Opens a Server-Sent Events stream for live webhook monitoring. The current implementation broadcasts plain data frames and keepalive comments rather than named event frames.", + "operationId": "streamLogs", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "SSE stream", + "content": { + "text/event-stream": { "schema": { - "type": "integer", - "minimum": 0, - "default": 0 - } - }, - "Cursor": { - "name": "cursor", - "in": "query", - "required": false, + "type": "string" + }, + "example": ": connected\n\ndata: {\"id\":\"evt_123\",\"webhookId\":\"wh_abc123\",\"method\":\"POST\",\"statusCode\":200}\n\n: heartbeat\n\n" + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + }, + "503": { + "$ref": "#/components/responses/ErrorResponse" + } + } + } + }, + "/system/metrics": { + "get": { + "tags": ["System"], + "summary": "Get system metrics", + "description": "Returns sync-service metrics for the Dataset-to-DuckDB replication loop.", + "operationId": "getSystemMetrics", + "security": [ + {}, + { + "bearerAuth": [] + }, + { + "queryKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Current sync metrics", + "content": { + "application/json": { "schema": { - "type": "string" + "$ref": "#/components/schemas/SystemMetricsResponse" } - }, - "Sort": { - "name": "sort", - "in": "query", - "required": false, - "description": "Comma-separated sort rules such as timestamp:desc,method:asc.", + } + } + }, + "401": { + "$ref": "#/components/responses/UnauthorizedResponse" + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + } + } + } + }, + "/health": { + "get": { + "tags": ["Health"], + "summary": "Get liveness status", + "description": "Liveness probe for container and uptime monitoring.", + "operationId": "getHealth", + "responses": { + "200": { + "description": "Process is healthy", + "content": { + "application/json": { "schema": { - "type": "string", - "default": "timestamp:DESC" + "$ref": "#/components/schemas/HealthResponse" } - }, - "Fields": { - "name": "fields", - "in": "query", - "required": false, - "description": "Comma-separated sparse field projection.", + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + } + } + } + }, + "/ready": { + "get": { + "tags": ["Health"], + "summary": "Get readiness status", + "description": "Readiness probe for orchestrators and load balancers.", + "operationId": "getReadiness", + "responses": { + "200": { + "description": "Service is ready", + "content": { + "application/json": { "schema": { - "type": "string", - "example": "id,webhookId,timestamp,body" + "$ref": "#/components/schemas/ReadyResponse" } + } } + }, + "429": { + "$ref": "#/components/responses/RateLimitedResponse" + }, + "503": { + "$ref": "#/components/responses/NotReadyResponse" + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API key", + "description": "Use the configured authKey as a bearer token when authKey protection is enabled." + }, + "queryKeyAuth": { + "type": "apiKey", + "in": "query", + "name": "key", + "description": "Use the configured authKey in the key query parameter when authKey protection is enabled." + } + }, + "parameters": { + "WebhookId": { + "name": "id", + "in": "path", + "required": true, + "description": "Active webhook identifier.", + "schema": { + "type": "string", + "example": "wh_abc123" + } + }, + "WebhookIdFilter": { + "name": "webhookId", + "in": "query", + "required": false, + "description": "Filter by exact webhook ID.", + "schema": { + "type": "string" + } + }, + "LogId": { + "name": "logId", + "in": "path", + "required": true, + "description": "Captured log identifier.", + "schema": { + "type": "string", + "example": "evt_8m2L5p9xR" + } + }, + "LogIdFilter": { + "name": "id", + "in": "query", + "required": false, + "description": "Filter by exact log ID.", + "schema": { + "type": "string" + } + }, + "ItemId": { + "name": "itemId", + "in": "path", + "required": true, + "description": "Log ID to replay. If it parses as a timestamp and no log ID is found, the server attempts a timestamp fallback lookup.", + "schema": { + "type": "string", + "example": "evt_8m2L5p9xR" + } + }, + "ReplayUrl": { + "name": "url", + "in": "query", + "required": true, + "description": "Replay destination URL. The server validates it against SSRF and DNS safety rules.", + "schema": { + "type": "string", + "format": "uri", + "example": "https://target.example/webhook" + } + }, + "ForcedStatus": { + "name": "__status", + "in": "query", + "required": false, + "description": "Override the response status code for the current webhook request.", + "schema": { + "type": "integer", + "minimum": 100, + "maximum": 599 + } + }, + "MethodFilter": { + "name": "method", + "in": "query", + "required": false, + "schema": { + "type": "string", + "example": "POST" + } + }, + "RequestUrlFilter": { + "name": "requestUrl", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "StatusCodeExact": { + "name": "statusCode", + "in": "query", + "required": false, + "description": "Exact status code filter.", + "schema": { + "type": "integer" + } + }, + "StatusCodeGte": { + "name": "statusCode[gte]", + "in": "query", + "required": false, + "description": "Lower bound status code filter.", + "schema": { + "type": "integer" + } + }, + "StatusCodeLte": { + "name": "statusCode[lte]", + "in": "query", + "required": false, + "description": "Upper bound status code filter.", + "schema": { + "type": "integer" + } + }, + "ContentTypeFilter": { + "name": "contentType", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "RequestIdFilter": { + "name": "requestId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "RemoteIpFilter": { + "name": "remoteIp", + "in": "query", + "required": false, + "description": "Exact IP or CIDR filter.", + "schema": { + "type": "string" + } + }, + "UserAgentFilter": { + "name": "userAgent", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "SignatureValidFilter": { + "name": "signatureValid", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, + "SignatureProviderFilter": { + "name": "signatureProvider", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "SignatureErrorFilter": { + "name": "signatureError", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "StartTimeFilter": { + "name": "startTime", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + "EndTimeFilter": { + "name": "endTime", + "in": "query", + "required": false, + "schema": { + "type": "string", + "format": "date-time" + } + }, + "Limit": { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 10000 + } + }, + "Offset": { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + "Sort": { + "name": "sort", + "in": "query", + "required": false, + "description": "Comma-separated sort rules such as timestamp:desc,method:asc.", + "schema": { + "type": "string", + "default": "timestamp:DESC" + } + }, + "Fields": { + "name": "fields", + "in": "query", + "required": false, + "description": "Comma-separated sparse field projection.", + "schema": { + "type": "string", + "example": "id,webhookId,timestamp,body" + } + } + }, + "responses": { + "ErrorResponse": { + "description": "Generic error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "UnauthorizedResponse": { + "description": "Authentication required or invalid credentials", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + }, + "text/html": { + "schema": { + "type": "string" + } + } + } + }, + "InternalServerErrorResponse": { + "description": "Internal server error", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "RateLimitedResponse": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RateLimitedResponse" + } + } + } + }, + "WebhookRateLimitedResponse": { + "description": "Per-webhook rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WebhookRateLimitedResponse" + } + } + } + }, + "NotReadyResponse": { + "description": "Service is not ready", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadyResponse" + } + } + } + } + }, + "schemas": { + "ErrorResponse": { + "type": "object", + "properties": { + "status": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "id": { + "type": "string" + }, + "docs": { + "type": "string", + "format": "uri" + }, + "code": { + "type": "string" + }, + "retryAfterSeconds": { + "type": "integer" + } }, - "responses": { - "ErrorResponse": { - "description": "Generic error response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "UnauthorizedResponse": { - "description": "Authentication required or invalid credentials", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnauthorizedResponse" - } - }, - "text/html": { - "schema": { - "type": "string" - } - } - } - }, - "InternalServerErrorResponse": { - "description": "Internal server error", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "RateLimitedResponse": { - "description": "Rate limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RateLimitedResponse" - } - } - } - }, - "WebhookRateLimitedResponse": { - "description": "Per-webhook rate limit exceeded", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/WebhookRateLimitedResponse" - } - } + "additionalProperties": true + }, + "UnauthorizedResponse": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "example": 401 + }, + "error": { + "type": "string", + "example": "Unauthorized" + }, + "message": { + "type": "string" + }, + "id": { + "type": "string" + }, + "docs": { + "type": "string", + "format": "uri" + } + }, + "required": ["status", "error", "message"], + "additionalProperties": true + }, + "RateLimitedResponse": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "example": 429 + }, + "error": { + "type": "string", + "example": "Too Many Requests" + }, + "message": { + "type": "string" + } + }, + "required": ["status", "error", "message"] + }, + "WebhookRateLimitedResponse": { + "type": "object", + "properties": { + "status": { + "type": "integer", + "example": 429 + }, + "error": { + "type": "string", + "example": "Too Many Requests" + }, + "message": { + "type": "string" + }, + "retryAfterSeconds": { + "type": "integer", + "example": 60 + } + }, + "required": ["status", "error", "message"] + }, + "WebhookStatusResponse": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "webhookId": { + "type": "string" + } + }, + "additionalProperties": true + }, + "ActiveWebhook": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + }, + "required": ["id"], + "additionalProperties": true + }, + "InfoResponse": { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "status": { + "type": "string" + }, + "system": { + "type": "object", + "properties": { + "authActive": { + "type": "boolean" + }, + "retentionHours": { + "type": "integer" + }, + "maxPayloadLimit": { + "type": "string" + }, + "webhookCount": { + "type": "integer" + }, + "activeWebhooks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveWebhook" } + } }, - "NotReadyResponse": { - "description": "Service is not ready", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ReadyResponse" - } - } - } + "required": [ + "authActive", + "retentionHours", + "maxPayloadLimit", + "webhookCount", + "activeWebhooks" + ] + }, + "features": { + "type": "array", + "items": { + "type": "string" } + }, + "endpoints": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "docs": { + "type": "string", + "format": "uri" + } }, - "schemas": { - "ErrorResponse": { - "type": "object", - "properties": { - "status": { - "type": "integer" - }, - "error": { - "type": "string" - }, - "message": { - "type": "string" - }, - "id": { - "type": "string" - }, - "docs": { - "type": "string", - "format": "uri" - }, - "code": { - "type": "string" - }, - "retryAfterSeconds": { - "type": "integer" - } - }, - "additionalProperties": true - }, - "UnauthorizedResponse": { - "type": "object", - "properties": { - "status": { - "type": "integer", - "example": 401 - }, - "error": { - "type": "string", - "example": "Unauthorized" - }, - "message": { - "type": "string" - }, - "id": { - "type": "string" - }, - "docs": { - "type": "string", - "format": "uri" - } - }, - "required": [ - "status", - "error", - "message" - ], - "additionalProperties": true - }, - "RateLimitedResponse": { - "type": "object", - "properties": { - "status": { - "type": "integer", - "example": 429 - }, - "error": { - "type": "string", - "example": "Too Many Requests" - }, - "message": { - "type": "string" - } - }, - "required": [ - "status", - "error", - "message" - ] - }, - "WebhookRateLimitedResponse": { - "type": "object", - "properties": { - "status": { - "type": "integer", - "example": 429 - }, - "error": { - "type": "string", - "example": "Too Many Requests" - }, - "message": { - "type": "string" - }, - "retryAfterSeconds": { - "type": "integer", - "example": 60 - } - }, - "required": [ - "status", - "error", - "message" - ] - }, - "WebhookStatusResponse": { - "type": "object", - "properties": { - "message": { - "type": "string" - }, - "webhookId": { - "type": "string" - } - }, - "additionalProperties": true - }, - "ActiveWebhook": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id" - ], - "additionalProperties": true - }, - "InfoResponse": { - "type": "object", - "properties": { - "version": { - "type": "string" - }, - "status": { - "type": "string" - }, - "system": { - "type": "object", - "properties": { - "authActive": { - "type": "boolean" - }, - "retentionHours": { - "type": "integer" - }, - "maxPayloadLimit": { - "type": "string" - }, - "webhookCount": { - "type": "integer" - }, - "activeWebhooks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ActiveWebhook" - } - } - }, - "required": [ - "authActive", - "retentionHours", - "maxPayloadLimit", - "webhookCount", - "activeWebhooks" - ] - }, - "features": { - "type": "array", - "items": { - "type": "string" - } - }, - "endpoints": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "docs": { - "type": "string", - "format": "uri" - } - }, - "required": [ - "version", - "status", - "system", - "features", - "endpoints", - "docs" - ] - }, - "LogListItem": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "webhookId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" - }, - "method": { - "type": "string" - }, - "statusCode": { - "type": "integer" - }, - "size": { - "type": "integer" - }, - "processingTime": { - "type": "integer" - }, - "requestId": { - "type": "string" - }, - "requestUrl": { - "type": "string" - }, - "contentType": { - "type": "string" - }, - "signatureValid": { - "type": "boolean" - }, - "signatureProvider": { - "type": "string" - }, - "detailUrl": { - "type": "string", - "format": "uri" - } - }, - "required": [ - "id", - "detailUrl" - ], - "additionalProperties": true - }, - "LogListResponse": { - "type": "object", - "properties": { - "filters": { - "type": "object", - "additionalProperties": true - }, - "count": { - "type": "integer" - }, - "total": { - "type": "integer", - "nullable": true - }, - "items": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LogListItem" - } - }, - "nextOffset": { - "type": "integer", - "nullable": true - }, - "nextCursor": { - "type": "string", - "nullable": true - }, - "nextPageUrl": { - "type": "string", - "nullable": true - } - }, - "required": [ - "filters", - "count", - "items" - ] - }, - "LogDetailResponse": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "webhookId": { - "type": "string" - }, - "timestamp": { - "type": "string", - "format": "date-time" - }, - "method": { - "type": "string" - }, - "statusCode": { - "type": "integer" - }, - "headers": { - "type": "object", - "additionalProperties": true - }, - "query": { - "type": "object", - "additionalProperties": true - }, - "body": { - "nullable": true, - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array" - }, - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - }, - "responseHeaders": { - "type": "object", - "additionalProperties": true - }, - "responseBody": { - "nullable": true, - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "array" - }, - { - "type": "string" - }, - { - "type": "number" - }, - { - "type": "boolean" - } - ] - }, - "signatureValid": { - "type": "boolean" - }, - "signatureProvider": { - "type": "string" - }, - "signatureError": { - "type": "string" - } - }, - "required": [ - "id" - ], - "additionalProperties": true - }, - "ReplayResponse": { + "required": [ + "version", + "status", + "system", + "features", + "endpoints", + "docs" + ] + }, + "LogListItem": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "webhookId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "method": { + "type": "string" + }, + "statusCode": { + "type": "integer" + }, + "size": { + "type": "integer" + }, + "processingTime": { + "type": "integer" + }, + "requestId": { + "type": "string" + }, + "requestUrl": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "signatureValid": { + "type": "boolean" + }, + "signatureProvider": { + "type": "string" + }, + "detailUrl": { + "type": "string", + "format": "uri" + } + }, + "required": ["id", "detailUrl"], + "additionalProperties": true + }, + "LogListResponse": { + "type": "object", + "properties": { + "filters": { + "type": "object", + "additionalProperties": true + }, + "count": { + "type": "integer" + }, + "total": { + "type": "integer", + "nullable": true + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LogListItem" + } + }, + "nextOffset": { + "type": "integer", + "nullable": true + }, + "nextCursor": { + "type": "string", + "nullable": true + }, + "nextPageUrl": { + "type": "string", + "nullable": true + } + }, + "required": ["filters", "count", "items"] + }, + "LogDetailResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "webhookId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "method": { + "type": "string" + }, + "statusCode": { + "type": "integer" + }, + "headers": { + "type": "object", + "additionalProperties": true + }, + "query": { + "type": "object", + "additionalProperties": true + }, + "body": { + "nullable": true, + "oneOf": [ + { "type": "object", - "properties": { - "status": { - "type": "string", - "example": "replayed" - }, - "targetUrl": { - "type": "string", - "format": "uri" - }, - "targetResponseCode": { - "type": "integer" - }, - "targetResponseBody": {}, - "strippedHeaders": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": [ - "status", - "targetUrl", - "targetResponseCode" - ], "additionalProperties": true - }, - "SyncMetrics": { + }, + { + "type": "array" + }, + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "responseHeaders": { + "type": "object", + "additionalProperties": true + }, + "responseBody": { + "nullable": true, + "oneOf": [ + { "type": "object", - "properties": { - "syncCount": { - "type": "integer" - }, - "errorCount": { - "type": "integer" - }, - "itemsSynced": { - "type": "integer" - }, - "lastSyncTime": { - "type": "string", - "nullable": true, - "format": "date-time" - }, - "lastErrorTime": { - "type": "string", - "nullable": true, - "format": "date-time" - }, - "isRunning": { - "type": "boolean" - } - }, "additionalProperties": true + }, + { + "type": "array" + }, + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + } + ] + }, + "signatureValid": { + "type": "boolean" + }, + "signatureProvider": { + "type": "string" + }, + "signatureError": { + "type": "string" + } + }, + "required": ["id"], + "additionalProperties": true + }, + "ReplayResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "replayed" + }, + "targetUrl": { + "type": "string", + "format": "uri" + }, + "targetResponseCode": { + "type": "integer" + }, + "targetResponseBody": {}, + "strippedHeaders": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["status", "targetUrl", "targetResponseCode"], + "additionalProperties": true + }, + "SyncMetrics": { + "type": "object", + "properties": { + "syncCount": { + "type": "integer" + }, + "errorCount": { + "type": "integer" + }, + "itemsSynced": { + "type": "integer" + }, + "lastSyncTime": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "lastErrorTime": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "isRunning": { + "type": "boolean" + } + }, + "additionalProperties": true + }, + "SystemMetricsResponse": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "sync": { + "$ref": "#/components/schemas/SyncMetrics" + } + }, + "required": ["timestamp", "sync"] + }, + "HealthResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "healthy" + }, + "uptime": { + "type": "integer" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "memory": { + "type": "object", + "properties": { + "heapUsed": { + "type": "integer" + }, + "heapTotal": { + "type": "integer" + }, + "rss": { + "type": "integer" + }, + "unit": { + "type": "string", + "example": "MB" + } }, - "SystemMetricsResponse": { - "type": "object", - "properties": { - "timestamp": { - "type": "string", - "format": "date-time" - }, - "sync": { - "$ref": "#/components/schemas/SyncMetrics" - } - }, - "required": [ - "timestamp", - "sync" - ] - }, - "HealthResponse": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "healthy" - }, - "uptime": { - "type": "integer" - }, - "timestamp": { - "type": "string", - "format": "date-time" - }, - "memory": { - "type": "object", - "properties": { - "heapUsed": { - "type": "integer" - }, - "heapTotal": { - "type": "integer" - }, - "rss": { - "type": "integer" - }, - "unit": { - "type": "string", - "example": "MB" - } - }, - "required": [ - "heapUsed", - "heapTotal", - "rss", - "unit" - ] - } - }, - "required": [ - "status", - "uptime", - "timestamp", - "memory" - ] - }, - "ReadyResponse": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "ready" - }, - "timestamp": { - "type": "string", - "format": "date-time" - }, - "checks": { - "type": "object", - "additionalProperties": { - "type": "object", - "properties": { - "status": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": [ - "status" - ], - "additionalProperties": true - } - } + "required": ["heapUsed", "heapTotal", "rss", "unit"] + } + }, + "required": ["status", "uptime", "timestamp", "memory"] + }, + "ReadyResponse": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "ready" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "checks": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "status": { + "type": "string" }, - "required": [ - "status", - "timestamp", - "checks" - ] + "message": { + "type": "string" + } + }, + "required": ["status"], + "additionalProperties": true } - } + } + }, + "required": ["status", "timestamp", "checks"] + } } -} \ No newline at end of file + } +} diff --git a/.github/workflows/link-check.yml b/.github/workflows/link-check.yml index ea9faa8..e1b6cec 100644 --- a/.github/workflows/link-check.yml +++ b/.github/workflows/link-check.yml @@ -3,21 +3,6 @@ name: Link Check on: pull_request: branches: [main] - paths: - - "*.md" - - "*.txt" - - "*.js" - - "*.mjs" - - "*.cjs" - - ".github/**/*.md" - - ".claude/**/*.md" - - "docs/**/*.md" - - "docs/**/*.html" - - "public/**/*.html" - - "src/**/*.js" - - "scripts/**/*.js" - - "scripts/**/*.mjs" - - "tests/**/*.js" push: branches: [main] paths: @@ -115,6 +100,7 @@ jobs: const softHandledUrls = new Set([ 'https://www.npmjs.com/package/webhook-debugger-logger', 'https://www.npmjs.com/package/isolated-vm?activeTab=readme', + 'https://img.shields.io/coderabbit/prs/github/ar27111994/webhook-debugger-logger?utm_source=oss&utm_medium=github&utm_campaign=ar27111994%2Fwebhook-debugger-logger&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews', ]); const softFailures = []; @@ -125,7 +111,7 @@ jobs: const url = failure.url; const code = failure.status?.code; - if (softHandledUrls.has(url) && (code === 403 || code === 429)) { + if (softHandledUrls.has(url) && (code === 403 || code === 408 || code === 429)) { softFailures.push({ source, url, code, text: failure.status?.text ?? '' }); continue; } @@ -149,7 +135,7 @@ jobs: } if (softFailures.length > 0) { - console.log(`Soft-handled npmjs anti-bot failures: ${softFailures.length}`); + console.log(`Soft-handled known external link failures: ${softFailures.length}`); for (const failure of softFailures) { console.log(`::warning title=Lychee soft-fail::${failure.url} returned ${failure.code} (${failure.text}) while checking ${failure.source}`); } @@ -161,7 +147,7 @@ jobs: process.exit(1); } - console.log('Lychee completed with no failures beyond the targeted npmjs anti-bot soft-fail policy.'); + console.log('Lychee completed with no failures beyond the targeted external soft-fail policy.'); EOF - name: Save lychee cache diff --git a/.github/workflows/release-docker.yml b/.github/workflows/release-docker.yml index 7fed018..72f49d3 100644 --- a/.github/workflows/release-docker.yml +++ b/.github/workflows/release-docker.yml @@ -3,6 +3,8 @@ name: Publish Docker Image "on": release: types: [published] + pull_request: + branches: [main] permissions: contents: read @@ -10,7 +12,7 @@ permissions: packages: write concurrency: - group: release-docker-${{ github.event.release.tag_name }} + group: release-docker-${{ github.event.release.tag_name || github.event.pull_request.number || github.ref }} cancel-in-progress: false jobs: @@ -36,7 +38,7 @@ jobs: RELEASE_PRERELEASE: ${{ github.event.release.prerelease }} run: | owner="${GITHUB_REPOSITORY_OWNER,,}" - release_tag="${RELEASE_TAG}" + release_tag="${RELEASE_TAG:-pr-${{ github.event.pull_request.number || github.run_number }}}" version="${release_tag#v}" minor="$(echo "${version}" | cut -d. -f1,2)" image_name="webhook-debugger-logger" @@ -53,6 +55,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Log In to GitHub Container Registry + if: github.event_name == 'release' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io @@ -60,6 +63,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build and Publish Standalone Image + if: github.event_name == 'release' uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . @@ -72,3 +76,7 @@ jobs: cache-to: type=gha,mode=max provenance: true sbom: true + + - name: Verify Docker release workflow wiring on pull requests + if: github.event_name != 'release' + run: echo "PR validation complete; Docker publishing only runs for published releases." diff --git a/.github/workflows/release-npm.yml b/.github/workflows/release-npm.yml index 51756dc..033b9f9 100644 --- a/.github/workflows/release-npm.yml +++ b/.github/workflows/release-npm.yml @@ -3,6 +3,8 @@ name: Publish to NPM on: release: types: [published] + pull_request: + branches: [main] jobs: publish: @@ -27,13 +29,18 @@ jobs: run: npm run validate:web-server-schema - name: Verify Builds/Tests - run: npm test + run: npm run test:jest -- --detectOpenHandles --forceExit + + - name: Skip publish outside release events + if: github.event_name != 'release' + run: echo "PR validation complete; npm publish only runs for published releases." # unset NODE_AUTH_TOKEN because it's automatically set by the setup-node action # node@v24.0.0+ # https://github.com/orgs/community/discussions/176761 # https://github.com/actions/setup-node/issues/1440#issuecomment-3705123143 - name: Publish to NPM + if: github.event_name == 'release' run: | unset NODE_AUTH_TOKEN npm publish diff --git a/CHANGELOG.md b/CHANGELOG.md index 463dba5..7fbf0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to this project will be documented in this file. +## [3.0.1] - 2026-04-15 + +### Fixed (3.0.1) + +- **CI/CD**: Make the release publish workflow use an explicit Jest invocation that force-exits after the suite completes. +- **CI/CD**: Make the release-only npm and Docker workflows report successful pull request checks without publishing artifacts, so Dependabot PRs do not remain stuck waiting on required checks. +- **CI/CD**: Make the required `Link Check` workflow run on every pull request so dependency-only PRs no longer remain stuck in an expected state when path filters skip the job. +- **Apify**: Move the webhook signing secret to a top-level `signatureVerificationSecret` input with `isSecret: true`, while keeping runtime compatibility with older nested secret values. + ## [3.0.0] - 2026-04-02 ### Added (3.0.0) diff --git a/package.json b/package.json index f0ba081..c43ae40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webhook-debugger-logger", - "version": "3.0.0", + "version": "3.0.1", "type": "module", "description": "Generate temporary webhook URLs and log all incoming requests with full details.", "main": "src/main.js", diff --git a/scripts/sync-version.js b/scripts/sync-version.js index 230a63e..f66d0cd 100644 --- a/scripts/sync-version.js +++ b/scripts/sync-version.js @@ -6,9 +6,9 @@ import { readFileSync, writeFileSync } from "fs"; import { fileURLToPath } from "url"; -import { APP_CONSTS, EXIT_CODES } from "../src/consts/app.js"; +import { APP_CONSTS, APP_ROUTES, EXIT_CODES } from "../src/consts/app.js"; import { FILE_NAMES } from "../src/consts/storage.js"; -import { ENCODINGS } from "../src/consts/http.js"; +import { ENCODINGS, HTTP_STATUS, MIME_TYPES } from "../src/consts/http.js"; import { LOG_COMPONENTS } from "../src/consts/logging.js"; import { LOG_MESSAGES } from "../src/consts/messages.js"; import { ERROR_MESSAGES } from "../src/consts/errors.js"; @@ -26,6 +26,8 @@ const webServerSchemaPath = fileURLToPath( const packageJsonPath = fileURLToPath( new URL(FILE_NAMES.PACKAGE_JSON, import.meta.url), ); +const DASHBOARD_EXAMPLE_VERSION_PATTERN = + /(Webhook Debugger & Logger \(v)([^)]+)(\))/; /** * @param {string} path @@ -33,7 +35,40 @@ const packageJsonPath = fileURLToPath( * @returns {void} */ const writeJsonFile = (path, data) => { - writeFileSync(path, JSON.stringify(data, null, APP_CONSTS.JSON_INDENT) + "\n"); + writeFileSync( + path, + JSON.stringify(data, null, APP_CONSTS.JSON_INDENT) + "\n", + ); +}; + +/** + * @param {Record} webServerSchema + * @param {string} packageVersion + * @returns {boolean} + */ +const syncDashboardExampleVersion = (webServerSchema, packageVersion) => { + const dashboardExample = + webServerSchema.paths?.[APP_ROUTES.DASHBOARD]?.get?.responses?.[ + HTTP_STATUS.OK.toString() + ]?.content?.[MIME_TYPES.TEXT]?.example; + + if (typeof dashboardExample !== "string") { + return false; + } + + const updatedExample = dashboardExample.replace( + DASHBOARD_EXAMPLE_VERSION_PATTERN, + `$1${packageVersion}$3`, + ); + + if (updatedExample === dashboardExample) { + return false; + } + + webServerSchema.paths[APP_ROUTES.DASHBOARD].get.responses[ + HTTP_STATUS.OK.toString() + ].content[MIME_TYPES.TEXT].example = updatedExample; + return true; }; export const syncVersion = () => { @@ -59,11 +94,21 @@ export const syncVersion = () => { didSync = true; } + let didSyncSchema = false; + if (webServerSchema.info?.version !== packageVersion) { webServerSchema.info = { ...webServerSchema.info, version: packageVersion, }; + didSyncSchema = true; + } + + if (syncDashboardExampleVersion(webServerSchema, packageVersion)) { + didSyncSchema = true; + } + + if (didSyncSchema) { writeJsonFile(webServerSchemaPath, webServerSchema); didSync = true; } diff --git a/src/typedefs.js b/src/typedefs.js index 6110b28..e1339ab 100644 --- a/src/typedefs.js +++ b/src/typedefs.js @@ -251,6 +251,7 @@ /** * @typedef {Object} WebhookConfig * @property {string} [authKey] + * @property {string} [signatureVerificationSecret] * @property {string[]} [allowedIps] * @property {number} [defaultResponseCode] * @property {string | Object} [defaultResponseBody] diff --git a/src/utils/config.js b/src/utils/config.js index eb6198c..88b6bcd 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -23,6 +23,8 @@ import { createChildLogger } from "./logger.js"; * @returns {WebhookConfig} Normalized configuration object */ export function parseWebhookOptions(options = {}) { + const signatureVerification = normalizeSignatureVerification(options); + return { allowedIps: options.allowedIps ?? [], defaultResponseCode: @@ -40,13 +42,64 @@ export function parseWebhookOptions(options = {}) { redactBodyPaths: options.redactBodyPaths ?? [], enableJSONParsing: options.enableJSONParsing ?? APP_CONSTS.DEFAULT_ENABLE_JSON_PARSING, - signatureVerification: options.signatureVerification, + signatureVerification, alerts: options.alerts, alertOn: options.alertOn, ...coerceRuntimeOptions(options), }; } +/** + * Maps the top-level Apify secret input into the nested runtime config while + * keeping backward compatibility with older persisted inputs. + * + * @param {WebhookConfig} options + * @returns {WebhookConfig["signatureVerification"]} + */ +function normalizeSignatureVerification(options) { + const existingConfig = options.signatureVerification; + const hasTopLevelSecret = + typeof options.signatureVerificationSecret === "string"; + + if (!existingConfig && !hasTopLevelSecret) { + return existingConfig; + } + + if (hasTopLevelSecret) { + const normalizedSecret = options.signatureVerificationSecret?.trim(); + + if (normalizedSecret) { + return { + ...existingConfig, + secret: normalizedSecret, + }; + } + + if (!existingConfig) { + return existingConfig; + } + + const { secret: _ignoredSecret, ...signatureVerification } = existingConfig; + return signatureVerification; + } + + if (typeof existingConfig?.secret !== "string") { + return existingConfig; + } + + const fallbackSecret = existingConfig.secret.trim(); + + if (!fallbackSecret) { + const { secret: _ignoredSecret, ...signatureVerification } = existingConfig; + return signatureVerification; + } + + return { + ...existingConfig, + secret: fallbackSecret, + }; +} + /** * Coerces and validates runtime options for hot-reloading. * @param {Partial} input diff --git a/tests/unit/actor/input_schema.test.js b/tests/unit/actor/input_schema.test.js new file mode 100644 index 0000000..4a8a1b4 --- /dev/null +++ b/tests/unit/actor/input_schema.test.js @@ -0,0 +1,90 @@ +/** + * @file tests/unit/actor/input_schema.test.js + * @description Unit tests for Apify input schema compatibility constraints. + */ + +import { describe, expect, it } from "@jest/globals"; +import { getInputSchemaSecretFieldKeys } from "@apify/input_secrets"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const inputSchema = require("../../../.actor/input_schema.json"); + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * @param {Record} schemaNode + * @param {string} schemaPath + * @returns {string[]} + */ +function findNestedSecretPaths(schemaNode, schemaPath) { + const properties = isPlainObject(schemaNode.properties) + ? schemaNode.properties + : null; + + if (!properties) { + return []; + } + + /** @type {string[]} */ + const matches = []; + + for (const [propertyName, propertyValue] of Object.entries(properties)) { + if (!isPlainObject(propertyValue)) { + continue; + } + + const propertyPath = `${schemaPath}.properties.${propertyName}`; + + if (propertyValue.isSecret === true) { + matches.push(propertyPath); + } + + matches.push(...findNestedSecretPaths(propertyValue, propertyPath)); + } + + return matches; +} + +describe("Apify input schema", () => { + it("keeps isSecret flags only on top-level properties", () => { + const topLevelProperties = isPlainObject(inputSchema.properties) + ? inputSchema.properties + : {}; + + const topLevelSecretKeys = Object.entries(topLevelProperties) + .filter( + ([, propertyValue]) => + isPlainObject(propertyValue) && propertyValue.isSecret === true, + ) + .map(([propertyName]) => propertyName) + .sort(); + + const apifySecretKeys = getInputSchemaSecretFieldKeys(inputSchema).sort(); + + expect(apifySecretKeys).toEqual(topLevelSecretKeys); + + /** @type {string[]} */ + const nestedSecretPaths = []; + + for (const [propertyName, propertyValue] of Object.entries( + topLevelProperties, + )) { + if (!isPlainObject(propertyValue)) { + continue; + } + + nestedSecretPaths.push( + ...findNestedSecretPaths(propertyValue, `properties.${propertyName}`), + ); + } + + expect(nestedSecretPaths).toEqual([]); + }); +}); diff --git a/tests/unit/scripts/sync_version.test.js b/tests/unit/scripts/sync_version.test.js index 2b97f99..81333a8 100644 --- a/tests/unit/scripts/sync_version.test.js +++ b/tests/unit/scripts/sync_version.test.js @@ -11,6 +11,8 @@ import { systemMock, } from "../../setup/helpers/shared-mocks.js"; import { assertType } from "../../setup/helpers/test-utils.js"; +import { APP_ROUTES } from "../../../src/consts/app.js"; +import { HTTP_STATUS, MIME_TYPES } from "../../../src/consts/http.js"; /** * @typedef {import('node:fs').PathOrFileDescriptor} PathOrFileDescriptor @@ -29,19 +31,112 @@ const { syncVersion } = await import("../../../scripts/sync-version.js"); describe("Sync Version Script", () => { // Constants for test data const PACKAGE_VERSION = "1.2.3"; + const OUTDATED_VERSION = "0.0.0"; + const WEBHOOK_API_TITLE = "Webhook API"; + const NON_MATCHING_DASHBOARD_EXAMPLE = + "Dashboard summary unavailable for this environment"; + const ALREADY_SYNCED_LOG_FRAGMENT = "already in sync"; const PACKAGE_JSON = JSON.stringify({ version: PACKAGE_VERSION }); - const ACTOR_JSON_OLD = JSON.stringify({ version: "0.0.0", name: "actor" }); + const ACTOR_JSON_OLD = JSON.stringify({ + version: OUTDATED_VERSION, + name: "actor", + }); const ACTOR_JSON_MATCH = JSON.stringify({ version: PACKAGE_VERSION, name: "actor", }); const WEB_SERVER_SCHEMA_OLD = JSON.stringify({ openapi: "3.0.3", - info: { title: "Webhook API", version: "0.0.0" }, + info: { title: WEBHOOK_API_TITLE, version: OUTDATED_VERSION }, + paths: { + [APP_ROUTES.DASHBOARD]: { + get: { + responses: { + [HTTP_STATUS.OK.toString()]: { + content: { + [MIME_TYPES.TEXT]: { + example: `Webhook Debugger & Logger (v${OUTDATED_VERSION})\nActive Webhooks: 1\nSignature Verification: STRIPE`, + }, + }, + }, + }, + }, + }, + }, }); const WEB_SERVER_SCHEMA_MATCH = JSON.stringify({ openapi: "3.0.3", - info: { title: "Webhook API", version: PACKAGE_VERSION }, + info: { title: WEBHOOK_API_TITLE, version: PACKAGE_VERSION }, + paths: { + [APP_ROUTES.DASHBOARD]: { + get: { + responses: { + [HTTP_STATUS.OK]: { + content: { + [MIME_TYPES.TEXT]: { + example: `Webhook Debugger & Logger (v${PACKAGE_VERSION})\nActive Webhooks: 1\nSignature Verification: STRIPE`, + }, + }, + }, + }, + }, + }, + }, + }); + const WEB_SERVER_SCHEMA_EXAMPLE_OLD = JSON.stringify({ + openapi: "3.0.3", + info: { title: WEBHOOK_API_TITLE, version: PACKAGE_VERSION }, + paths: { + [APP_ROUTES.DASHBOARD]: { + get: { + responses: { + [HTTP_STATUS.OK]: { + content: { + [MIME_TYPES.TEXT]: { + example: `Webhook Debugger & Logger (v${OUTDATED_VERSION})\nActive Webhooks: 1\nSignature Verification: STRIPE`, + }, + }, + }, + }, + }, + }, + }, + }); + const WEB_SERVER_SCHEMA_WITHOUT_EXAMPLE = JSON.stringify({ + openapi: "3.0.3", + info: { title: WEBHOOK_API_TITLE, version: PACKAGE_VERSION }, + paths: { + [APP_ROUTES.DASHBOARD]: { + get: { + responses: { + [HTTP_STATUS.OK]: { + content: { + [MIME_TYPES.TEXT]: {}, + }, + }, + }, + }, + }, + }, + }); + const WEB_SERVER_SCHEMA_WITH_NON_MATCHING_EXAMPLE = JSON.stringify({ + openapi: "3.0.3", + info: { title: WEBHOOK_API_TITLE, version: PACKAGE_VERSION }, + paths: { + [APP_ROUTES.DASHBOARD]: { + get: { + responses: { + [HTTP_STATUS.OK]: { + content: { + [MIME_TYPES.TEXT]: { + example: NON_MATCHING_DASHBOARD_EXAMPLE, + }, + }, + }, + }, + }, + }, + }, }); const ACTOR_JSON = "actor.json"; const WEB_SERVER_SCHEMA_JSON = "web_server_schema.json"; @@ -98,6 +193,11 @@ describe("Sync Version Script", () => { const writtenSchema = JSON.parse(assertType(schemaContent)); expect(writtenSchema.info.version).toBe(PACKAGE_VERSION); + expect( + writtenSchema.paths[APP_ROUTES.DASHBOARD].get.responses[ + HTTP_STATUS.OK.toString() + ].content[MIME_TYPES.TEXT].example, + ).toContain(`(v${PACKAGE_VERSION})`); expect(loggerMock.info).toHaveBeenCalledWith( expect.stringContaining(PACKAGE_VERSION), @@ -127,7 +227,7 @@ describe("Sync Version Script", () => { expect(fsMock.readFileSync).toHaveBeenCalledTimes(READ_FILE_COUNT); expect(fsMock.writeFileSync).not.toHaveBeenCalled(); expect(loggerMock.info).toHaveBeenCalledWith( - expect.stringContaining("already in sync"), + expect.stringContaining(ALREADY_SYNCED_LOG_FRAGMENT), ); }); @@ -160,6 +260,92 @@ describe("Sync Version Script", () => { expect(writtenSchema.info.version).toBe(PACKAGE_VERSION); }); + it("should update only the web server schema example when the info version already matches", () => { + fsMock.readFileSync.mockImplementation( + assertType( + /** + * @param {PathOrFileDescriptor} path + * @returns {string} + */ + (path) => { + if (String(path).includes(PACKAGE_JSON_PATH)) return PACKAGE_JSON; + if (String(path).includes(ACTOR_JSON)) return ACTOR_JSON_MATCH; + if (String(path).includes(WEB_SERVER_SCHEMA_JSON)) { + return WEB_SERVER_SCHEMA_EXAMPLE_OLD; + } + return "{}"; + }, + ), + ); + + syncVersion(); + + expect(fsMock.writeFileSync).toHaveBeenCalledTimes(1); + + const [path, content] = fsMock.writeFileSync.mock.calls[0]; + expect(path).toContain(WEB_SERVER_SCHEMA_JSON); + + const writtenSchema = JSON.parse(assertType(content)); + expect(writtenSchema.info.version).toBe(PACKAGE_VERSION); + expect( + writtenSchema.paths[APP_ROUTES.DASHBOARD].get.responses[ + HTTP_STATUS.OK.toString() + ].content[MIME_TYPES.TEXT].example, + ).toContain(`(v${PACKAGE_VERSION})`); + }); + + it("should skip schema writes when the dashboard example is missing and versions already match", () => { + fsMock.readFileSync.mockImplementation( + assertType( + /** + * @param {PathOrFileDescriptor} path + * @returns {string} + */ + (path) => { + if (String(path).includes(PACKAGE_JSON_PATH)) return PACKAGE_JSON; + if (String(path).includes(ACTOR_JSON)) return ACTOR_JSON_MATCH; + if (String(path).includes(WEB_SERVER_SCHEMA_JSON)) { + return WEB_SERVER_SCHEMA_WITHOUT_EXAMPLE; + } + return "{}"; + }, + ), + ); + + syncVersion(); + + expect(fsMock.writeFileSync).not.toHaveBeenCalled(); + expect(loggerMock.info).toHaveBeenCalledWith( + expect.stringContaining(ALREADY_SYNCED_LOG_FRAGMENT), + ); + }); + + it("should skip schema writes when the dashboard example does not match the version pattern", () => { + fsMock.readFileSync.mockImplementation( + assertType( + /** + * @param {PathOrFileDescriptor} path + * @returns {string} + */ + (path) => { + if (String(path).includes(PACKAGE_JSON_PATH)) return PACKAGE_JSON; + if (String(path).includes(ACTOR_JSON)) return ACTOR_JSON_MATCH; + if (String(path).includes(WEB_SERVER_SCHEMA_JSON)) { + return WEB_SERVER_SCHEMA_WITH_NON_MATCHING_EXAMPLE; + } + return "{}"; + }, + ), + ); + + syncVersion(); + + expect(fsMock.writeFileSync).not.toHaveBeenCalled(); + expect(loggerMock.info).toHaveBeenCalledWith( + expect.stringContaining(ALREADY_SYNCED_LOG_FRAGMENT), + ); + }); + it("should handle errors gracefully", () => { const error = "Read failed"; fsMock.readFileSync.mockImplementation(() => { diff --git a/tests/unit/utils/config.test.js b/tests/unit/utils/config.test.js index 55c71ae..fcc1300 100644 --- a/tests/unit/utils/config.test.js +++ b/tests/unit/utils/config.test.js @@ -10,6 +10,7 @@ import { LOG_MESSAGES } from "../../../src/consts/messages.js"; import { setupCommonMocks } from "../../setup/helpers/mock-setup.js"; import { loggerMock, constsMock } from "../../setup/helpers/shared-mocks.js"; import { assertType } from "../../setup/helpers/test-utils.js"; +import { SIGNATURE_PROVIDERS } from "../../../src/consts/security.js"; /** * @typedef {import('../../../src/utils/config.js')} ConfigUtils @@ -293,6 +294,8 @@ describe("Config Utils", () => { }); describe("parseWebhookOptions", () => { + const signatureVerificationSecret = " top-level-secret "; + it("should apply defaults for complex fields", () => { const result = configUtils.parseWebhookOptions({}); expect(result.allowedIps).toEqual([]); @@ -331,5 +334,90 @@ describe("Config Utils", () => { const result = configUtils.parseWebhookOptions(assertType(input)); expect(result.urlCount).toBe(correctUrlCount); }); + + it("should map the top-level signature verification secret into runtime options", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerification: { provider: SIGNATURE_PROVIDERS.STRIPE }, + signatureVerificationSecret, + }); + + expect(result.signatureVerification).toEqual({ + provider: SIGNATURE_PROVIDERS.STRIPE, + secret: signatureVerificationSecret.trim(), + }); + }); + + it("should allow a top-level secret without a nested signature config", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerificationSecret, + }); + + expect(result.signatureVerification).toEqual({ + secret: signatureVerificationSecret.trim(), + }); + }); + + it("should ignore a blank top-level secret when no signature config exists", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerificationSecret: " ", + }); + + expect(result.signatureVerification).toBeUndefined(); + }); + + it("should preserve the nested signature secret for backward compatibility", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerification: { + provider: SIGNATURE_PROVIDERS.GITHUB, + secret: "existing-secret", + }, + }); + + expect(result.signatureVerification).toEqual({ + provider: SIGNATURE_PROVIDERS.GITHUB, + secret: "existing-secret", + }); + }); + + it("should preserve a nested signature config without a secret", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerification: { + provider: SIGNATURE_PROVIDERS.CUSTOM, + headerName: "X-Custom-Signature", + }, + }); + + expect(result.signatureVerification).toEqual({ + provider: SIGNATURE_PROVIDERS.CUSTOM, + headerName: "X-Custom-Signature", + }); + }); + + it("should clear the nested signature secret when the top-level secret is blank", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerification: { + provider: SIGNATURE_PROVIDERS.SHOPIFY, + secret: "nested-secret", + }, + signatureVerificationSecret: " ", + }); + + expect(result.signatureVerification).toEqual({ + provider: SIGNATURE_PROVIDERS.SHOPIFY, + }); + }); + + it("should trim and drop a blank nested signature secret", () => { + const result = configUtils.parseWebhookOptions({ + signatureVerification: { + provider: SIGNATURE_PROVIDERS.GITHUB, + secret: " ", + }, + }); + + expect(result.signatureVerification).toEqual({ + provider: SIGNATURE_PROVIDERS.GITHUB, + }); + }); }); });