feat(server): add explicit GitHub webhook strategies

Move GitHub webhook intake onto the main API router, add explicit
server_url and tailscale_funnel strategies, and validate strategy
requirements at config resolution. This also updates the API contract,
generated client, and operator docs to match the new webhook model.
This commit is contained in:
Bryan Helmkamp 2026-04-19 22:53:22 -04:00
parent b8d93603f8
commit c8cc9f20b0
No known key found for this signature in database
15 changed files with 746 additions and 393 deletions

View file

@ -211,7 +211,7 @@ Customize the git author identity used for checkpoint commits. When not set, def
### `[server.integrations.github]` section
Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow, browser OAuth, and webhooks.
Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow and browser OAuth; webhook delivery is configured separately under `[server.integrations.github.webhooks]`.
```toml title="settings.toml"
[server.integrations.github]
@ -227,11 +227,20 @@ app_id = "123456"
client_id = "Iv1.abc123"
slug = "fabro-app"
[server.api]
url = "https://fabro-api.example.com"
[server.integrations.github.webhooks]
strategy = "tailscale_funnel"
strategy = "server_url"
```
When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable.
Fabro always serves the GitHub webhook handler at `POST /api/v1/webhooks/github` when `GITHUB_APP_WEBHOOK_SECRET` is configured. The `strategy` field controls how Fabro exposes that route and whether it mutates the GitHub App webhook URL on startup:
- `strategy = "server_url"`: recommended for production or any deployment with a stable public API URL. Fabro sets the GitHub App webhook URL to `<server.api.url>/api/v1/webhooks/github` on startup. Requires `server.api.url` and `GITHUB_APP_WEBHOOK_SECRET`.
- `strategy = "tailscale_funnel"`: opt-in for Tailscale-hosted machines without a stable public URL. Fabro runs `tailscale funnel <server-port>`, exposes the main server on that Funnel URL, and best-effort updates the GitHub App webhook URL to `<funnel-url>/api/v1/webhooks/github`. Requires a TCP listener and `GITHUB_APP_WEBHOOK_SECRET`.
- `strategy` unset: Fabro still accepts signed webhook deliveries on `/api/v1/webhooks/github` when the secret is present, but it does not run `tailscale funnel` and does not update the GitHub App webhook URL.
Incoming webhooks are authenticated only by GitHub's `X-Hub-Signature-256` HMAC signature, not by Fabro's bearer/session auth.
### `[run.checkpoint]` section

View file

@ -9,6 +9,8 @@ tags:
description: API discovery and health
- name: Install
description: First-run browser install workflow
- name: Integrations
description: External provider callbacks and integration endpoints
- name: Runs
description: Run management operations
- name: Human-in-the-Loop
@ -374,6 +376,26 @@ paths:
schema:
type: object
/api/v1/webhooks/github:
post:
operationId: receiveGithubWebhook
tags: [Integrations]
summary: Receive GitHub Webhook
description: Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
security: []
requestBody:
required: true
content:
application/json:
schema:
type: string
format: binary
responses:
"200":
description: Webhook accepted
"401":
description: Missing or invalid webhook signature
/api/v1/user:
get:
operationId: getUser

View file

@ -19,6 +19,14 @@ The published Docker image switched to [Docker Hardened Images](https://www.dock
A new `docker-compose.prod.yaml` stands up a Caddy 2 sidecar that handles auto-HTTPS on ports 80/443 and proxies to the Fabro service. Set `FABRO_DOMAIN` to your domain and Caddy provisions and renews the certificate; certs persist in a named volume. The base `docker-compose.yaml` has moved to the repo root.
## GitHub webhook exposure is now explicit
GitHub App webhook exposure no longer auto-enables just because `[server.integrations.github.webhooks]` exists. The webhook handler now lives on the main API router at `POST /api/v1/webhooks/github`, and operators must choose an explicit strategy if they want Fabro to mutate any external state on startup.
- Breaking: if you previously relied on Fabro silently starting `tailscale funnel` and rewriting the GitHub App webhook URL, add `strategy = "tailscale_funnel"` under `[server.integrations.github.webhooks]` to restore that behavior.
- New: `strategy = "server_url"` tells Fabro to use `server.api.url` and set the GitHub App webhook URL to `<server.api.url>/api/v1/webhooks/github` on startup. This is the recommended choice for stable production deployments behind HTTPS.
- Unset `strategy`: Fabro still verifies and serves signed GitHub webhooks when `GITHUB_APP_WEBHOOK_SECRET` is present, but it will not run `tailscale funnel` and it will not rewrite the GitHub App webhook URL.
## More
<Accordion title="Fixes">

View file

@ -6,7 +6,7 @@ description: "Integrate Fabro with GitHub for repository access and OAuth login"
Fabro supports two GitHub integration strategies:
- `token` — the default for local and individual use. Fabro captures `gh auth token` during `fabro install`, stores it as `GITHUB_TOKEN`, and uses that token directly for repo access, pull requests, and sandbox `GITHUB_TOKEN` injection.
- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, and enables browser OAuth and webhooks.
- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, enables browser OAuth, and supports webhook delivery when you configure a [webhook strategy](#webhook-delivery-strategies).
`token` changes GitHub integration auth only. It does not provide browser sign-in, so the embedded web UI is disabled when `strategy = "token"`.
@ -19,11 +19,11 @@ Fabro supports two GitHub integration strategies:
| Sandbox `GITHUB_TOKEN` | Direct token | Scoped installation token |
| Browser sign-in | No | Yes |
| Web UI routes | Disabled | Enabled |
| Webhooks | No | Yes |
| Webhooks | No | Strategy-dependent |
## GitHub App mode
The rest of this page describes the `app` strategy, which is required for browser auth and webhooks.
The rest of this page describes the `app` strategy, which is required for browser auth and for any webhook delivery strategy.
| Feature | How it's used |
|---|---|
@ -125,6 +125,30 @@ Fabro stores the GitHub App secrets in `<data_dir>/server.env` under these keys:
The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`).
### Webhook delivery strategies
Fabro receives GitHub webhooks on `POST /api/v1/webhooks/github` whenever `GITHUB_APP_WEBHOOK_SECRET` is configured. The webhook `strategy` controls how that route becomes reachable from GitHub:
```toml title="settings.toml"
[server.integrations.github]
strategy = "app"
app_id = "123456"
client_id = "Iv1.abc123def"
slug = "fabro-a3f2"
[server.api]
url = "https://fabro-api.example.com"
[server.integrations.github.webhooks]
strategy = "server_url"
```
- `server_url`: recommended for production. Fabro assumes `server.api.url` is already publicly reachable and best-effort updates the GitHub App webhook URL to `<server.api.url>/api/v1/webhooks/github` each time `fabro server start` runs.
- `tailscale_funnel`: opt-in for machines reachable through Tailscale but not through a stable public URL. Fabro runs `tailscale funnel` against the main server port and best-effort updates the GitHub App webhook URL to the resulting Funnel origin.
- Unset `strategy`: Fabro still serves the webhook route if the secret is present, but it does not expose the route for you and does not mutate the GitHub App webhook URL.
`tailscale_funnel` has host-wide side effects: it changes Tailscale Funnel state on the server and rewrites the GitHub App webhook URL on startup. Use `server_url` when you already have HTTPS and a stable hostname.
### Reconfigure GitHub after install
To switch GitHub strategies or re-register the app without re-running the full install wizard, use `fabro install github`:

View file

@ -1,15 +1,16 @@
use fabro_types::settings::InterpString;
use fabro_types::settings::server::{
DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksLayer,
IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, ObjectStoreProvider,
ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, ServerApiSettings,
ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthLayer,
ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, ServerIntegrationsSettings,
ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings,
ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, ServerListenSettings,
ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, ServerSlateDbLayer,
ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, ServerWebLayer,
ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy,
IntegrationWebhooksLayer, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer,
ObjectStoreProvider, ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer,
ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings,
ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer,
ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer,
ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer,
ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings,
ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings,
ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings,
WebhookStrategy,
};
use fabro_util::Home;
@ -25,6 +26,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec<ResolveError>) -> Se
let integrations = resolve_integrations(layer.integrations.as_ref(), errors);
validate_ip_allowlist_for_listen(&listen, &ip_allowlist, errors);
validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors);
validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors);
ServerSettings {
listen,
@ -294,6 +296,40 @@ fn validate_github_webhook_ip_allowlist_for_listen(
}
}
fn validate_github_webhook_strategy(
integrations: &ServerIntegrationsSettings,
api_layer: Option<&ServerApiLayer>,
errors: &mut Vec<ResolveError>,
) {
let github = &integrations.github;
let strategy = github
.webhooks
.as_ref()
.and_then(|webhooks| webhooks.strategy);
if strategy.is_some()
&& github.strategy == GithubIntegrationStrategy::App
&& github.app_id.is_none()
{
errors.push(ResolveError::Invalid {
path: "server.integrations.github.app_id".to_string(),
reason: "must be set when server.integrations.github.webhooks.strategy is configured"
.to_string(),
});
}
if matches!(strategy, Some(WebhookStrategy::ServerUrl))
&& api_layer.and_then(|api| api.url.as_ref()).is_none()
{
errors.push(ResolveError::Invalid {
path: "server.api.url".to_string(),
reason:
"must be set when server.integrations.github.webhooks.strategy = \"server_url\""
.to_string(),
});
}
}
fn resolve_artifacts(
layer: Option<&ServerArtifactsLayer>,
storage_root: &InterpString,

View file

@ -298,6 +298,56 @@ trusted_proxy_count = 3
assert_eq!(webhook_allowlist.trusted_proxy_count, Some(3));
}
#[test]
fn rejects_server_url_webhook_strategy_without_server_api_url() {
let file = parse(
r#"
_version = 1
[server.integrations.github]
strategy = "app"
[server.integrations.github.webhooks]
strategy = "server_url"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("server_url webhook strategy should require server.api.url");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("server.api.url"));
}
#[test]
fn rejects_configured_webhook_strategy_without_github_app_id() {
let file = parse(
r#"
_version = 1
[server.integrations.github]
strategy = "app"
[server.integrations.github.webhooks]
strategy = "tailscale_funnel"
"#,
);
let errors = fabro_config::resolve_server_from_file(&file)
.expect_err("configured webhook strategy should require server.integrations.github.app_id");
let rendered = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("\n");
assert!(rendered.contains("server.integrations.github.app_id"));
}
#[test]
fn rejects_invalid_ip_allowlist_entry() {
let file = parse(

View file

@ -1,19 +1,7 @@
use std::net::SocketAddr;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::post;
use axum::{Router, middleware};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use tokio::net::TcpListener;
use tokio::process::Command;
use tokio::sync::oneshot;
use tracing::{debug, error, info, warn};
use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
use tracing::{info, warn};
type HmacSha256 = Hmac<Sha256>;
@ -21,7 +9,7 @@ type HmacSha256 = Hmac<Sha256>;
///
/// `signature_header` is the value of the `X-Hub-Signature-256` header,
/// expected in the form `sha256=<hex-digest>`.
pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool {
pub(crate) fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool {
let Some(hex_digest) = signature_header.strip_prefix("sha256=") else {
return false;
};
@ -37,60 +25,7 @@ pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> b
mac.verify_slice(&expected).is_ok()
}
#[derive(Clone)]
struct WebhookState {
secret: Vec<u8>,
}
async fn webhook_handler(
State(state): State<WebhookState>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let delivery_id = headers
.get("x-github-delivery")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
let Some(signature) = headers
.get("x-hub-signature-256")
.and_then(|v| v.to_str().ok())
else {
warn!(delivery = %delivery_id, "Webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
};
if !verify_signature(&state.secret, &body, signature) {
warn!(delivery = %delivery_id, "Webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
}
let event_type = headers
.get("x-github-event")
.and_then(|v| v.to_str().ok())
.unwrap_or("unknown");
if tracing::enabled!(tracing::Level::DEBUG) {
let (repo, action) = parse_event_metadata(&body);
debug!(
event = %event_type,
delivery = %delivery_id,
repo = %repo,
action = %action,
"Webhook received"
);
} else {
info!(
event = %event_type,
delivery = %delivery_id,
"Webhook received"
);
}
StatusCode::OK
}
fn parse_event_metadata(body: &[u8]) -> (String, String) {
pub(crate) fn parse_event_metadata(body: &[u8]) -> (String, String) {
let parsed: serde_json::Value = serde_json::from_slice(body).unwrap_or_default();
let repo = parsed
.get("repository")
@ -106,106 +41,41 @@ fn parse_event_metadata(body: &[u8]) -> (String, String) {
(repo, action)
}
/// A running webhook listener that can be shut down.
pub struct WebhookListener {
port: u16,
shutdown_tx: oneshot::Sender<()>,
/// Manages `tailscale funnel` lifecycle for the main Fabro server port.
pub struct TailscaleFunnelManager {
port: u16,
}
impl WebhookListener {
pub fn port(&self) -> u16 {
self.port
}
pub fn shutdown(self) {
let _ = self.shutdown_tx.send(());
info!("Webhook listener stopped");
}
}
/// Spawn the webhook HTTP listener on a random port (127.0.0.1 only).
pub async fn spawn_webhook_listener(
secret: Vec<u8>,
ip_allowlist: Arc<IpAllowlistConfig>,
) -> anyhow::Result<WebhookListener> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let port = listener.local_addr()?.port();
let state = WebhookState { secret };
let router = Router::new()
.route("/webhooks/github", post(webhook_handler))
.with_state(state)
.layer(middleware::from_fn_with_state(
ip_allowlist,
ip_allowlist_middleware,
));
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
tokio::spawn(async move {
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
})
.await
.ok();
});
info!(port = port, "Webhook listener started");
Ok(WebhookListener { port, shutdown_tx })
}
/// Manage the full webhook lifecycle: listener + tailscale funnel + GitHub API.
pub struct WebhookManager {
listener: WebhookListener,
}
impl WebhookManager {
/// Start the webhook system: spawn listener, enable Tailscale funnel,
/// and update the GitHub App webhook URL.
impl TailscaleFunnelManager {
pub async fn start(
secret: Vec<u8>,
main_server_port: u16,
app_id: &str,
private_key_pem: &str,
ip_allowlist: Arc<IpAllowlistConfig>,
) -> anyhow::Result<Self> {
let listener = spawn_webhook_listener(secret, ip_allowlist).await?;
let port = listener.port();
let funnel_url = enable_tailscale_funnel(main_server_port).await?;
info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled");
// Enable Tailscale funnel
let funnel_url = match enable_tailscale_funnel(port).await {
Ok(url) => url,
Err(err) => {
error!(error = %err, "Failed to enable Tailscale funnel");
listener.shutdown();
return Err(err);
let webhook_url = format!("{funnel_url}/api/v1/webhooks/github");
match update_github_app_webhook(app_id, private_key_pem, &webhook_url).await {
Ok(()) => {
info!(url = %webhook_url, "GitHub App webhook URL updated");
}
Err(err) => {
warn!(
error = %err,
url = %webhook_url,
"Failed to update GitHub App webhook URL"
);
}
};
info!(url = %funnel_url, "Tailscale funnel enabled");
// Update GitHub App webhook URL
let webhook_url = format!("{funnel_url}/webhooks/github");
if let Err(err) = update_github_app_webhook(app_id, private_key_pem, &webhook_url).await {
error!(error = %err, "Failed to update GitHub App webhook URL");
disable_tailscale_funnel(port).await;
listener.shutdown();
return Err(err);
}
info!(url = %webhook_url, "GitHub App webhook URL updated");
Ok(Self { listener })
Ok(Self {
port: main_server_port,
})
}
/// Shut down: disable funnel, stop listener.
pub async fn shutdown(self) {
disable_tailscale_funnel(self.listener.port()).await;
self.listener.shutdown();
disable_tailscale_funnel(self.port).await;
}
}
@ -220,28 +90,26 @@ async fn enable_tailscale_funnel(port: u16) -> anyhow::Result<String> {
anyhow::bail!("tailscale funnel failed: {stderr}");
}
// Get the funnel URL from `tailscale funnel status`
let status_output = Command::new("tailscale")
.args(["funnel", "status"])
.output()
.await?;
if !status_output.status.success() {
let stderr = String::from_utf8_lossy(&status_output.stderr);
anyhow::bail!("tailscale funnel status failed: {stderr}");
}
let stdout = String::from_utf8_lossy(&status_output.stdout);
// Parse the HTTPS URL from status output — first line typically contains it
let url = stdout
.lines()
.find_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("https://") {
// Strip trailing path/colon info
Some(trimmed.trim_end_matches('/').to_string())
} else {
None
}
})
.ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))?;
parse_tailscale_funnel_url(&stdout)
.ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))
}
Ok(url)
fn parse_tailscale_funnel_url(status_output: &str) -> Option<String> {
status_output.lines().find_map(|line| {
line.split_whitespace()
.find(|part| part.starts_with("https://"))
.map(|url| url.trim_end_matches('/').to_string())
})
}
async fn disable_tailscale_funnel(port: u16) {
@ -251,19 +119,19 @@ async fn disable_tailscale_funnel(port: u16) {
.await
{
Ok(output) if output.status.success() => {
info!("Tailscale funnel disabled");
info!(port, "Tailscale funnel disabled");
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!(error = %stderr, "Failed to disable Tailscale funnel");
warn!(port, error = %stderr, "Failed to disable Tailscale funnel");
}
Err(err) => {
warn!(error = %err, "Failed to disable Tailscale funnel");
warn!(port, error = %err, "Failed to disable Tailscale funnel");
}
}
}
async fn update_github_app_webhook(
pub(crate) async fn update_github_app_webhook(
app_id: &str,
private_key_pem: &str,
webhook_url: &str,
@ -297,27 +165,7 @@ async fn update_github_app_webhook(
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::Request;
use tower::ServiceExt;
use super::*;
use crate::ip_allowlist::{IpAllowlist, IpAllowlistConfig};
fn test_http_client() -> fabro_http::HttpClient {
fabro_http::test_http_client().unwrap()
}
fn empty_allowlist_config() -> Arc<IpAllowlistConfig> {
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::default(),
trusted_proxy_count: 0,
})
}
// -----------------------------------------------------------------------
// verify_signature
// -----------------------------------------------------------------------
fn compute_signature(secret: &[u8], body: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(secret).unwrap();
@ -359,138 +207,21 @@ mod tests {
assert!(verify_signature(secret, body, &sig));
}
// -----------------------------------------------------------------------
// webhook_handler
// -----------------------------------------------------------------------
fn build_test_router(secret: &[u8]) -> Router {
let state = WebhookState {
secret: secret.to_vec(),
};
Router::new()
.route("/webhooks/github", post(webhook_handler))
.with_state(state)
.layer(middleware::from_fn_with_state(
empty_allowlist_config(),
ip_allowlist_middleware,
))
#[test]
fn parse_event_metadata_defaults_missing_fields() {
assert_eq!(
parse_event_metadata(br#"{"repository":{}}"#),
("unknown".to_string(), "none".to_string(),)
);
}
#[tokio::test]
async fn rejects_missing_signature() {
let app = build_test_router(b"secret");
let req = Request::builder()
.method("POST")
.uri("/webhooks/github")
.body(Body::from("{}"))
.unwrap();
#[test]
fn parse_tailscale_funnel_url_extracts_https_origin() {
let status = "https://fabro.example.ts.net proxy http://127.0.0.1:32276";
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_bad_signature() {
let app = build_test_router(b"secret");
let body = b"{}";
let bad_sig = compute_signature(b"wrong", body);
let req = Request::builder()
.method("POST")
.uri("/webhooks/github")
.header("x-hub-signature-256", bad_sig)
.body(Body::from(body.to_vec()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn accepts_valid_webhook() {
let secret = b"my-secret";
let app = build_test_router(secret);
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
let sig = compute_signature(secret, body);
let req = Request::builder()
.method("POST")
.uri("/webhooks/github")
.header("x-hub-signature-256", sig)
.header("x-github-event", "pull_request")
.header("x-github-delivery", "abc-123")
.body(Body::from(body.to_vec()))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
// -----------------------------------------------------------------------
// spawn_webhook_listener
// -----------------------------------------------------------------------
#[tokio::test]
async fn spawn_listener_serves_route() {
let secret = b"integration-secret";
let listener = spawn_webhook_listener(secret.to_vec(), empty_allowlist_config())
.await
.unwrap();
let port = listener.port();
// Valid request should return 200
let body = b"{}";
let sig = compute_signature(secret, body);
let client = test_http_client();
let resp = client
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
.header("x-hub-signature-256", sig)
.body(body.to_vec())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// Missing signature should return 401
let resp = client
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
.body("{}")
.send()
.await
.unwrap();
assert_eq!(resp.status(), 401);
listener.shutdown();
}
#[tokio::test]
async fn spawn_listener_blocks_non_allowlisted_ip() {
let secret = b"integration-secret";
let listener = spawn_webhook_listener(
secret.to_vec(),
Arc::new(IpAllowlistConfig {
allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]),
trusted_proxy_count: 0,
}),
)
.await
.unwrap();
let port = listener.port();
let body = b"{}";
let sig = compute_signature(secret, body);
let client = test_http_client();
let resp = client
.post(format!("http://127.0.0.1:{port}/webhooks/github"))
.header("x-hub-signature-256", sig)
.body(body.to_vec())
.send()
.await
.unwrap();
assert_eq!(resp.status(), 403);
listener.shutdown();
assert_eq!(
parse_tailscale_funnel_url(status),
Some("https://fabro.example.ts.net".to_string())
);
}
}

View file

@ -9,7 +9,9 @@ use fabro_config::merge::combine_files;
use fabro_config::user::load_settings_config;
use fabro_config::{Storage, resolve_server_from_file};
use fabro_sandbox::SandboxProvider;
use fabro_types::settings::server::{GithubIntegrationStrategy, ServerLayer, ServerListenLayer};
use fabro_types::settings::server::{
GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy,
};
use fabro_types::settings::{
InterpString, ObjectStoreSettings, ServerListenSettings,
ServerSettings as ResolvedServerSettings, SettingsLayer,
@ -25,7 +27,7 @@ use tokio::time::interval;
use tracing::{error, info, warn};
use crate::bind::{self, Bind, BindRequest};
use crate::github_webhooks::WebhookManager;
use crate::github_webhooks::{TailscaleFunnelManager, update_github_app_webhook};
use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config};
use crate::jwt_auth::resolve_auth_mode_with_lookup;
use crate::server::{
@ -163,6 +165,20 @@ async fn resolve_github_webhook_ip_allowlist(
Ok(Arc::new(config))
}
async fn resolve_startup_github_webhook_ip_allowlist(
resolved_server_settings: &ResolvedServerSettings,
github_meta_resolver: &GitHubMetaResolver,
webhook_secret_present: bool,
) -> anyhow::Result<Option<Arc<IpAllowlistConfig>>> {
if !webhook_secret_present {
return Ok(None);
}
resolve_github_webhook_ip_allowlist(resolved_server_settings, github_meta_resolver)
.await
.map(Some)
}
fn use_in_memory_store() -> bool {
!matches!(
std::env::var(TEST_IN_MEMORY_STORE_ENV).ok().as_deref(),
@ -335,6 +351,7 @@ where
let vault_path = storage.secrets_path();
let server_env_path = storage.server_state().env_path();
let server_secrets = ServerSecrets::load(server_env_path.clone())?;
let webhook_secret_present = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET").is_some();
// Shared config for live reloading
let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir);
@ -400,67 +417,143 @@ where
.await
.context("resolving server IP allowlist")?,
);
let github_webhook_ip_allowlist = resolve_startup_github_webhook_ip_allowlist(
&resolved_server_settings,
&github_meta_resolver,
webhook_secret_present,
)
.await?;
let router = build_router_with_options(
Arc::clone(&state),
auth_mode,
Arc::clone(&default_ip_allowlist),
RouterOptions { web_enabled },
RouterOptions {
web_enabled,
github_webhook_ip_allowlist,
},
);
let bound_listener = bind_listener(&bind_request).await?;
let bind_addr = bound_listener.bind.clone();
// Optionally start webhook listener
let webhook_manager = match resolved_server_settings.integrations.github.strategy {
GithubIntegrationStrategy::Token => None,
GithubIntegrationStrategy::App => {
let webhook_manager = match resolved_server_settings
.integrations
.github
.webhooks
.as_ref()
.and_then(|webhooks| webhooks.strategy)
{
None => None,
Some(_)
if resolved_server_settings.integrations.github.strategy
!= GithubIntegrationStrategy::App =>
{
warn!(
"GitHub webhook strategy is configured but GitHub integration auth is not set to app; skipping webhook startup"
);
None
}
Some(strategy) => {
let webhook_app_id = resolved_server_settings
.integrations
.github
.webhooks
.app_id
.as_ref()
.and(resolved_server_settings.integrations.github.app_id.as_ref())
.map(resolve_interp)
.transpose()?;
match webhook_app_id {
Some(app_id) => {
let secret = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET");
let github_app = state
.github_credentials(&resolved_server_settings.integrations.github)
.unwrap_or_else(|err| {
warn!(
error = %err,
"Webhook config present but GitHub credentials are invalid; skipping webhook listener"
"Webhook strategy is configured but GitHub credentials are invalid; skipping webhook startup"
);
None
});
if let (Some(secret), Some(fabro_github::GitHubCredentials::App(github_app))) =
(secret, github_app)
{
let webhook_ip_allowlist = resolve_github_webhook_ip_allowlist(
&resolved_server_settings,
&github_meta_resolver,
)
.await?;
match WebhookManager::start(
secret.into_bytes(),
&app_id,
&github_app.private_key_pem,
webhook_ip_allowlist,
)
.await
{
Ok(manager) => Some(manager),
Err(err) => {
error!(error = %err, "Failed to start webhook listener");
None
if webhook_secret_present {
if let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app {
match strategy {
WebhookStrategy::TailscaleFunnel => {
match bound_tcp_port(&bind_addr) {
Some(port) => match TailscaleFunnelManager::start(
port,
&app_id,
&github_app.private_key_pem,
)
.await
{
Ok(manager) => Some(manager),
Err(err) => {
error!(
error = %err,
"Failed to start Tailscale funnel for GitHub webhooks"
);
None
}
},
None => {
warn!(
"GitHub webhook strategy tailscale_funnel requires a TCP server listen address; skipping webhook startup"
);
None
}
}
}
WebhookStrategy::ServerUrl => {
let server_api_url = resolved_server_settings
.api
.url
.as_ref()
.map(resolve_interp)
.transpose()?
.expect(
"server.api.url should be validated when server_url strategy is configured",
);
let webhook_url = format!(
"{}/api/v1/webhooks/github",
server_api_url.trim_end_matches('/')
);
if let Err(err) = update_github_app_webhook(
&app_id,
&github_app.private_key_pem,
&webhook_url,
)
.await
{
warn!(
error = %err,
url = %webhook_url,
"Failed to update GitHub App webhook URL"
);
} else {
info!(
url = %webhook_url,
"GitHub App webhook URL updated"
);
}
None
}
}
} else {
warn!(
"Webhook strategy is configured but GITHUB_APP_PRIVATE_KEY is not available; skipping webhook startup"
);
None
}
} else {
warn!(
"Webhook config present but GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_PRIVATE_KEY not set; skipping webhook listener"
"Webhook strategy is configured but GITHUB_APP_WEBHOOK_SECRET is not set; skipping webhook startup"
);
None
}
}
None => None,
None => {
warn!(
"Webhook strategy is configured but server.integrations.github.app_id is not set; skipping webhook startup"
);
None
}
}
}
};
@ -517,8 +610,6 @@ where
}
});
let bound_listener = bind_listener(&bind_request).await?;
let bind_addr = bound_listener.bind.clone();
if bound_listener.used_random_port_fallback {
if let BindRequest::TcpHost(host) = bind_request {
warn!(
@ -600,6 +691,13 @@ enum BoundListener {
Tcp(TcpListener),
}
fn bound_tcp_port(bind: &Bind) -> Option<u16> {
match bind {
Bind::Tcp(address) => Some(address.port()),
Bind::Unix(_) => None,
}
}
async fn bind_listener(requested: &BindRequest) -> anyhow::Result<BoundServerListener> {
match requested {
BindRequest::Unix(path) => {
@ -748,7 +846,8 @@ mod tests {
GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings,
bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_slatedb_store,
resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist,
resolve_server_settings, router_web_enabled, server_bind_title, server_title,
resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled,
server_bind_title, server_title,
};
use crate::bind::{Bind, BindRequest};
@ -1080,4 +1179,41 @@ entries = ["github_meta_hooks"]
assert!(error.to_string().contains("GitHub webhook IP allowlist"));
}
#[tokio::test]
async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() {
let settings = resolve_server_settings(&parse_settings(
r#"
_version = 1
[server.listen]
type = "tcp"
address = "127.0.0.1:0"
[server.integrations.github]
strategy = "app"
app_id = "123"
[server.integrations.github.webhooks.ip_allowlist]
entries = ["github_meta_hooks"]
"#,
))
.expect("settings should resolve");
let cache_dir = tempfile::tempdir().unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let resolver = GitHubMetaResolver::new(
fabro_http::test_http_client().unwrap(),
format!("http://127.0.0.1:{port}/meta"),
cache_dir.path().join("github-meta.json"),
);
let allowlist = resolve_startup_github_webhook_ip_allowlist(&settings, &resolver, false)
.await
.expect("inactive webhook route should skip GitHub meta resolution");
assert!(allowlist.is_none());
}
}

View file

@ -111,6 +111,7 @@ use ulid::Ulid;
use crate::bind::Bind;
use crate::error::ApiError;
use crate::github_webhooks::{parse_event_metadata, verify_signature};
use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware};
use crate::jwt_auth::{
AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts,
@ -922,14 +923,18 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
)
}
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Debug)]
pub struct RouterOptions {
pub web_enabled: bool,
pub web_enabled: bool,
pub github_webhook_ip_allowlist: Option<Arc<IpAllowlistConfig>>,
}
impl Default for RouterOptions {
fn default() -> Self {
Self { web_enabled: true }
Self {
web_enabled: true,
github_webhook_ip_allowlist: None,
}
}
}
@ -945,8 +950,15 @@ pub fn build_router_with_options(
options: RouterOptions,
) -> Router {
start_optional_slack_service(&state);
let web_enabled = options.web_enabled;
let webhook_ip_allowlist = options.github_webhook_ip_allowlist;
let middleware_state = Arc::clone(&state);
let api_common = if options.web_enabled {
let webhook_state = Arc::clone(&state);
let default_webhook_ip_allowlist = Arc::clone(&ip_allowlist_config);
let webhook_secret_present = webhook_state
.server_secret("GITHUB_APP_WEBHOOK_SECRET")
.is_some();
let api_common = if web_enabled {
Router::new()
.route("/openapi.json", get(openapi_spec))
.merge(web_auth::api_routes())
@ -960,7 +972,7 @@ pub fn build_router_with_options(
.with_state(state.clone());
let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes()));
if options.web_enabled {
if web_enabled {
real_router = real_router.nest("/auth", web_auth::routes());
}
let real_router = real_router
@ -971,7 +983,7 @@ pub fn build_router_with_options(
let demo = demo_router.clone();
let real = real_router.clone();
async move {
if options.web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") {
if web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") {
demo.oneshot(req).await
} else {
real.oneshot(req).await
@ -1000,7 +1012,7 @@ pub fn build_router_with_options(
},
);
let mut router = Router::new()
let mut app_router = Router::new()
.route("/health", get(health))
.fallback_service(service_fn(move |req: axum_extract::Request| {
let dispatch = dispatch.clone();
@ -1008,14 +1020,12 @@ pub fn build_router_with_options(
let path = req.uri().path().to_string();
let dispatch_path = path.starts_with("/api/")
|| path == "/health"
|| (options.web_enabled && path.starts_with("/auth/"));
|| (web_enabled && path.starts_with("/auth/"));
if dispatch_path {
dispatch.oneshot(req).await
} else if options.web_enabled && removed_web_route(&path) {
} else if web_enabled && removed_web_route(&path) {
Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response())
} else if options.web_enabled
&& matches!(req.method(), &Method::GET | &Method::HEAD)
{
} else if web_enabled && matches!(req.method(), &Method::GET | &Method::HEAD) {
let headers = req.headers().clone();
Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers).await)
} else {
@ -1024,23 +1034,40 @@ pub fn build_router_with_options(
}
}));
if options.web_enabled {
router = router.layer(middleware::from_fn_with_state(
if web_enabled {
app_router = app_router.layer(middleware::from_fn_with_state(
middleware_state,
cookie_and_demo_middleware,
));
}
router = router.layer(middleware::from_fn_with_state(
app_router = app_router.layer(middleware::from_fn_with_state(
ip_allowlist_config,
ip_allowlist_middleware,
));
let mut router = app_router;
if webhook_secret_present {
let webhook_ip_allowlist = webhook_ip_allowlist.unwrap_or(default_webhook_ip_allowlist);
router = github_webhook_routes(webhook_ip_allowlist)
.with_state(webhook_state)
.merge(router);
}
router
.layer(middleware::from_fn(security_headers::layer))
.layer(trace_layer)
}
fn github_webhook_routes(ip_allowlist_config: Arc<IpAllowlistConfig>) -> Router<Arc<AppState>> {
Router::new()
.route("/api/v1/webhooks/github", post(github_webhook))
.layer(middleware::from_fn_with_state(
ip_allowlist_config,
ip_allowlist_middleware,
))
}
fn demo_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/runs", get(demo::list_runs).post(demo::create_run_stub))
@ -1211,6 +1238,62 @@ async fn not_implemented() -> Response {
ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response()
}
async fn github_webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
let delivery_id = headers
.get("x-github-delivery")
.and_then(|value| value.to_str().ok())
.unwrap_or("unknown");
let Some(secret) = state.server_secret("GITHUB_APP_WEBHOOK_SECRET") else {
warn!(
delivery = %delivery_id,
"GitHub webhook route reached without a configured webhook secret"
);
return StatusCode::NOT_FOUND;
};
let Some(signature) = headers
.get("x-hub-signature-256")
.and_then(|value| value.to_str().ok())
else {
warn!(delivery = %delivery_id, "Webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
};
if !verify_signature(secret.as_bytes(), &body, signature) {
warn!(delivery = %delivery_id, "Webhook signature verification failed");
return StatusCode::UNAUTHORIZED;
}
let event_type = headers
.get("x-github-event")
.and_then(|value| value.to_str().ok())
.unwrap_or("unknown");
if tracing::enabled!(tracing::Level::DEBUG) {
let (repo, action) = parse_event_metadata(&body);
debug!(
event = %event_type,
delivery = %delivery_id,
repo = %repo,
action = %action,
"Webhook received"
);
} else {
info!(
event = %event_type,
delivery = %delivery_id,
"Webhook received"
);
}
StatusCode::OK
}
async fn health() -> Response {
Json(serde_json::json!({
"status": "ok",
@ -7307,7 +7390,9 @@ mod tests {
use fabro_model::Provider;
use fabro_types::settings::ServerAuthMethod;
use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures};
use hmac::{Hmac, Mac};
use serde_json::json;
use sha2::Sha256;
use tower::ServiceExt;
use super::*;
@ -7319,6 +7404,13 @@ mod tests {
exit [shape=Msquare]
start -> exit
}"#;
const TEST_WEBHOOK_SECRET: &str = "webhook-secret";
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const WRONG_DEV_TOKEN: &str =
"fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd";
type TestHmacSha256 = Hmac<Sha256>;
fn test_app_with() -> Router {
let state = create_app_state();
@ -7339,6 +7431,28 @@ mod tests {
format!("/api/v1{path}")
}
fn webhook_signature(secret: &str, body: &[u8]) -> String {
let mut mac = TestHmacSha256::new_from_slice(secret.as_bytes()).unwrap();
mac.update(body);
format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
}
fn webhook_test_app(auth_mode: AuthMode) -> Router {
let secret = TEST_WEBHOOK_SECRET.to_string();
let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| {
(name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone())
});
build_router_with_options(
state,
auth_mode,
Arc::new(IpAllowlistConfig::default()),
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
)
}
#[tokio::test]
async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() {
let state = create_app_state();
@ -7403,6 +7517,79 @@ type = "http"
)]);
}
#[tokio::test]
async fn github_webhook_rejects_missing_or_invalid_signatures() {
let app = webhook_test_app(AuthMode::Disabled);
let body = br#"{"action":"opened"}"#;
let missing_signature = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api("/webhooks/github"))
.body(Body::from(body.to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_signature.status(), StatusCode::UNAUTHORIZED);
let invalid_signature = app
.oneshot(
Request::builder()
.method("POST")
.uri(api("/webhooks/github"))
.header(
"x-hub-signature-256",
webhook_signature("wrong-secret", body),
)
.body(Body::from(body.to_vec()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(invalid_signature.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn github_webhook_accepts_valid_signatures_regardless_of_auth_mode() {
let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#;
let signature = webhook_signature(TEST_WEBHOOK_SECRET, body);
for auth_mode in [
AuthMode::Disabled,
AuthMode::Enabled(ConfiguredAuth {
methods: vec![ServerAuthMethod::DevToken],
dev_token: Some(TEST_DEV_TOKEN.to_string()),
}),
] {
let app = webhook_test_app(auth_mode);
for authorization in [
None,
Some(format!("Bearer {WRONG_DEV_TOKEN}")),
Some(format!("Bearer {TEST_DEV_TOKEN}")),
] {
let mut request = Request::builder()
.method("POST")
.uri(api("/webhooks/github"))
.header("x-hub-signature-256", signature.clone())
.header("x-github-event", "pull_request")
.body(Body::from(body.to_vec()))
.unwrap();
if let Some(value) = authorization.as_deref() {
request
.headers_mut()
.insert(header::AUTHORIZATION, value.parse().unwrap());
}
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
}
#[tokio::test]
async fn create_secret_stores_valid_credential_entries() {
let state = create_app_state();

View file

@ -58,3 +58,22 @@ fn changelog_marks_removed_mutual_tls_as_historical() {
"historical changelog should clarify that inbound mutual TLS is no longer supported"
);
}
#[test]
fn github_docs_describe_webhooks_as_strategy_dependent() {
let github = read_doc("docs/integrations/github.mdx");
assert!(
!github.contains("enables browser OAuth and webhooks"),
"GitHub integration docs should not imply app auth alone enables webhook delivery"
);
assert!(
!github.contains("| Webhooks | No | Yes |"),
"GitHub strategy matrix should describe webhook delivery as strategy-dependent"
);
let server_configuration = read_doc("docs/administration/server-configuration.mdx");
assert!(
!server_configuration.contains("enables the GitHub App flow, browser OAuth, and webhooks"),
"server configuration docs should not imply app auth alone enables webhook delivery"
);
}

View file

@ -310,7 +310,10 @@ enabled = false
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions { web_enabled: false },
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
);
for (method, path, body) in [
@ -369,7 +372,10 @@ enabled = false
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
Arc::new(IpAllowlistConfig::default()),
RouterOptions { web_enabled: false },
RouterOptions {
web_enabled: false,
..RouterOptions::default()
},
);
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";

View file

@ -536,4 +536,5 @@ pub enum GithubIntegrationStrategy {
#[serde(rename_all = "snake_case")]
pub enum WebhookStrategy {
TailscaleFunnel,
ServerUrl,
}

View file

@ -5,6 +5,7 @@ api/discovery-api.ts
api/human-in-the-loop-api.ts
api/insights-api.ts
api/install-api.ts
api/integrations-api.ts
api/models-api.ts
api/repos-api.ts
api/run-internals-api.ts

View file

@ -20,6 +20,7 @@ export * from './api/discovery-api';
export * from './api/human-in-the-loop-api';
export * from './api/insights-api';
export * from './api/install-api';
export * from './api/integrations-api';
export * from './api/models-api';
export * from './api/repos-api';
export * from './api/run-internals-api';

View file

@ -0,0 +1,122 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
/**
* IntegrationsApi - axios parameter creator
*/
export const IntegrationsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {File} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
receiveGithubWebhook: async (body: File, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'body' is not null or undefined
assertParamExists('receiveGithubWebhook', 'body', body)
const localVarPath = `/api/v1/webhooks/github`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* IntegrationsApi - functional programming interface
*/
export const IntegrationsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = IntegrationsApiAxiosParamCreator(configuration)
return {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {File} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.receiveGithubWebhook(body, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['IntegrationsApi.receiveGithubWebhook']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* IntegrationsApi - factory interface
*/
export const IntegrationsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = IntegrationsApiFp(configuration)
return {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {File} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.receiveGithubWebhook(body, options).then((request) => request(axios, basePath));
},
};
};
/**
* IntegrationsApi - object-oriented interface
*/
export class IntegrationsApi extends BaseAPI {
/**
* Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth.
* @summary Receive GitHub Webhook
* @param {File} body
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig) {
return IntegrationsApiFp(this.configuration).receiveGithubWebhook(body, options).then((request) => request(this.axios, this.basePath));
}
}