From f690e8f30437fb8f24fcaeb1f166239714b1b1a3 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Mon, 22 Dec 2025 19:41:04 +0100 Subject: [PATCH 01/17] initial --- api/.env.local | 1 + ...9af7d074973f208679c8507cd5c7b20b03b36.json | 152 ++++++++++++++ ...2dd858c5b403cd4315ad0de5706e1ad5f0166.json | 116 +++++++++++ ...0eab17ef6674a1b321922f171a2ec86427bb.json} | 4 +- ...119f4e41e142947a244743e5890a37800825a.json | 115 ++++++++++ api/migrations/20251221141049_webhook.sql | 65 ++++++ api/src/api/scope.rs | 135 ++++++++++++ api/src/api/types.rs | 45 ++++ api/src/db/database.rs | 125 ++++++++++- api/src/db/models.rs | 124 +++++++++++ api/src/tasks.rs | 55 +++-- .../~/{settings.tsx => settings/index.tsx} | 27 ++- .../routes/@[scope]/~/settings/webhooks.tsx | 197 ++++++++++++++++++ frontend/utils/api_types.ts | 25 +++ terraform/queues.tf | 38 ++++ 15 files changed, 1183 insertions(+), 41 deletions(-) create mode 100644 api/.env.local create mode 100644 api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json create mode 100644 api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json rename api/.sqlx/{query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json => query-a3a2216da3088328e296bca671120eab17ef6674a1b321922f171a2ec86427bb.json} (90%) create mode 100644 api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json create mode 100644 api/migrations/20251221141049_webhook.sql rename frontend/routes/@[scope]/~/{settings.tsx => settings/index.tsx} (94%) create mode 100644 frontend/routes/@[scope]/~/settings/webhooks.tsx diff --git a/api/.env.local b/api/.env.local new file mode 100644 index 000000000..66b0d2827 --- /dev/null +++ b/api/.env.local @@ -0,0 +1 @@ +DATABASE_URL=postgres://postgres:password@localhost:/registry_webhooks diff --git a/api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json b/api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json new file mode 100644 index 000000000..197da0f00 --- /dev/null +++ b/api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json @@ -0,0 +1,152 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_archived", + "scope_member_added", + "scope_member_left" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Varchar", + { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_archived", + "scope_member_added", + "scope_member_left" + ] + } + } + } + } + } + }, + { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36" +} diff --git a/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json new file mode 100644 index 000000000..a5c3cf267 --- /dev/null +++ b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json @@ -0,0 +1,116 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_archived", + "scope_member_added", + "scope_member_left" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166" +} diff --git a/api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json b/api/.sqlx/query-a3a2216da3088328e296bca671120eab17ef6674a1b321922f171a2ec86427bb.json similarity index 90% rename from api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json rename to api/.sqlx/query-a3a2216da3088328e296bca671120eab17ef6674a1b321922f171a2ec86427bb.json index 896fca8b7..6f15215bc 100644 --- a/api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json +++ b/api/.sqlx/query-a3a2216da3088328e296bca671120eab17ef6674a1b321922f171a2ec86427bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n audit_logs.actor_id as \"audit_actor_id\",\n audit_logs.is_sudo as \"audit_is_sudo\",\n audit_logs.action as \"audit_action\",\n audit_logs.meta as \"audit_meta\",\n audit_logs.created_at as \"audit_created_at\",\n users.id as \"user_id\",\n users.name as \"user_name\",\n users.avatar_url as \"user_avatar_url\",\n users.github_id as \"user_github_id\",\n users.updated_at as \"user_updated_at\",\n users.created_at as \"user_created_at\"\n FROM\n audit_logs\n LEFT JOIN\n users ON audit_logs.actor_id = users.id\n WHERE \n audit_logs.meta::text LIKE $1\n ORDER BY audit_logs.created_at DESC;\n ", + "query": "\n SELECT\n audit_logs.actor_id as \"audit_actor_id\",\n audit_logs.is_sudo as \"audit_is_sudo\",\n audit_logs.action as \"audit_action\",\n audit_logs.meta as \"audit_meta\",\n audit_logs.created_at as \"audit_created_at\",\n users.id as \"user_id\",\n users.name as \"user_name\",\n users.avatar_url as \"user_avatar_url\",\n users.github_id as \"user_github_id\",\n users.updated_at as \"user_updated_at\",\n users.created_at as \"user_created_at\"\n FROM\n audit_logs\n LEFT JOIN\n users ON audit_logs.actor_id = users.id\n WHERE\n audit_logs.meta::text LIKE $1\n ORDER BY audit_logs.created_at DESC;\n ", "describe": { "columns": [ { @@ -78,5 +78,5 @@ false ] }, - "hash": "1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764" + "hash": "a3a2216da3088328e296bca671120eab17ef6674a1b321922f171a2ec86427bb" } diff --git a/api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json b/api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json new file mode 100644 index 000000000..46ae8a989 --- /dev/null +++ b/api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json @@ -0,0 +1,115 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_archived", + "scope_member_added", + "scope_member_left" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a" +} diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql new file mode 100644 index 000000000..88ea8aea3 --- /dev/null +++ b/api/migrations/20251221141049_webhook.sql @@ -0,0 +1,65 @@ +CREATE TYPE webhook_event_kind AS ENUM ( + 'package_version_published', + 'package_version_yanked', + 'package_version_deleted', + 'scope_package_created', + 'scope_package_archived', + 'scope_member_added', + 'scope_member_left' +); + +CREATE TYPE webhook_payload_format AS ENUM ( + 'json', + 'discord' +); + +CREATE TABLE webhook_endpoints ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scope TEXT NOT NULL references scopes (scope), + package TEXT, + url TEXT NOT NULL, + description TEXT, + secret VARCHAR(255), + events webhook_event_kind[] NOT NULL, + payload_format webhook_payload_format NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + updated_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + + FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE +); + +SELECT manage_updated_at('webhook_endpoints'); + +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + scope TEXT NOT NULL references scopes (scope), + package TEXT, + event webhook_event_kind NOT NULL, + payload JSONB NOT NULL, + idempotency_key TEXT UNIQUE, + created_at timestamptz NOT NULL DEFAULT now(), + + FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE +); + +CREATE TYPE webhook_delivery_status AS ENUM ( + 'pending', 'success', 'failure', 'retrying' +); + +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + endpoint_id UUID NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE, + event_id UUID NOT NULL REFERENCES webhook_events(id) ON DELETE CASCADE, + status webhook_delivery_status NOT NULL, + + request_headers JSONB, + + response_http_code INT, + response_headers JSONB, + response_body TEXT, + + updated_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() +); +SELECT manage_updated_at('webhook_deliveries'); diff --git a/api/src/api/scope.rs b/api/src/api/scope.rs index 93a87282f..8a0afe525 100644 --- a/api/src/api/scope.rs +++ b/api/src/api/scope.rs @@ -55,6 +55,22 @@ pub fn scope_router() -> Router { "/:scope/invites/:user_id", util::auth(delete_invite_handler), ) + .post( + "/:scope/webhooks", + util::auth(util::json(create_webhook_handler)), + ) + .get( + "/:scope/webhooks", + util::auth(util::json(list_webhooks_handler)), + ) + .get( + "/:scope/webhooks/:webhook", + util::auth(util::json(get_webhook_handler)), + ) + .delete( + "/:scope/webhooks/:webhook", + util::auth(delete_webhook_handler), + ) .build() .unwrap() } @@ -501,6 +517,125 @@ pub async fn delete_invite_handler( Ok(resp) } +#[instrument( + name = "POST /api/scopes/:scope/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn create_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + Span::current().record("scope", field::display(&scope)); + + let ApiCreateWebhookEndpointRequest { + package, + url, + description, + secret, + events, + payload_format, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .create_webhook_endpoint( + NewWebhookEndpoint { + scope: &scope, + package: package.as_ref(), + url: &url, + description: description.as_deref(), + secret: &secret, + events, + payload_format, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = + db.get_webhook_endpoint(&scope, None, webhook_id).await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhooks_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = db.list_webhook_endpoints(&scope, None).await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "DELETE /api/scopes/:scope/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn delete_webhook_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + db.delete_webhook_endpoint(&user.id, sudo, &scope, None, webhook_id) + .await?; + + let res = Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap(); + Ok(res) +} + #[cfg(test)] pub mod tests { use super::*; diff --git a/api/src/api/types.rs b/api/src/api/types.rs index c17befe92..0e5d10d32 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1149,3 +1149,48 @@ impl From<(AuditLog, UserPublic)> for ApiAuditLog { } } } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiCreateWebhookEndpointRequest { + pub package: Option, + pub url: String, + pub description: Option, + pub secret: String, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiWebhookEndpoint { + pub id: Uuid, + pub scope: ScopeName, + pub package: Option, + pub url: String, + pub description: Option, + pub has_secret: bool, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, + pub is_active: bool, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +impl From for ApiWebhookEndpoint { + fn from(value: WebhookEndpoint) -> Self { + Self { + id: value.id, + scope: value.scope, + package: value.package, + url: value.url, + description: value.description, + has_secret: value.secret.is_some(), + events: value.events, + payload_format: value.payload_format, + is_active: value.is_active, + updated_at: value.updated_at, + created_at: value.created_at, + } + } +} diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 9f91b43ff..6da6de7e0 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -4664,7 +4664,7 @@ impl Database { audit_logs LEFT JOIN users ON audit_logs.actor_id = users.id - WHERE + WHERE audit_logs.meta::text LIKE $1 ORDER BY audit_logs.created_at DESC; "#, @@ -4973,6 +4973,129 @@ impl Database { Ok((total_scopes as usize, scopes)) } + + #[instrument( + name = "Database::create_webhook_endpoint", + skip(self, new_webhook), + err + )] + pub async fn create_webhook_endpoint( + &self, + new_webhook: NewWebhookEndpoint<'_>, + actor_id: &Uuid, + is_sudo: bool, + ) -> Result { + let mut tx = self.pool.begin().await?; + + audit_log( + &mut tx, + actor_id, + is_sudo, + "create_webhook_endpoint", + json!({ + "scope": new_webhook.scope, + "package": new_webhook.package, + "url": new_webhook.url + }), + ) + .await?; + + let res = sqlx::query_as!( + WebhookEndpoint, + r#"INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at"#, + new_webhook.scope, + new_webhook.package as _, + new_webhook.url, + new_webhook.description, + new_webhook.secret, + new_webhook.events as _, + new_webhook.payload_format as _, + ) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(res) + } + + #[instrument(name = "Database::get_webhook_endpoint", skip(self), err)] + pub async fn get_webhook_endpoint( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + id: Uuid, + ) -> Result { + sqlx::query_as!( + WebhookEndpoint, + r#"SELECT id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at + FROM webhook_endpoints + WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3"#, + scope as _, + package as _, + id, + ) + .fetch_one(&self.pool) + .await + } + + #[instrument(name = "Database::list_webhook_endpoints", skip(self), err)] + pub async fn list_webhook_endpoints( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + ) -> Result> { + sqlx::query_as!( + WebhookEndpoint, + r#"SELECT id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at + FROM webhook_endpoints + WHERE scope = $1 AND ($2::text IS NULL OR package = $2)"#, + scope as _, + package as _, + ) + .fetch_all(&self.pool) + .await + } + + #[instrument(name = "Database::delete_webhook_endpoint", skip(self), err)] + pub async fn delete_webhook_endpoint( + &self, + actor_id: &Uuid, + is_sudo: bool, + scope: &ScopeName, + package: Option<&PackageName>, + id: Uuid, + ) -> Result<()> { + let mut tx = self.pool.begin().await?; + + audit_log( + &mut tx, + actor_id, + is_sudo, + "delete_webhook_endpoint", + json!({ + "scope": scope, + "package": package, + "id": id + }), + ) + .await?; + + sqlx::query!( + r#"DELETE FROM webhook_endpoints WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3"#, + scope as _, + package as _, + id, + ) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(()) + } } async fn finalize_package_creation( diff --git a/api/src/db/models.rs b/api/src/db/models.rs index f6e959933..36746c3a0 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -1042,3 +1042,127 @@ impl FromRow<'_, sqlx::postgres::PgRow> for AuditLog { }) } } + +#[derive(Debug, Clone, PartialEq, sqlx::Type, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[sqlx(type_name = "webhook_event_kind", rename_all = "snake_case")] +pub enum WebhookEventKind { + PackageVersionPublished, + PackageVersionYanked, + PackageVersionDeleted, + ScopePackageCreated, + ScopePackageArchived, + ScopeMemberAdded, + ScopeMemberLeft, +} + +impl sqlx::postgres::PgHasArrayType for WebhookEventKind { + fn array_type_info() -> sqlx::postgres::PgTypeInfo { + // Postgres creates array types with an underscore prefix by default + sqlx::postgres::PgTypeInfo::with_name("_webhook_event_kind") + } +} + +#[derive(Debug, Clone, PartialEq, sqlx::Type, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[sqlx(type_name = "webhook_payload_format", rename_all = "snake_case")] +pub enum WebhookPayloadFormat { + Json, + Discord, +} + +#[derive(Debug, Clone)] +pub struct WebhookEndpoint { + pub id: Uuid, + pub scope: ScopeName, + pub package: Option, + pub url: String, + pub description: Option, + pub secret: Option, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, + pub is_active: bool, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +impl FromRow<'_, sqlx::postgres::PgRow> for WebhookEndpoint { + fn from_row(row: &sqlx::postgres::PgRow) -> Result { + Ok(Self { + id: try_get_row_or(row, "id", "webhook_endpoint_id")?, + scope: try_get_row_or(row, "scope", "webhook_endpoint_scope")?, + package: try_get_row_or(row, "package", "webhook_endpoint_package")?, + url: try_get_row_or(row, "url", "webhook_endpoint_url")?, + description: try_get_row_or( + row, + "description", + "webhook_endpoint_description", + )?, + secret: try_get_row_or(row, "secret", "webhook_endpoint_secret")?, + events: try_get_row_or(row, "events", "webhook_endpoint_events")?, + payload_format: try_get_row_or(row, "type", "webhook_endpoint_type")?, + is_active: try_get_row_or( + row, + "is_active", + "webhook_endpoint_is_active", + )?, + updated_at: try_get_row_or( + row, + "updated_at", + "webhook_endpoint_updated_at", + )?, + created_at: try_get_row_or( + row, + "created_at", + "webhook_endpoint_created_at", + )?, + }) + } +} + +pub struct NewWebhookEndpoint<'s> { + pub scope: &'s ScopeName, + pub package: Option<&'s ScopeName>, + pub url: &'s str, + pub description: Option<&'s str>, + pub secret: &'s str, + pub events: Vec, + pub payload_format: WebhookPayloadFormat, +} + +#[derive(Debug, Clone)] +pub struct WebhookEvent { + pub id: Uuid, + pub scope: ScopeName, + pub package: Option, + pub event: WebhookEventKind, + pub payload: serde_json::Value, + pub idempotency_key: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, sqlx::Type)] +#[sqlx(type_name = "task_status", rename_all = "lowercase")] +pub enum WebhookDeliveryStatus { + Pending, + Success, + Failure, + Retrying, +} + +#[derive(Debug, Clone)] +pub struct WebhookDelivery { + pub id: Uuid, + pub endpoint_id: Uuid, + pub event_id: Uuid, + pub status: WebhookDeliveryStatus, + + pub request_headers: serde_json::Value, + + pub response_http_code: Option, + pub response_headers: Option, + pub response_body: Option, + + pub updated_at: DateTime, + pub created_at: DateTime, +} diff --git a/api/src/tasks.rs b/api/src/tasks.rs index 4c54aa9d9..81d888c23 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -1,29 +1,6 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. use std::collections::HashSet; -use bytes::Bytes; -use chrono::DateTime; -use chrono::Utc; -use deno_semver::StackString; -use deno_semver::VersionReq; -use deno_semver::package::PackageReq; -use deno_semver::package::PackageReqReference; -use deno_semver::package::PackageSubPath; -use futures::StreamExt; -use futures::stream; -use hyper::Body; -use hyper::Request; -use routerify::Router; -use routerify::ext::RequestExt; -use routerify_query::RequestQueryExt; -use serde::Deserialize; -use serde::Serialize; -use serde_json::json; -use tracing::Span; -use tracing::error; -use tracing::field; -use tracing::instrument; - use crate::NpmUrl; use crate::RegistryUrl; use crate::analysis::RebuildNpmTarballData; @@ -49,6 +26,29 @@ use crate::publish; use crate::util; use crate::util::ApiResult; use crate::util::decode_json; +use bytes::Bytes; +use chrono::DateTime; +use chrono::Utc; +use deno_semver::StackString; +use deno_semver::VersionReq; +use deno_semver::package::PackageReq; +use deno_semver::package::PackageReqReference; +use deno_semver::package::PackageSubPath; +use futures::StreamExt; +use futures::stream; +use hyper::Body; +use hyper::Request; +use routerify::Router; +use routerify::ext::RequestExt; +use routerify_query::RequestQueryExt; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use tracing::Span; +use tracing::error; +use tracing::field; +use tracing::instrument; +use uuid::Uuid; pub struct NpmTarballBuildQueue(pub Option); pub struct LogsBigQueryTable( @@ -67,6 +67,7 @@ pub fn tasks_router() -> Router { "/scrape_download_counts", util::json(scrape_download_counts_handler), ) + .post("/webhook_dispatch", util::json(webhook_dispatch_handler)) .build() .unwrap() } @@ -453,6 +454,14 @@ fn deserialize_version_download_count_from_bigquery( })) } +#[instrument(name = "POST /tasks/webhook_dispatch", skip(req), err)] +pub async fn webhook_dispatch_handler(mut req: Request) -> ApiResult<()> { + let webhook_dispatch_id: Uuid = decode_json(&mut req).await?; + let db = req.data::().unwrap(); + + Ok(()) +} + #[cfg(test)] mod tests { use chrono::DateTime; diff --git a/frontend/routes/@[scope]/~/settings.tsx b/frontend/routes/@[scope]/~/settings/index.tsx similarity index 94% rename from frontend/routes/@[scope]/~/settings.tsx rename to frontend/routes/@[scope]/~/settings/index.tsx index af98b36b2..c73ce15ce 100644 --- a/frontend/routes/@[scope]/~/settings.tsx +++ b/frontend/routes/@[scope]/~/settings/index.tsx @@ -2,16 +2,16 @@ import { HttpError } from "fresh"; import { ComponentChildren } from "preact"; import { TbCheck, TbTrash } from "tb-icons"; -import { define } from "../../../util.ts"; -import { ScopeHeader } from "../(_components)/ScopeHeader.tsx"; -import { ScopeNav } from "../(_components)/ScopeNav.tsx"; -import { ScopeDescriptionForm } from "../(_islands)/ScopeDescriptionForm.tsx"; -import { FullScope, User } from "../../../utils/api_types.ts"; -import { scopeDataWithMember } from "../../../utils/data.ts"; -import { path } from "../../../utils/api.ts"; -import { QuotaCard } from "../../../components/QuotaCard.tsx"; -import { scopeIAM } from "../../../utils/iam.ts"; -import { TicketModal } from "../../../islands/TicketModal.tsx"; +import { define } from "../../../../util.ts"; +import { ScopeHeader } from "../../(_components)/ScopeHeader.tsx"; +import { ScopeNav } from "../../(_components)/ScopeNav.tsx"; +import { ScopeDescriptionForm } from "../../(_islands)/ScopeDescriptionForm.tsx"; +import { FullScope, User } from "../../../../utils/api_types.ts"; +import { scopeDataWithMember } from "../../../../utils/data.ts"; +import { path } from "../../../../utils/api.ts"; +import { QuotaCard } from "../../../../components/QuotaCard.tsx"; +import { scopeIAM } from "../../../../utils/iam.ts"; +import { TicketModal } from "../../../../islands/TicketModal.tsx"; export default define.page(function ScopeSettingsPage( { data, state }, @@ -31,12 +31,9 @@ export default define.page(function ScopeSettingsPage( function ScopeDescription({ scope }: { scope: FullScope }) { return ( -
+

Description

-

- The description of the scope{" "} - @{scope.scope}: -

+

The description of the scope

); diff --git a/frontend/routes/@[scope]/~/settings/webhooks.tsx b/frontend/routes/@[scope]/~/settings/webhooks.tsx new file mode 100644 index 000000000..12087350f --- /dev/null +++ b/frontend/routes/@[scope]/~/settings/webhooks.tsx @@ -0,0 +1,197 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import { HttpError, RouteConfig } from "fresh"; +import { ComponentChildren } from "preact"; +import { TbCheck, TbTrash } from "tb-icons"; +import { define } from "../../../../util.ts"; +import { ScopeHeader } from "../../(_components)/ScopeHeader.tsx"; +import { ScopeNav } from "../../(_components)/ScopeNav.tsx"; +import { ScopeDescriptionForm } from "../../(_islands)/ScopeDescriptionForm.tsx"; +import { + FullScope, + WebhookEndpoint, +} from "../../../../utils/api_types.ts"; +import { scopeDataWithMember } from "../../../../utils/data.ts"; +import { path } from "../../../../utils/api.ts"; +import { scopeIAM } from "../../../../utils/iam.ts"; + +const events = [ + { + id: "package_version_published", + name: "Package version published", + description: "A new version of a package is published.", + }, + { + id: "package_version_yanked", + name: "Package version yanked", + description: "A version of a package is yanked.", + }, + { + id: "package_version_deleted", + name: "Package version deleted", + description: "A version of a package is deleted.", + }, + { + id: "scope_package_created", + name: "Scope package created", + description: "A new package is created in the scope.", + }, + { + id: "scope_package_archived", + name: "Scope package archived", + description: "A package in the scope is archived.", + }, + { + id: "scope_member_added", + name: "Scope member added", + description: "A new member is added to the scope.", + }, + { + id: "scope_member_left", + name: "Scope member left", + description: "A member leaves the scope.", + }, +] + +export default define.page(function ScopeSettingsPage( + { data, state }, +) { + return ( +
+ + +
+
+

Description

+
+ +
+
+
+

URL

+
+ +
+
+
+

Payload format

+
+ +
+
+
+

Secret

+
+ +
+
+
+

Events

+
+ {events.map((event) => )} +
+
+
+
+ ); +}); + +export const handler = define.handlers({ + async GET(ctx) { + const [user, data, webhookResp] = await Promise.all([ + ctx.state.userPromise, + scopeDataWithMember(ctx.state, ctx.params.scope), + ctx.state.api.get(path`/scopes/${ctx.params.scope}/webhooks/${ctx.params.webhook}`), + ]); + if (user instanceof Response) return user; + if (data === null) throw new HttpError(404, "The scope was not found."); + + const iam = scopeIAM(ctx.state, data?.scopeMember, user); + if (!iam.canAdmin) throw new HttpError(404, "The scope was not found."); + + if (!webhookResp.ok) { + if (webhookResp.code === "webhookNotFound") { + throw new HttpError(404, "The webhook was not found."); + } + throw webhookResp; // graceful handle errors + } + + ctx.state.meta = { title: `Settings - @${data.scope.scope} - JSR` }; + return { + data: { + scope: data.scope as FullScope, + webhook: webhookResp.data, + iam, + }, + }; + }, + async POST(ctx) { + const req = ctx.req; + const scope = ctx.params.scope; + const form = await req.formData(); + const action = String(form.get("action")); + let enableGhActionsVerifyActor = false; + switch (action) { + case "enableGhActionsVerifyActor": + enableGhActionsVerifyActor = true; + // fallthrough + case "disableGhActionsVerifyActor": { + const res = await ctx.state.api.patch( + path`/scopes/${scope}`, + { ghActionsVerifyActor: enableGhActionsVerifyActor }, + ); + if (!res.ok) { + if (res.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw res; // graceful handle errors + } + return new Response(null, { + status: 303, + headers: { Location: `/@${scope}/~/settings` }, + }); + } + case "requirePublishingFromCI": { + const value = form.get("value") === "true"; + const res = await ctx.state.api.patch( + path`/scopes/${scope}`, + { requirePublishingFromCI: value }, + ); + if (!res.ok) { + if (res.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw res; // graceful handle errors + } + return new Response(null, { + status: 303, + headers: { Location: `/@${scope}/~/settings` }, + }); + } + case "deleteScope": { + const res = await ctx.state.api.delete(path`/scopes/${scope}`); + if (!res.ok) { + if (res.code === "scopeNotFound") { + throw new HttpError(404, "The scope was not found."); + } + throw res; // graceful handle errors + } + return new Response(null, { + status: 303, + headers: { Location: `/` }, + }); + } + default: + throw new Error("Invalid action " + action); + } + }, +}); + +export const config: RouteConfig = { + routeOverride: "/@:scope/~/settings/webhooks/:webhook", +}; diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index c373f7490..0262346bd 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -401,3 +401,28 @@ export interface PackageDownloadsRecentVersion { version: string; downloads: DownloadDataPoint[]; } + +export type WebhookEventKind = + | "package_version_published" + | "package_version_yanked" + | "package_version_deleted" + | "scope_package_created" + | "scope_package_archived" + | "scope_member_added" + | "scope_member_left"; + +export type WebhookPayloadFormat = "json" | "discord"; + +export interface WebhookEndpoint { + id: string; + scope: string; + package: string | null; + url: string; + description: string | null; + secret: string; + events: WebhookEventKind[]; + payloadFormat: WebhookPayloadFormat, + isActive: boolean; + updatedAt: string; + createdAt: string; +} diff --git a/terraform/queues.tf b/terraform/queues.tf index 572fcb2a7..4aad91081 100644 --- a/terraform/queues.tf +++ b/terraform/queues.tf @@ -2,6 +2,7 @@ locals { publishing_tasks_queue_name = var.gcp_project == "deno-registry3-prod" ? "publishing-tasks3" : "publishing-tasks" npm_tarball_build_tasks_queue_name = "npm-tarball-build-tasks2" + webhook_dispatches_queue_name = "webhook-dispatches" } resource "google_cloud_tasks_queue" "publishing_tasks" { @@ -78,6 +79,43 @@ resource "google_cloud_tasks_queue" "npm_tarball_build_tasks" { } } +resource "google_cloud_tasks_queue" "webhook_dispatches" { + name = local.webhook_dispatches_queue_name + location = "us-central1" + + retry_config { + max_attempts = 3 + min_backoff = "1s" + max_backoff = "60s" + } + + rate_limits { + max_concurrent_dispatches = 30 # this is bounded by Cloud Run invoke concurrency + } + + stackdriver_logging_config { + sampling_ratio = 1.0 + } + + lifecycle { + # Names of queues can't be reused for 7 days after deletion, so be careful! + prevent_destroy = true + } + + http_target { + uri_override { + host = trimprefix(google_cloud_run_v2_service.registry_api_tasks.uri, "https://") + path_override { + path = "/tasks/webhook_dispatch" + } + } + + oidc_token { + service_account_email = google_service_account.task_dispatcher.email + } + } +} + resource "google_service_account" "task_dispatcher" { account_id = "task-dispatcher" display_name = "service account used when dispatching tasks to Cloud Run" From 4b2adc8b7bf47239134c0363cd0a78ea0e2d8742 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 25 Dec 2025 13:18:41 +0100 Subject: [PATCH 02/17] work --- Cargo.lock | 1 + api/Cargo.toml | 1 + api/migrations/20251221141049_webhook.sql | 3 +- api/src/api/admin.rs | 4 + api/src/api/package.rs | 213 +++++++++++++++- api/src/api/scope.rs | 18 +- api/src/api/self_user.rs | 11 +- api/src/api/types.rs | 1 - api/src/config.rs | 7 + api/src/db/database.rs | 228 +++++++++++++++--- api/src/db/models.rs | 70 +++++- api/src/main.rs | 9 + api/src/publish.rs | 23 +- api/src/tasks.rs | 147 ++++++++++- deno.lock | 55 ++++- frontend/components/List.tsx | 2 +- frontend/islands/WebhookEdit.tsx | 131 ++++++++++ frontend/routes/@[scope]/~/settings/index.tsx | 50 +++- .../routes/@[scope]/~/settings/webhooks.tsx | 197 --------------- .../~/settings/webhooks/[webhook].tsx | 53 ++++ .../@[scope]/~/settings/webhooks/new.tsx | 106 ++++++++ .../{settings.tsx => settings/index.tsx} | 70 +++++- .../package/settings/webhooks/[webhook].tsx | 78 ++++++ .../routes/package/settings/webhooks/new.tsx | 69 ++++++ frontend/static/styles.css | 8 +- 25 files changed, 1281 insertions(+), 274 deletions(-) create mode 100644 frontend/islands/WebhookEdit.tsx delete mode 100644 frontend/routes/@[scope]/~/settings/webhooks.tsx create mode 100644 frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx create mode 100644 frontend/routes/@[scope]/~/settings/webhooks/new.tsx rename frontend/routes/package/{settings.tsx => settings/index.tsx} (85%) create mode 100644 frontend/routes/package/settings/webhooks/[webhook].tsx create mode 100644 frontend/routes/package/settings/webhooks/new.tsx diff --git a/Cargo.lock b/Cargo.lock index 401c23d1d..6600e4b9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3235,6 +3235,7 @@ dependencies = [ "flate2", "futures", "handlebars 5.1.2", + "hmac", "hyper", "indexmap 2.5.0", "infer", diff --git a/api/Cargo.toml b/api/Cargo.toml index fc50f086e..4b14eef9c 100644 --- a/api/Cargo.toml +++ b/api/Cargo.toml @@ -34,6 +34,7 @@ once_cell = "1" percent-encoding = "2" rand = "0.8" sha2 = "0.10.7" +hmac = "0.12.1" crc32fast = "1.3.2" routerify = "3" routerify-query = "3" diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql index 88ea8aea3..060adb9f9 100644 --- a/api/migrations/20251221141049_webhook.sql +++ b/api/migrations/20251221141049_webhook.sql @@ -37,7 +37,6 @@ CREATE TABLE webhook_events ( package TEXT, event webhook_event_kind NOT NULL, payload JSONB NOT NULL, - idempotency_key TEXT UNIQUE, created_at timestamptz NOT NULL DEFAULT now(), FOREIGN KEY (scope, package) REFERENCES packages (scope, name) ON DELETE CASCADE @@ -51,7 +50,7 @@ CREATE TABLE webhook_deliveries ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), endpoint_id UUID NOT NULL REFERENCES webhook_endpoints (id) ON DELETE CASCADE, event_id UUID NOT NULL REFERENCES webhook_events(id) ON DELETE CASCADE, - status webhook_delivery_status NOT NULL, + status webhook_delivery_status NOT NULL DEFAULT 'pending', request_headers JSONB, diff --git a/api/src/api/admin.rs b/api/src/api/admin.rs index ba7551526..4f3dcd77c 100644 --- a/api/src/api/admin.rs +++ b/api/src/api/admin.rs @@ -17,6 +17,7 @@ use crate::db::*; use crate::iam::ReqIamExt; use crate::ids::ScopeDescription; use crate::publish::publish_task; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::RequestIdExt; @@ -296,6 +297,8 @@ pub async fn requeue_publishing_tasks(req: Request) -> ApiResult<()> { } let publish_queue = req.data::().unwrap().0.clone(); + let webhook_dispatch_queue = + req.data::().unwrap().clone(); let orama_client = req.data::>().unwrap().clone(); if let Some(queue) = publish_queue { @@ -313,6 +316,7 @@ pub async fn requeue_publishing_tasks(req: Request) -> ApiResult<()> { registry, npm_url, db, + webhook_dispatch_queue, orama_client, ) .instrument(span); diff --git a/api/src/api/package.rs b/api/src/api/package.rs index 3a8961eb8..5dc221764 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -60,6 +60,7 @@ use crate::db::CreatePublishingTaskResult; use crate::db::Database; use crate::db::NewGithubRepository; use crate::db::NewPublishingTask; +use crate::db::NewWebhookEndpoint; use crate::db::Package; use crate::db::RuntimeCompat; use crate::db::User; @@ -81,6 +82,7 @@ use crate::orama::OramaClient; use crate::provenance; use crate::publish::publish_task; use crate::tarball::gcs_tarball_path; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::CacheDuration; @@ -91,6 +93,7 @@ use crate::util::pagination; use crate::util::search; use super::ApiCreatePackageRequest; +use super::ApiCreateWebhookEndpointRequest; use super::ApiDependency; use super::ApiDependencyGraphItem; use super::ApiDependent; @@ -115,6 +118,7 @@ use super::ApiStats; use super::ApiUpdatePackageGithubRepositoryRequest; use super::ApiUpdatePackageRequest; use super::ApiUpdatePackageVersionRequest; +use super::ApiWebhookEndpoint; const MAX_PUBLISH_TARBALL_SIZE: u64 = 20 * 1024 * 1024; // 20mb @@ -191,6 +195,22 @@ pub fn package_router() -> Router { util::json(list_publishing_tasks_handler), ) .get("/:package/score", util::json(get_score_handler)) + .post( + "/:package/webhooks", + util::auth(util::json(create_webhook_handler)), + ) + .get( + " /:package/webhooks/:webhook", + util::auth(util::json(get_webhook_handler)), + ) + .get( + "/:package/webhooks", + util::auth(util::json(list_webhooks_handler)), + ) + .delete( + "/:package/webhooks/:webhook", + util::auth(delete_webhook_handler), + ) .build() .unwrap() } @@ -297,14 +317,18 @@ pub async fn create_handler(mut req: Request) -> ApiResult { iam.check_scope_write_access(&scope).await?; let db = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); if db.check_is_bad_word(&package_name.to_string()).await? { return Err(ApiError::PackageNameNotAllowed); } let res = db.create_package(&scope, &package_name).await?; - let package = match res { - CreatePackageResult::Ok(package) => package, + let (package, webhook_deliveries) = match res { + CreatePackageResult::Ok { + package, + webhook_deliveries, + } => (package, webhook_deliveries), CreatePackageResult::AlreadyExists => { return Err(ApiError::PackageAlreadyExists); } @@ -316,6 +340,13 @@ pub async fn create_handler(mut req: Request) -> ApiResult { } }; + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + webhook_deliveries, + ) + .await?; + let orama_client = req.data::>().unwrap(); if let Some(orama_client) = orama_client { orama_client.upsert_package(&package, &Default::default()); @@ -470,7 +501,7 @@ pub async fn update_handler(mut req: Request) -> ApiResult { Ok(ApiPackage::from((package, repo, meta))) } ApiUpdatePackageRequest::IsArchived(is_archived) => { - let package = db + let (package, webhook_deliveries) = db .update_package_is_archived( &user.id, sudo, @@ -480,6 +511,15 @@ pub async fn update_handler(mut req: Request) -> ApiResult { ) .await?; + let webhook_dispatch_queue = req.data::().unwrap(); + + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + webhook_deliveries, + ) + .await?; + if let Some(orama_client) = orama_client { if package.is_archived { orama_client.delete_package(&scope, &package.name); @@ -795,6 +835,8 @@ pub async fn version_publish_handler( let registry_url = req.data::().unwrap().0.clone(); let npm_url = req.data::().unwrap().0.clone(); let publish_queue = req.data::().unwrap().0.clone(); + let webhook_dispatch_queue = + req.data::().unwrap().clone(); let orama_client = req.data::>().unwrap().clone(); let iam = req.iam(); @@ -902,6 +944,7 @@ pub async fn version_publish_handler( registry_url, npm_url, db, + webhook_dispatch_queue, orama_client, ) .instrument(span); @@ -981,17 +1024,26 @@ pub async fn version_update_handler( let db = req.data::().unwrap(); let buckets = req.data::().unwrap().clone(); let npm_url = &req.data::().unwrap().0; + let webhook_dispatch_queue = req.data::().unwrap(); let iam = req.iam(); let (user, sudo) = iam.check_scope_admin_access(&scope).await?; - db.yank_package_version( - &user.id, - sudo, - &scope, - &package, - &version, - body.yanked, + let (_, webhook_deliveries) = db + .yank_package_version( + &user.id, + sudo, + &scope, + &package, + &version, + body.yanked, + ) + .await?; + + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + webhook_deliveries, ) .await?; @@ -1073,9 +1125,18 @@ pub async fn version_delete_handler( return Err(ApiError::DeleteVersionHasDependents); } - db.delete_package_version(&staff.id, &scope, &package, &version) + let webhook_deliveries = db + .delete_package_version(&staff.id, &scope, &package, &version) .await?; + let webhook_dispatch_queue = req.data::().unwrap(); + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + webhook_deliveries, + ) + .await?; + let path = crate::gcs_paths::docs_v1_path(&scope, &package, &version); buckets.docs_bucket.delete_file(path.into()).await?; @@ -2372,6 +2433,136 @@ pub async fn get_score_handler( Ok(ApiPackageScore::from((&meta, &pkg))) } +#[instrument( + name = "POST /api/scopes/:scope/packages/:package/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn create_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + Span::current().record("scope", field::display(&scope)); + + let ApiCreateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .create_webhook_endpoint( + NewWebhookEndpoint { + scope: &scope, + package: Some(&package), + url: &url, + description: description.as_deref(), + secret: &secret, + events, + payload_format, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .get_webhook_endpoint(&scope, Some(&package), webhook_id) + .await?; + + Ok(webhook_endpoint.into()) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhooks_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = + db.list_webhook_endpoints(&scope, Some(&package)).await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "DELETE /api/scopes/:scope/packages/:package/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn delete_webhook_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + db.delete_webhook_endpoint( + &user.id, + sudo, + &scope, + Some(&package), + webhook_id, + ) + .await?; + + let res = Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap(); + Ok(res) +} + #[cfg(test)] mod test { use hyper::Body; diff --git a/api/src/api/scope.rs b/api/src/api/scope.rs index 8a0afe525..0b08cd592 100644 --- a/api/src/api/scope.rs +++ b/api/src/api/scope.rs @@ -25,6 +25,7 @@ use super::types::*; use crate::auth::GithubOauth2Client; use crate::auth::lookup_user_by_github_login; use crate::db::*; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::RequestIdExt; @@ -390,7 +391,7 @@ async fn update_member_handler( .await?; let scope_member = match res { - ScopeMemberUpdateResult::Ok(scope_member) => scope_member, + ScopeMemberUpdateResult::Ok { scope_member, .. } => scope_member, ScopeMemberUpdateResult::TargetIsLastTransferableAdmin => { return Err(ApiError::NoScopeOwnerAvailable); } @@ -428,6 +429,7 @@ pub async fn delete_member_handler( Span::current().record("member", field::display(&member_id)); let db = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); db.get_scope(&scope).await?.ok_or(ApiError::ScopeNotFound)?; @@ -438,7 +440,16 @@ pub async fn delete_member_handler( let res = db.delete_scope_member(&scope, member_id).await?; match res { - ScopeMemberUpdateResult::Ok(_) => {} + ScopeMemberUpdateResult::Ok { + webhook_deliveries, .. + } => { + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + webhook_deliveries.unwrap(), + ) + .await?; + } ScopeMemberUpdateResult::TargetIsLastTransferableAdmin => { return Err(ApiError::NoScopeOwnerAvailable); } @@ -530,7 +541,6 @@ pub async fn create_webhook_handler( Span::current().record("scope", field::display(&scope)); let ApiCreateWebhookEndpointRequest { - package, url, description, secret, @@ -547,7 +557,7 @@ pub async fn create_webhook_handler( .create_webhook_endpoint( NewWebhookEndpoint { scope: &scope, - package: package.as_ref(), + package: None, url: &url, description: description.as_deref(), secret: &secret, diff --git a/api/src/api/self_user.rs b/api/src/api/self_user.rs index b7244fc39..8187b98aa 100644 --- a/api/src/api/self_user.rs +++ b/api/src/api/self_user.rs @@ -20,6 +20,7 @@ use crate::db::UserPublic; use crate::emails::EmailArgs; use crate::emails::EmailSender; use crate::iam::ReqIamExt; +use crate::tasks::WebhookDispatchQueue; use crate::util; use crate::util::ApiResult; use crate::util::RequestIdExt; @@ -134,12 +135,20 @@ pub async fn accept_invite_handler( let current_user = iam.check_current_user_access()?.to_owned(); let db = req.data::().unwrap(); + let webhook_dispatch_queue = req.data::().unwrap(); - let member = db + let (member, webhook_deliveries) = db .accept_scope_invite(¤t_user.id, &scope) .await? .ok_or(ApiError::ScopeInviteNotFound)?; + crate::tasks::enqueue_webhook_dispatches( + webhook_dispatch_queue, + db, + webhook_deliveries, + ) + .await?; + Ok((member, UserPublic::from(current_user)).into()) } diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 0e5d10d32..2ba695c3f 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1153,7 +1153,6 @@ impl From<(AuditLog, UserPublic)> for ApiAuditLog { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiCreateWebhookEndpointRequest { - pub package: Option, pub url: String, pub description: Option, pub secret: String, diff --git a/api/src/config.rs b/api/src/config.rs index 0abdec7fb..8c61cf00c 100644 --- a/api/src/config.rs +++ b/api/src/config.rs @@ -134,6 +134,13 @@ pub struct Config { /// The ID of the npm tarball build queue. pub npm_tarball_build_queue_id: Option, + #[clap( + long = "webhook_dispatch_queue_id", + env = "WEBHOOK_DISPATCH_QUEUE_ID" + )] + /// The ID of the npm tarball build queue. + pub webhook_dispatch_queue_id: Option, + #[clap(long = "logs_bigquery_table_id", env = "LOGS_BIGQUERY_TABLE_ID")] /// The ID of the logs table in BigQuery that is used for download analysis. pub logs_bigquery_table_id: Option, diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 6da6de7e0..79e8bb45a 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -565,11 +565,7 @@ impl Database { } }; - if let Some(res) = finalize_package_creation(tx, scope).await? { - return Ok(res); - }; - - Ok(CreatePackageResult::Ok(package)) + finalize_package_creation(tx, scope, package).await } #[instrument( @@ -886,7 +882,7 @@ impl Database { scope: &ScopeName, name: &PackageName, is_archived: bool, - ) -> Result { + ) -> Result<(Package, Vec)> { let mut tx = self.pool.begin().await?; audit_log( @@ -917,9 +913,18 @@ impl Database { .fetch_one(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopePackageArchived, + WebhookPayload::ScopePackageArchived {}, + ) + .await?; + tx.commit().await?; - Ok(package) + Ok((package, webhook_deliveries)) } #[instrument(name = "Database::update_package_source", skip(self), err)] @@ -1951,8 +1956,8 @@ impl Database { scope as _, name as _, ) - .fetch_all(&self.pool) - .await + .fetch_all(&self.pool) + .await } #[instrument( @@ -2225,7 +2230,7 @@ impl Database { name: &PackageName, version: &Version, yank: bool, - ) -> Result { + ) -> Result<(PackageVersion, Vec)> { let mut tx = self.pool.begin().await?; audit_log( @@ -2268,9 +2273,18 @@ impl Database { .fetch_one(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + Some(name), + WebhookEventKind::PackageVersionYanked, + WebhookPayload::PackageVersionYanked {}, + ) + .await?; + tx.commit().await?; - Ok(package_version) + Ok((package_version, webhook_deliveries)) } #[instrument(name = "Database::delete_package_version", skip(self), err)] @@ -2280,7 +2294,7 @@ impl Database { scope: &ScopeName, name: &PackageName, version: &Version, - ) -> Result<()> { + ) -> Result> { let mut tx = self.pool.begin().await?; audit_log( @@ -2296,8 +2310,7 @@ impl Database { ) .await?; - sqlx::query_as!( - PackageVersion, + sqlx::query!( r#"DELETE FROM package_versions WHERE scope = $1 AND name = $2 AND version = $3"#, scope as _, name as _, @@ -2306,9 +2319,18 @@ impl Database { .execute(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + Some(name), + WebhookEventKind::PackageVersionDeleted, + WebhookPayload::PackageVersionDeleted {}, + ) + .await?; + tx.commit().await?; - Ok(()) + Ok(webhook_deliveries) } #[instrument(name = "Database::get_package_file", skip(self), err)] @@ -2548,6 +2570,7 @@ impl Database { Ok(scope_invite) } + #[cfg(test)] #[instrument(name = "Database::add_user_to_scope", skip( self, new_scope_member @@ -2665,7 +2688,7 @@ impl Database { &self, target_user_id: &Uuid, scope: &ScopeName, - ) -> Result> { + ) -> Result)>> { let mut tx = self.pool.begin().await?; let res = sqlx::query!( @@ -2689,9 +2712,18 @@ impl Database { .fetch_one(&mut *tx) .await?; + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopeMemberAdded, + WebhookPayload::ScopeMemberAdded {}, + ) + .await?; + tx.commit().await?; - Ok(Some(member)) + Ok(Some((member, webhook_deliveries))) } #[instrument(name = "Database::delete_scope_invite", skip(self), err)] @@ -2952,7 +2984,10 @@ impl Database { tx.commit().await?; - Ok(ScopeMemberUpdateResult::Ok(scope_member)) + Ok(ScopeMemberUpdateResult::Ok { + scope_member, + webhook_deliveries: None, + }) } #[instrument(name = "Database::delete_scope_member", skip(self), err)] @@ -2994,9 +3029,24 @@ impl Database { return Ok(result); } + let webhook_deliveries = insert_webhook_event( + &mut tx, + &scope, + None, + WebhookEventKind::ScopeMemberLeft, + WebhookPayload::ScopeMemberLeft { + scope: scope.clone(), + user_id, + }, + ) + .await?; + tx.commit().await?; - Ok(ScopeMemberUpdateResult::Ok(scope_member)) + Ok(ScopeMemberUpdateResult::Ok { + scope_member, + webhook_deliveries: Some(webhook_deliveries), + }) } #[instrument( @@ -3435,6 +3485,31 @@ impl Database { Ok(task) } + #[instrument( + name = "Database::process_webhooks_for_publish", + skip(self), + err + )] + pub async fn process_webhooks_for_publish( + &self, + scope: &ScopeName, + name: &PackageName, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + Some(name), + WebhookEventKind::PackageVersionPublished, + WebhookPayload::PackageVersionPublished {}, + ) + .await?; + + tx.commit().await?; + + Ok(webhook_deliveries) + } #[instrument(name = "Database::get_oauth_state", skip(self), err)] pub async fn get_oauth_state( @@ -5096,12 +5171,58 @@ impl Database { Ok(()) } + + #[instrument(name = "Database::get_webhook_delivery", skip(self), err)] + pub async fn get_webhook_for_dispatch( + &self, + id: Uuid, + ) -> Result { + sqlx::query_as!( + WebhookForDispatch, + r#"SELECT + webhook_endpoints.url as "url", webhook_events.event as "event: _", webhook_events.id as "event_id", webhook_endpoints.secret as "secret", webhook_endpoints.payload_format AS "payload_format: _", webhook_events.payload as "payload: WebhookPayload" + FROM webhook_endpoints + LEFT JOIN webhook_deliveries ON webhook_endpoints.id = webhook_deliveries.endpoint_id + LEFT JOIN webhook_events ON webhook_events.id = webhook_deliveries.event_id + WHERE webhook_deliveries.id = $1"#, + id, + ) + .fetch_one(&self.pool) + .await + } + + #[instrument(name = "Database::update_webhook_delivery", skip(self), err)] + pub async fn update_webhook_delivery( + &self, + id: Uuid, + status: WebhookDeliveryStatus, + request_headers: serde_json::Value, + response_http_code: i32, + response_headers: serde_json::Value, + response_body: String, + ) -> Result<()> { + sqlx::query!( + r#"UPDATE webhook_deliveries + SET status = $2, request_headers = $3, response_http_code = $4, response_headers = $5, response_body = $6 + WHERE id = $1"#, + id, + status as _, + request_headers, + response_http_code, + response_headers, + response_body, + ) + .execute(&self.pool) + .await?; + Ok(()) + } } async fn finalize_package_creation( mut tx: sqlx::Transaction<'_, sqlx::Postgres>, scope: &ScopeName, -) -> Result, sqlx::Error> { + package: Package, +) -> Result { let (package_limit, new_package_per_week_limit) = sqlx::query!( r#" SELECT package_limit, new_package_per_week_limit FROM scopes WHERE scope = $1; @@ -5128,9 +5249,9 @@ async fn finalize_package_creation( if packages_from_last_week > new_package_per_week_limit as i64 { tx.rollback().await?; - return Ok(Some(CreatePackageResult::WeeklyPackageLimitExceeded( + return Ok(CreatePackageResult::WeeklyPackageLimitExceeded( new_package_per_week_limit, - ))); + )); } let total_packages = sqlx::query!( @@ -5145,13 +5266,23 @@ async fn finalize_package_creation( if total_packages > package_limit as i64 { tx.rollback().await?; - return Ok(Some(CreatePackageResult::PackageLimitExceeded( - package_limit, - ))); + return Ok(CreatePackageResult::PackageLimitExceeded(package_limit)); } + let webhook_deliveries = insert_webhook_event( + &mut tx, + scope, + None, + WebhookEventKind::ScopePackageCreated, + WebhookPayload::ScopePackageCreated {}, + ) + .await?; + tx.commit().await?; - Ok(None) + Ok(CreatePackageResult::Ok { + package, + webhook_deliveries, + }) } async fn audit_log( @@ -5174,9 +5305,47 @@ async fn audit_log( Ok(()) } +pub async fn insert_webhook_event( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + scope: &ScopeName, + package: Option<&PackageName>, + event: WebhookEventKind, + payload: WebhookPayload, +) -> Result> { + let event_id = sqlx::query!( + r#"INSERT INTO webhook_events (scope, package, event, payload) VALUES ($1, $2, $3, $4) RETURNING id"#, + scope as _, + package as _, + event as _, + payload as _, + ) + .map(|r| r.id) + .fetch_one(&mut **tx) + .await?; + + let webhook_deliveries = sqlx::query!( + r#"INSERT INTO webhook_deliveries (endpoint_id, event_id) + SELECT webhook_endpoints.id, $1 FROM webhook_endpoints + WHERE webhook_endpoints.scope = $2 AND (webhook_endpoints.package IS NULL OR webhook_endpoints.package = $3) AND $4 = ANY(webhook_endpoints.events) AND webhook_endpoints.is_active = TRUE + RETURNING id"#, + event_id, + scope as _, + package as _, + event as _, + ) + .map(|r| r.id) + .fetch_all(&mut **tx) + .await?; + + Ok(webhook_deliveries) +} + #[derive(Debug)] pub enum ScopeMemberUpdateResult { - Ok(ScopeMember), + Ok { + scope_member: ScopeMember, + webhook_deliveries: Option>, + }, TargetIsLastAdmin, TargetIsLastTransferableAdmin, TargetNotMember, @@ -5184,7 +5353,10 @@ pub enum ScopeMemberUpdateResult { #[derive(Debug)] pub enum CreatePackageResult { - Ok(Package), + Ok { + package: Package, + webhook_deliveries: Vec, + }, AlreadyExists, WeeklyPackageLimitExceeded(i32), PackageLimitExceeded(i32), diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 36746c3a0..cdcfb482e 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -1054,6 +1054,7 @@ pub enum WebhookEventKind { ScopePackageArchived, ScopeMemberAdded, ScopeMemberLeft, + // todo: package delete } impl sqlx::postgres::PgHasArrayType for WebhookEventKind { @@ -1122,7 +1123,7 @@ impl FromRow<'_, sqlx::postgres::PgRow> for WebhookEndpoint { pub struct NewWebhookEndpoint<'s> { pub scope: &'s ScopeName, - pub package: Option<&'s ScopeName>, + pub package: Option<&'s PackageName>, pub url: &'s str, pub description: Option<&'s str>, pub secret: &'s str, @@ -1130,14 +1131,67 @@ pub struct NewWebhookEndpoint<'s> { pub payload_format: WebhookPayloadFormat, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WebhookPayload { + PackageVersionPublished {}, + PackageVersionYanked {}, + PackageVersionDeleted {}, + ScopePackageCreated {}, + ScopePackageArchived {}, + ScopeMemberAdded {}, + ScopeMemberLeft { scope: ScopeName, user_id: Uuid }, +} + +impl WebhookPayload { + pub fn discord_format(self) -> Result { + match self { + WebhookPayload::PackageVersionPublished { .. } => todo!(), + WebhookPayload::PackageVersionYanked { .. } => todo!(), + WebhookPayload::PackageVersionDeleted { .. } => todo!(), + WebhookPayload::ScopePackageCreated { .. } => todo!(), + WebhookPayload::ScopePackageArchived { .. } => todo!(), + WebhookPayload::ScopeMemberAdded { .. } => todo!(), + WebhookPayload::ScopeMemberLeft { .. } => todo!(), + } + } +} + +impl sqlx::Decode<'_, sqlx::Postgres> for WebhookPayload { + fn decode( + value: sqlx::postgres::PgValueRef<'_>, + ) -> Result> { + let s: sqlx::types::Json = + sqlx::Decode::<'_, sqlx::Postgres>::decode(value)?; + Ok(s.0) + } +} + +impl<'q> sqlx::Encode<'q, sqlx::Postgres> for WebhookPayload { + fn encode_by_ref( + &self, + buf: &mut >::ArgumentBuffer, + ) -> sqlx::encode::IsNull { + as sqlx::Encode< + '_, + sqlx::Postgres, + >>::encode_by_ref(&Json(self), buf) + } +} + +impl sqlx::Type for WebhookPayload { + fn type_info() -> ::TypeInfo { + as sqlx::Type>::type_info( + ) + } +} + #[derive(Debug, Clone)] pub struct WebhookEvent { pub id: Uuid, pub scope: ScopeName, pub package: Option, pub event: WebhookEventKind, - pub payload: serde_json::Value, - pub idempotency_key: Option, + pub payload: WebhookPayload, pub created_at: DateTime, } @@ -1166,3 +1220,13 @@ pub struct WebhookDelivery { pub updated_at: DateTime, pub created_at: DateTime, } + +#[derive(Debug, Clone)] +pub struct WebhookForDispatch { + pub url: String, + pub event: WebhookEventKind, + pub event_id: Uuid, + pub payload_format: WebhookPayloadFormat, + pub secret: Option, + pub payload: WebhookPayload, +} diff --git a/api/src/main.rs b/api/src/main.rs index 1762e7271..33a80ea24 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -44,6 +44,7 @@ use crate::sitemap::packages_sitemap_handler; use crate::sitemap::scopes_sitemap_handler; use crate::sitemap::sitemap_index_handler; use crate::tasks::NpmTarballBuildQueue; +use crate::tasks::WebhookDispatchQueue; use crate::tasks::tasks_router; use crate::traced_router::TracedRouterService; use crate::tracing::TracingExportTarget; @@ -68,6 +69,7 @@ pub struct MainRouterOptions { npm_url: Url, publish_queue: Option, npm_tarball_build_queue: Option, + webhook_dispatch_queue: Option, logs_bigquery_table: Option<(gcp::BigQuery, /* logs_table_id */ String)>, expose_api: bool, expose_tasks: bool, @@ -87,6 +89,7 @@ pub(crate) fn main_router( npm_url, publish_queue, npm_tarball_build_queue, + webhook_dispatch_queue, logs_bigquery_table, expose_api, expose_tasks, @@ -102,6 +105,7 @@ pub(crate) fn main_router( .data(NpmUrl(npm_url)) .data(PublishQueue(publish_queue)) .data(NpmTarballBuildQueue(npm_tarball_build_queue)) + .data(WebhookDispatchQueue(webhook_dispatch_queue)) .data(LogsBigQueryTable(logs_bigquery_table)) .middleware(routerify_query::query_parser()) .err_handler_with_info(error_handler); @@ -187,6 +191,10 @@ async fn main() { .npm_tarball_build_queue_id .map(|id: String| Queue::new(gcp_client.clone(), id, None)); + let webhook_dispatch_queue = config + .webhook_dispatch_queue_id + .map(|id: String| Queue::new(gcp_client.clone(), id, None)); + let logs_bigquery_table = config.logs_bigquery_table_id.map(|logs_table_id| { ( @@ -256,6 +264,7 @@ async fn main() { npm_url: config.npm_url, publish_queue, npm_tarball_build_queue, + webhook_dispatch_queue, logs_bigquery_table, expose_api: config.api, expose_tasks: config.tasks, diff --git a/api/src/publish.rs b/api/src/publish.rs index 1a81a5c21..e594447d3 100644 --- a/api/src/publish.rs +++ b/api/src/publish.rs @@ -31,6 +31,7 @@ use crate::orama::OramaClient; use crate::tarball::NpmTarballInfo; use crate::tarball::ProcessTarballOutput; use crate::tarball::process_tarball; +use crate::tasks::WebhookDispatchQueue; use crate::util::ApiResult; use crate::util::decode_json; use deno_semver::package::PackageReqReference; @@ -57,6 +58,8 @@ pub async fn publish_handler(mut req: Request) -> ApiResult<()> { let orama_client = req.data::>().unwrap().clone(); let registry_url = req.data::().unwrap().0.clone(); let npm_url = req.data::().unwrap().0.clone(); + let webhook_dispatch_queue = + req.data::().unwrap().clone(); publish_task( publishing_task_id, @@ -64,6 +67,7 @@ pub async fn publish_handler(mut req: Request) -> ApiResult<()> { registry_url, npm_url, db, + webhook_dispatch_queue, orama_client, ) .await?; @@ -73,7 +77,7 @@ pub async fn publish_handler(mut req: Request) -> ApiResult<()> { #[instrument( name = "publish_task", - skip(buckets, db, registry_url, orama_client), + skip(buckets, db, registry_url, webhook_dispatch_queue, orama_client), err )] pub async fn publish_task( @@ -82,6 +86,7 @@ pub async fn publish_task( registry_url: Url, npm_url: Url, db: Database, + webhook_dispatch_queue: WebhookDispatchQueue, orama_client: Option, ) -> Result<(), ApiError> { let (mut publishing_task, _) = db @@ -137,6 +142,20 @@ pub async fn publish_task( } PublishingTaskStatus::Failure => return Ok(()), PublishingTaskStatus::Success => { + let webhook_deliveries = db + .process_webhooks_for_publish( + &publishing_task.package_scope, + &publishing_task.package_name, + ) + .await?; + + crate::tasks::enqueue_webhook_dispatches( + &webhook_dispatch_queue, + &db, + webhook_deliveries, + ) + .await?; + if let Some(orama_client) = orama_client { let (package, _, meta) = db .get_package( @@ -483,7 +502,7 @@ pub mod tests { .await .unwrap(); assert!( - matches!(res, CreatePackageResult::Ok(_)) + matches!(res, CreatePackageResult::Ok { .. }) || matches!(res, CreatePackageResult::AlreadyExists) ); diff --git a/api/src/tasks.rs b/api/src/tasks.rs index 81d888c23..f970c071f 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -8,10 +8,10 @@ use crate::analysis::rebuild_npm_tarball; use crate::api::ApiError; use crate::buckets::Buckets; use crate::buckets::UploadTaskBody; -use crate::db::Database; use crate::db::DownloadKind; use crate::db::NewNpmTarball; use crate::db::VersionDownloadCount; +use crate::db::{Database, WebhookPayloadFormat}; use crate::gcp; use crate::gcp::CACHE_CONTROL_DO_NOT_CACHE; use crate::gcp::CACHE_CONTROL_IMMUTABLE; @@ -25,6 +25,7 @@ use crate::npm::generate_npm_version_manifest; use crate::publish; use crate::util; use crate::util::ApiResult; +use crate::util::USER_AGENT; use crate::util::decode_json; use bytes::Bytes; use chrono::DateTime; @@ -35,9 +36,14 @@ use deno_semver::package::PackageReq; use deno_semver::package::PackageReqReference; use deno_semver::package::PackageSubPath; use futures::StreamExt; +use futures::TryFutureExt; +use futures::future::FutureExt; use futures::stream; +use hmac::Mac; use hyper::Body; use hyper::Request; +use opentelemetry::trace::TraceContextExt; +use reqwest::header::HeaderValue; use routerify::Router; use routerify::ext::RequestExt; use routerify_query::RequestQueryExt; @@ -48,9 +54,13 @@ use tracing::Span; use tracing::error; use tracing::field; use tracing::instrument; +use tracing_futures::Instrument; +use tracing_opentelemetry::OpenTelemetrySpanExt; use uuid::Uuid; pub struct NpmTarballBuildQueue(pub Option); +#[derive(Clone)] +pub struct WebhookDispatchQueue(pub Option); pub struct LogsBigQueryTable( pub Option<(gcp::BigQuery, /* logs table id */ String)>, ); @@ -459,9 +469,144 @@ pub async fn webhook_dispatch_handler(mut req: Request) -> ApiResult<()> { let webhook_dispatch_id: Uuid = decode_json(&mut req).await?; let db = req.data::().unwrap(); + dispatch_webhook(db, webhook_dispatch_id).await?; + + Ok(()) +} + +const WEBHOOK_DISPATCH_ENQUEUE_PARALLELISM: usize = 32; + +#[instrument(name = "enqueue_webhook_dispatches", skip(queue, db), err)] +pub async fn enqueue_webhook_dispatches( + queue: &WebhookDispatchQueue, + db: &Database, + webhook_dispatch_ids: Vec, +) -> ApiResult<()> { + let mut futs = stream::iter(webhook_dispatch_ids) + .map(|webhook_dispatch_id| { + if let Some(queue) = &queue.0 { + let body = serde_json::to_vec(&webhook_dispatch_id).unwrap(); + queue.task_buffer(None, Some(body.into())).boxed() + } else { + let span = Span::current(); + let fut = dispatch_webhook(db, webhook_dispatch_id) + .instrument(span) + .map_err(anyhow::Error::from); + fut.boxed() + } + }) + .buffer_unordered(WEBHOOK_DISPATCH_ENQUEUE_PARALLELISM); + + while let Some(result) = futs.next().await { + result?; + } + Ok(()) } +#[instrument(name = "dispatch_webhook", skip(db), err)] +async fn dispatch_webhook( + db: &Database, + webhook_dispatch_id: Uuid, +) -> ApiResult<()> { + let webhook = db.get_webhook_for_dispatch(webhook_dispatch_id).await?; + + let (json, signature) = match webhook.payload_format { + WebhookPayloadFormat::Json => { + let json = serde_json::to_value(webhook.payload)?; + let signature = if let Some(secret) = webhook.secret { + let mut hmac = + hmac::Hmac::::new_from_slice(secret.as_bytes()) + .unwrap(); + hmac.update(&serde_json::to_vec(&json)?); + let hash = hmac.finalize().into_bytes(); + Some(format!("sha256={:02x}", hash)) + } else { + None + }; + + (json, signature) + } + WebhookPayloadFormat::Discord => (webhook.payload.discord_format()?, None), + }; + + let mut headers = reqwest::header::HeaderMap::new(); + + headers.insert( + "X-JSR-Event", + serde_json::to_string(&webhook.event)?.parse().unwrap(), + ); + headers.insert( + "X-JSR-Event-Id", + webhook.event_id.to_string().parse().unwrap(), + ); + headers.insert( + reqwest::header::USER_AGENT, + HeaderValue::from_static(USER_AGENT), + ); + headers.insert( + reqwest::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + + let span = Span::current(); + let ctx = span.context(); + let span_ref = ctx.span(); + let span_ctx = span_ref.span_context(); + let trace_id = span_ctx.trace_id().to_string(); + headers.insert("x-deno-ray", trace_id.parse().unwrap()); + + if let Some(signature) = signature { + headers.insert("X-JSR-Signature", signature.parse().unwrap()); + } + + let request_headers = serde_json::to_value(headers_to_map(&headers))?; + + let response = reqwest::Client::new() + .post(webhook.url) + .headers(headers) + .json(&json) + .send() + .await + .map_err(anyhow::Error::from)?; + + let success = response.status().is_success(); + let response_http_status = response.status().as_u16() as i32; + let response_headers = + serde_json::to_value(headers_to_map(response.headers()))?; + let response_body = response.text().await.map_err(anyhow::Error::from)?; + + db.update_webhook_delivery( + webhook_dispatch_id, + if success { + crate::db::models::WebhookDeliveryStatus::Success + } else { + todo!() + }, + request_headers, + response_http_status, + response_headers, + response_body, + ) + .await?; + + if !success {} + + Ok(()) +} + +fn headers_to_map( + headers: &reqwest::header::HeaderMap, +) -> std::collections::HashMap> { + let mut header_hashmap = std::collections::HashMap::new(); + for (k, v) in headers { + let k = k.as_str().to_owned(); + let v = String::from_utf8_lossy(v.as_bytes()).into_owned(); + header_hashmap.entry(k).or_insert_with(Vec::new).push(v) + } + header_hashmap +} + #[cfg(test)] mod tests { use chrono::DateTime; diff --git a/deno.lock b/deno.lock index e34fc271c..c7d9b2c5c 100644 --- a/deno.lock +++ b/deno.lock @@ -4,7 +4,9 @@ "jsr:@deno/cache-dir@0.14": "0.14.0", "jsr:@deno/doc@0.183": "0.183.0", "jsr:@deno/gfm@0.10": "0.10.0", + "jsr:@deno/sandbox@*": "0.4.4", "jsr:@denosaurs/emoji@0.3": "0.3.1", + "jsr:@std/async@^1.0.15": "1.0.15", "jsr:@std/async@^1.0.8": "1.0.9", "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/bytes@^1.0.6": "1.0.6", @@ -15,12 +17,18 @@ "jsr:@std/fmt@^1.0.3": "1.0.8", "jsr:@std/fmt@^1.0.8": "1.0.8", "jsr:@std/front-matter@^1.0.5": "1.0.5", + "jsr:@std/fs@^1.0.19": "1.0.19", "jsr:@std/fs@^1.0.6": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/internal@^1.0.9": "1.0.12", "jsr:@std/io@0.225": "0.225.2", "jsr:@std/path@^1.0.8": "1.0.8", + "jsr:@std/path@^1.1.1": "1.1.3", + "jsr:@std/path@^1.1.2": "1.1.3", "jsr:@std/toml@^1.0.1": "1.0.2", "jsr:@std/yaml@^1.0.5": "1.0.5", "npm:@mdn/browser-compat-data@^7.1.6": "7.1.6", + "npm:devalue@5.3.2": "5.3.2", "npm:github-slugger@2": "2.0.0", "npm:he@^1.2.0": "1.2.0", "npm:katex@0.16": "0.16.11", @@ -29,16 +37,18 @@ "npm:marked-gfm-heading-id@^3.1.0": "3.2.0_marked@12.0.2", "npm:marked@12": "12.0.2", "npm:prismjs@^1.29.0": "1.29.0", - "npm:sanitize-html@^2.13.0": "2.13.1" + "npm:sanitize-html@^2.13.0": "2.13.1", + "npm:ws@^8.18.3": "8.18.3", + "npm:zod@4.1.5": "4.1.5" }, "jsr": { "@deno/cache-dir@0.14.0": { "integrity": "729f0b68e7fc96443c09c2c544b830ca70897bdd5168598446d752f7a4c731ad", "dependencies": [ "jsr:@std/fmt@^1.0.3", - "jsr:@std/fs", + "jsr:@std/fs@^1.0.6", "jsr:@std/io", - "jsr:@std/path" + "jsr:@std/path@^1.0.8" ] }, "@deno/doc@0.183.0": { @@ -62,12 +72,27 @@ "npm:sanitize-html" ] }, + "@deno/sandbox@0.4.4": { + "integrity": "d3c243b35c2f3fa7915105211afa87dff27c15c2948a01f650c2926ca0333c23", + "dependencies": [ + "jsr:@std/async@^1.0.15", + "jsr:@std/encoding", + "jsr:@std/fs@^1.0.19", + "jsr:@std/path@^1.1.2", + "npm:devalue", + "npm:ws", + "npm:zod" + ] + }, "@denosaurs/emoji@0.3.1": { "integrity": "b0aed5f55dec99e83da7c9637fe0a36d1d6252b7c99deaaa3fc5dea3fcf3da8b" }, "@std/async@1.0.9": { "integrity": "c6472fd0623b3f3daae023cdf7ca5535e1b721dfbf376562c0c12b3fb4867f91" }, + "@std/async@1.0.15": { + "integrity": "55d1d9d04f99403fe5730ab16bdcc3c47f658a6bf054cafb38a50f046238116e" + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, @@ -94,7 +119,14 @@ ] }, "@std/fs@1.0.19": { - "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06" + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", + "dependencies": [ + "jsr:@std/internal@^1.0.9", + "jsr:@std/path@^1.1.1" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, "@std/io@0.225.2": { "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", @@ -105,6 +137,12 @@ "@std/path@1.0.8": { "integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be" }, + "@std/path@1.1.3": { + "integrity": "b015962d82a5e6daea980c32b82d2c40142149639968549c649031a230b1afb3", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/toml@1.0.2": { "integrity": "5892ba489c5b512265a384238a8fe8dddbbb9498b4b210ef1b9f0336a423a39b", "dependencies": [ @@ -125,6 +163,9 @@ "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, + "devalue@5.3.2": { + "integrity": "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==" + }, "dom-serializer@2.0.0": { "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "dependencies": [ @@ -239,6 +280,12 @@ }, "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "ws@8.18.3": { + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==" + }, + "zod@4.1.5": { + "integrity": "sha512-rcUUZqlLJgBC33IT3PNMgsCq6TzLQEG/Ei/KTCU0PedSWRMAXoOUN+4t/0H+Q8bdnLPdqUYnvboJT0bn/229qg==" } }, "remote": { diff --git a/frontend/components/List.tsx b/frontend/components/List.tsx index 12bfcbb26..c0be9c6e8 100644 --- a/frontend/components/List.tsx +++ b/frontend/components/List.tsx @@ -37,7 +37,7 @@ export function ListDisplay( > {item.content} - + ))} diff --git a/frontend/islands/WebhookEdit.tsx b/frontend/islands/WebhookEdit.tsx new file mode 100644 index 000000000..6463f9fa8 --- /dev/null +++ b/frontend/islands/WebhookEdit.tsx @@ -0,0 +1,131 @@ +import { WebhookEndpoint, WebhookEventKind } from "../utils/api_types.ts"; + +const events: Array<{ + id: WebhookEventKind; + name: string; + description: string; + packageLevel: boolean; +}> = [ + { + id: "package_version_published", + name: "Package version published", + description: "A new version of a package is published.", + packageLevel: true, + }, + { + id: "package_version_yanked", + name: "Package version yanked", + description: "A version of a package is yanked or unyanked.", + packageLevel: true, + }, + { + id: "package_version_deleted", + name: "Package version deleted", + description: "A version of a package is deleted.", + packageLevel: true, + }, + { + id: "scope_package_created", + name: "Scope package created", + description: "A new package is created in the scope.", + packageLevel: false, + }, + { + id: "scope_package_archived", + name: "Scope package archived", + description: "A package in the scope is archived or unarchived.", + packageLevel: false, + }, + { + id: "scope_member_added", + name: "Scope member added", + description: "A new member is added to the scope.", + packageLevel: false, + }, + { + id: "scope_member_left", + name: "Scope member left", + description: "A member leaves the scope.", + packageLevel: false, + }, +] + +function Required() { + return * +} + +export function WebhookEdit({ webhook, packageLevel }: { webhook: WebhookEndpoint | null, packageLevel?: boolean }) { + return ( +
+
+
+ + + + +
+
+

Events

+
+ {events.filter((event) => { + if (packageLevel) { + return event.packageLevel; + } else { + return true; + } + }).map((event) => )} +
+
+
+
+ {webhook && } + + +
+
+ ); +} diff --git a/frontend/routes/@[scope]/~/settings/index.tsx b/frontend/routes/@[scope]/~/settings/index.tsx index c73ce15ce..3536ab679 100644 --- a/frontend/routes/@[scope]/~/settings/index.tsx +++ b/frontend/routes/@[scope]/~/settings/index.tsx @@ -6,12 +6,18 @@ import { define } from "../../../../util.ts"; import { ScopeHeader } from "../../(_components)/ScopeHeader.tsx"; import { ScopeNav } from "../../(_components)/ScopeNav.tsx"; import { ScopeDescriptionForm } from "../../(_islands)/ScopeDescriptionForm.tsx"; -import { FullScope, User } from "../../../../utils/api_types.ts"; +import { + FullScope, type Package, + User, + WebhookEndpoint, +} from "../../../../utils/api_types.ts"; import { scopeDataWithMember } from "../../../../utils/data.ts"; import { path } from "../../../../utils/api.ts"; import { QuotaCard } from "../../../../components/QuotaCard.tsx"; import { scopeIAM } from "../../../../utils/iam.ts"; import { TicketModal } from "../../../../islands/TicketModal.tsx"; +import { ListDisplay } from "../../../../components/List.tsx"; +import { PackageHit } from "../../../../components/PackageHit.tsx"; export default define.page(function ScopeSettingsPage( { data, state }, @@ -24,6 +30,7 @@ export default define.page(function ScopeSettingsPage( +
); @@ -222,6 +229,37 @@ function RequirePublishingFromCI({ scope }: { scope: FullScope }) { ); } +function Webhooks({ scope, webhooks }: { scope: FullScope; webhooks: WebhookEndpoint[] }) { + return ( +
+

Webhooks

+

+ Webhooks let you receive notifications when packages are published or other events happen in the scope. +

+ {webhooks.length > 0 && + {webhooks.map((entry) => ({ + href: `./settings/webhooks/${entry.id}`, + content: ( +
+
+ {entry.description ?? entry.url} +
+ +
+ {entry.events.length} event{entry.events.length > 1 && "s"} +
+
+ ) + }))} +
} + + + Create + +
+ ) +} + interface CardButtonProps { title: ComponentChildren; description: ComponentChildren; @@ -270,7 +308,7 @@ function DeleteScope({ scope }: { scope: FullScope }) { and publish packages to it. This action cannot be undone.

} - + diff --git a/frontend/routes/@[scope]/~/settings/index.tsx b/frontend/routes/@[scope]/~/settings/index.tsx index 3536ab679..5c122584a 100644 --- a/frontend/routes/@[scope]/~/settings/index.tsx +++ b/frontend/routes/@[scope]/~/settings/index.tsx @@ -7,7 +7,8 @@ import { ScopeHeader } from "../../(_components)/ScopeHeader.tsx"; import { ScopeNav } from "../../(_components)/ScopeNav.tsx"; import { ScopeDescriptionForm } from "../../(_islands)/ScopeDescriptionForm.tsx"; import { - FullScope, type Package, + FullScope, + type Package, User, WebhookEndpoint, } from "../../../../utils/api_types.ts"; @@ -229,35 +230,40 @@ function RequirePublishingFromCI({ scope }: { scope: FullScope }) { ); } -function Webhooks({ scope, webhooks }: { scope: FullScope; webhooks: WebhookEndpoint[] }) { +function Webhooks( + { scope, webhooks }: { scope: FullScope; webhooks: WebhookEndpoint[] }, +) { return (

Webhooks

- Webhooks let you receive notifications when packages are published or other events happen in the scope. + Webhooks let you receive notifications when packages are published or + other events happen in the scope.

- {webhooks.length > 0 && - {webhooks.map((entry) => ({ - href: `./settings/webhooks/${entry.id}`, - content: ( -
-
- {entry.description ?? entry.url} -
+ {webhooks.length > 0 && ( + + {webhooks.map((entry) => ({ + href: `./settings/webhooks/${entry.id}`, + content: ( +
+
+ {entry.description ?? entry.url} +
-
- {entry.events.length} event{entry.events.length > 1 && "s"} +
+ {entry.events.length} event{entry.events.length > 1 && "s"} +
-
- ) - }))} -
} + ), + }))} + + )} Create
- ) + ); } interface CardButtonProps { @@ -332,7 +338,9 @@ export const handler = define.handlers({ const [user, data, webhooksResp] = await Promise.all([ ctx.state.userPromise, scopeDataWithMember(ctx.state, ctx.params.scope), - ctx.state.api.get(path`/scopes/${ctx.params.scope}/webhooks`), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/webhooks`, + ), ]); if (user instanceof Response) return user; if (data === null) throw new HttpError(404, "The scope was not found."); diff --git a/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx b/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx new file mode 100644 index 000000000..c634647ba --- /dev/null +++ b/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx @@ -0,0 +1,74 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. +import { HttpError, RouteConfig } from "fresh"; +import { define } from "../../../../../util.ts"; +import { ScopeHeader } from "../../../(_components)/ScopeHeader.tsx"; +import { ScopeNav } from "../../../(_components)/ScopeNav.tsx"; +import { WebhookEdit } from "../../../../../islands/WebhookEdit.tsx"; +import type { + FullScope, + WebhookDelivery as ApiWebhookDelivery, + WebhookEndpoint, +} from "../../../../../utils/api_types.ts"; +import { scopeDataWithMember } from "../../../../../utils/data.ts"; +import { path } from "../../../../../utils/api.ts"; +import { scopeIAM } from "../../../../../utils/iam.ts"; +import { WebhookDelivery } from "../../../../../components/WebhookDelivery.tsx"; + +export default define.page(function ScopeSettingsPage( + { data }, +) { + return ( +
+ + + +
+ ); +}); + +export const handler = define.handlers({ + async GET(ctx) { + const [user, data, webhookResp, webhookDeliveryResp] = await Promise.all([ + ctx.state.userPromise, + scopeDataWithMember(ctx.state, ctx.params.scope), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/webhooks/${ctx.params.webhook}`, + ), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/webhooks/${ctx.params.webhook}/deliveries/${ctx.params.delivery}`, + ), + ]); + if (user instanceof Response) return user; + if (data === null) throw new HttpError(404, "The scope was not found."); + + const iam = scopeIAM(ctx.state, data?.scopeMember, user); + if (!iam.canAdmin) throw new HttpError(404, "The scope was not found."); + + if (!webhookResp.ok) { + if (webhookResp.code === "webhookNotFound") { + throw new HttpError(404, "The webhook was not found."); + } + throw webhookResp; // graceful handle errors + } + if (!webhookDeliveryResp.ok) { + if (webhookDeliveryResp.code === "webhookNotFound") { + throw new HttpError(404, "The webhook was not found."); + } + throw webhookDeliveryResp; // graceful handle errors + } + + ctx.state.meta = { title: `Webhook Settings - @${data.scope.scope} - JSR` }; + return { + data: { + scope: data.scope as FullScope, + webhook: webhookResp.data, + webhookDelivery: webhookDeliveryResp.data, + iam, + }, + }; + }, +}); + +export const config: RouteConfig = { + routeOverride: "/@:scope/~/settings/webhooks/:webhook/deliveries/:delivery", +}; diff --git a/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx b/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx index c587a2338..1a9cb4daa 100644 --- a/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx +++ b/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx @@ -4,10 +4,17 @@ import { define } from "../../../../../util.ts"; import { ScopeHeader } from "../../../(_components)/ScopeHeader.tsx"; import { ScopeNav } from "../../../(_components)/ScopeNav.tsx"; import { WebhookEdit } from "../../../../../islands/WebhookEdit.tsx"; -import { FullScope, WebhookEndpoint } from "../../../../../utils/api_types.ts"; +import { + FullScope, + WebhookDelivery, + WebhookEndpoint, +} from "../../../../../utils/api_types.ts"; import { scopeDataWithMember } from "../../../../../utils/data.ts"; import { path } from "../../../../../utils/api.ts"; import { scopeIAM } from "../../../../../utils/iam.ts"; +import { + WebhookDeliveries, +} from "../../../../../components/WebhookDeliveries.tsx"; export default define.page(function ScopeSettingsPage( { data }, @@ -17,16 +24,25 @@ export default define.page(function ScopeSettingsPage( +
); }); export const handler = define.handlers({ async GET(ctx) { - const [user, data, webhookResp] = await Promise.all([ + const [user, data, webhookResp, webhookDeliveriesResp] = await Promise.all([ ctx.state.userPromise, scopeDataWithMember(ctx.state, ctx.params.scope), - ctx.state.api.get(path`/scopes/${ctx.params.scope}/webhooks/${ctx.params.webhook}`), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/webhooks/${ctx.params.webhook}`, + ), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/webhooks/${ctx.params.webhook}/deliveries`, + ), ]); if (user instanceof Response) return user; if (data === null) throw new HttpError(404, "The scope was not found."); @@ -40,12 +56,19 @@ export const handler = define.handlers({ } throw webhookResp; // graceful handle errors } + if (!webhookDeliveriesResp.ok) { + if (webhookDeliveriesResp.code === "webhookNotFound") { + throw new HttpError(404, "The webhook was not found."); + } + throw webhookDeliveriesResp; // graceful handle errors + } ctx.state.meta = { title: `Webhook Settings - @${data.scope.scope} - JSR` }; return { data: { scope: data.scope as FullScope, webhook: webhookResp.data, + webhookDeliveries: webhookDeliveriesResp.data, iam, }, }; diff --git a/frontend/routes/@[scope]/~/settings/webhooks/new.tsx b/frontend/routes/@[scope]/~/settings/webhooks/new.tsx index db474dd85..1564a266d 100644 --- a/frontend/routes/@[scope]/~/settings/webhooks/new.tsx +++ b/frontend/routes/@[scope]/~/settings/webhooks/new.tsx @@ -4,9 +4,7 @@ import { define } from "../../../../../util.ts"; import { ScopeHeader } from "../../../(_components)/ScopeHeader.tsx"; import { ScopeNav } from "../../../(_components)/ScopeNav.tsx"; import { WebhookEdit } from "../../../../../islands/WebhookEdit.tsx"; -import { - FullScope, -} from "../../../../../utils/api_types.ts"; +import { FullScope } from "../../../../../utils/api_types.ts"; import { scopeDataWithMember } from "../../../../../utils/data.ts"; import { path } from "../../../../../utils/api.ts"; import { scopeIAM } from "../../../../../utils/iam.ts"; diff --git a/frontend/routes/package/settings/index.tsx b/frontend/routes/package/settings/index.tsx index a99a764e0..88bfd14db 100644 --- a/frontend/routes/package/settings/index.tsx +++ b/frontend/routes/package/settings/index.tsx @@ -323,39 +323,43 @@ function Webhooks({ webhooks }: { webhooks: WebhookEndpoint[] }) {

Webhooks

- Webhooks let you receive notifications when packages are published or other events happen in the scope. + Webhooks let you receive notifications when packages are published or + other events happen in the scope.

- {webhooks.length > 0 && - {webhooks.map((entry) => ({ - href: `./settings/webhooks/${entry.id}`, - content: ( -
-
- {entry.description ?? entry.url} + {webhooks.length > 0 && ( + + {webhooks.map((entry) => ({ + href: `./settings/webhooks/${entry.id}`, + content: ( +
+
+ {entry.description ?? entry.url} +
+ +
+ {entry.events.length} event{entry.events.length > 1 && "s"} +
- -
- {entry.events.length} event{entry.events.length > 1 && "s"} -
-
- ) - }))} - } + ), + }))} + + )} Create
- ) + ); } - export const handler = define.handlers({ async GET(ctx) { const [user, data, webhooksResp] = await Promise.all([ ctx.state.userPromise, packageData(ctx.state, ctx.params.scope, ctx.params.package), - ctx.state.api.get(path`/scopes/${ctx.params.scope}/packages/${ctx.params.package}/webhooks`), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/packages/${ctx.params.package}/webhooks`, + ), ]); if (user instanceof Response) return user; if (!data) throw new HttpError(404, "This package was not found."); @@ -376,9 +380,9 @@ export const handler = define.handlers({ pkg.description ? `: ${pkg.description}` : "" }`, }; - return { data: { package: pkg, downloads, iam, - webhooks: webhooksResp.data, - } }; + return { + data: { package: pkg, downloads, iam, webhooks: webhooksResp.data }, + }; }, async POST(ctx) { const req = ctx.req; diff --git a/frontend/routes/package/settings/webhooks/[webhook].tsx b/frontend/routes/package/settings/webhooks/[webhook].tsx index 0ef355e45..99123e876 100644 --- a/frontend/routes/package/settings/webhooks/[webhook].tsx +++ b/frontend/routes/package/settings/webhooks/[webhook].tsx @@ -8,6 +8,9 @@ import { path } from "../../../../utils/api.ts"; import { scopeIAM } from "../../../../utils/iam.ts"; import { PackageHeader } from "../../(_components)/PackageHeader.tsx"; import { PackageNav, Params } from "../../(_components)/PackageNav.tsx"; +import { + WebhookDeliveries, +} from "../../../../components/WebhookDeliveries.tsx"; export default define.page(function ScopeSettingsPage( { data, params }, @@ -30,6 +33,8 @@ export default define.page(function ScopeSettingsPage( /> + +
); }); @@ -39,7 +44,9 @@ export const handler = define.handlers({ const [user, data, webhookResp] = await Promise.all([ ctx.state.userPromise, packageData(ctx.state, ctx.params.scope, ctx.params.package), - ctx.state.api.get(path`/scopes/${ctx.params.scope}/packages/${ctx.params.package}/webhooks/${ctx.params.webhook}`), + ctx.state.api.get( + path`/scopes/${ctx.params.scope}/packages/${ctx.params.package}/webhooks/${ctx.params.webhook}`, + ), ]); if (user instanceof Response) return user; if (data === null) throw new HttpError(404, "The scope was not found."); diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index 0262346bd..98370d827 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -403,15 +403,16 @@ export interface PackageDownloadsRecentVersion { } export type WebhookEventKind = - | "package_version_published" - | "package_version_yanked" - | "package_version_deleted" - | "scope_package_created" - | "scope_package_archived" - | "scope_member_added" - | "scope_member_left"; + | "package_version_published" + | "package_version_yanked" + | "package_version_deleted" + | "scope_package_created" + | "scope_package_deleted" + | "scope_package_archived" + | "scope_member_added" + | "scope_member_removed"; -export type WebhookPayloadFormat = "json" | "discord"; +export type WebhookPayloadFormat = "json" | "discord" | "slack"; export interface WebhookEndpoint { id: string; @@ -419,10 +420,29 @@ export interface WebhookEndpoint { package: string | null; url: string; description: string | null; - secret: string; + hasSecret: boolean; events: WebhookEventKind[]; - payloadFormat: WebhookPayloadFormat, + payloadFormat: WebhookPayloadFormat; isActive: boolean; updatedAt: string; createdAt: string; } + +export type WebhookDeliveryStatus = + | "pending" + | "success" + | "failure" + | "retrying"; + +export interface WebhookDelivery { + id: string; + status: WebhookDeliveryStatus; + requestHeaders: Record | null; + responseHttpCode: number | null; + responseHeaders: Record | null; + responseBody: string | null; + payload: unknown; + event: WebhookEventKind; + updatedAt: string; + createdAt: string; +} From de1cff084e9c6935e15048b50f90662898f2ee63 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 26 Dec 2025 02:41:59 +0100 Subject: [PATCH 04/17] get stuff to work --- api/migrations/20251221141049_webhook.sql | 1 + api/src/api/types.rs | 4 +- api/src/db/database.rs | 14 +- api/src/db/models.rs | 131 +----------------- api/src/tasks.rs | 153 +++++++++++++++++++++- frontend/components/WebhookDelivery.tsx | 13 +- frontend/static/logo-square.png | Bin 0 -> 4355 bytes frontend/utils/api_types.ts | 2 +- 8 files changed, 172 insertions(+), 146 deletions(-) create mode 100644 frontend/static/logo-square.png diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql index 529fa8d99..dc9971f4f 100644 --- a/api/migrations/20251221141049_webhook.sql +++ b/api/migrations/20251221141049_webhook.sql @@ -55,6 +55,7 @@ CREATE TABLE webhook_deliveries ( status webhook_delivery_status NOT NULL DEFAULT 'pending', request_headers JSONB, + request_body JSONB, response_http_code INT, response_headers JSONB, diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 3406a45c3..8cb1db5b5 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1200,8 +1200,8 @@ pub struct ApiWebhookDelivery { pub id: Uuid, pub status: WebhookDeliveryStatus, pub event: WebhookEventKind, - pub payload: WebhookPayload, pub request_headers: Option, + pub request_body: Option, pub response_http_code: Option, pub response_headers: Option, pub response_body: Option, @@ -1215,8 +1215,8 @@ impl From<(WebhookDelivery, WebhookEvent)> for ApiWebhookDelivery { id: delivery.id, status: delivery.status, event: event.event, - payload: event.payload, request_headers: delivery.request_headers, + request_body: delivery.request_body, response_http_code: delivery.response_http_code, response_headers: delivery.response_headers, response_body: delivery.response_body, diff --git a/api/src/db/database.rs b/api/src/db/database.rs index d51b35b4b..0e0662066 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -5164,7 +5164,7 @@ impl Database { WebhookEndpoint, r#"SELECT id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at FROM webhook_endpoints - WHERE scope = $1 AND ($2::text IS NULL OR package = $2)"#, + WHERE scope = $1 AND ($2::text IS NULL OR package = $2) ORDER BY created_at DESC"#, scope as _, package as _, ) @@ -5239,17 +5239,19 @@ impl Database { id: Uuid, status: WebhookDeliveryStatus, request_headers: serde_json::Value, + request_body: serde_json::Value, response_http_code: i32, response_headers: serde_json::Value, response_body: String, ) -> Result<()> { sqlx::query!( r#"UPDATE webhook_deliveries - SET status = $2, request_headers = $3, response_http_code = $4, response_headers = $5, response_body = $6 + SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7 WHERE id = $1"#, id, status as _, request_headers, + request_body, response_http_code, response_headers, response_body, @@ -5266,11 +5268,11 @@ impl Database { ) -> Result> { sqlx::query!( r#"SELECT - webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", + webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" FROM webhook_deliveries INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id - WHERE endpoint_id = $1"#, + WHERE endpoint_id = $1 ORDER BY webhook_deliveries.created_at DESC"#, webhook_endpoint_id, ) .try_map(|r| { @@ -5280,6 +5282,7 @@ impl Database { event_id: r.webhook_delivery_event_id, status: r.webhook_delivery_status, request_headers: r.webhook_delivery_request_headers, + request_body: r.webhook_delivery_request_body, response_http_code: r.webhook_delivery_response_http_code, response_headers: r.webhook_delivery_response_headers, response_body: r.webhook_delivery_response_body, @@ -5308,7 +5311,7 @@ impl Database { ) -> Result<(WebhookDelivery, WebhookEvent)> { sqlx::query!( r#"SELECT - webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", + webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" FROM webhook_deliveries INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id @@ -5322,6 +5325,7 @@ impl Database { event_id: r.webhook_delivery_event_id, status: r.webhook_delivery_status, request_headers: r.webhook_delivery_request_headers, + request_body: r.webhook_delivery_request_body, response_http_code: r.webhook_delivery_response_http_code, response_headers: r.webhook_delivery_response_headers, response_body: r.webhook_delivery_response_body, diff --git a/api/src/db/models.rs b/api/src/db/models.rs index fca90330b..17002ab62 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -488,8 +488,7 @@ impl<'q> sqlx::Encode<'q, sqlx::Postgres> for PackageVersionMeta { impl sqlx::Type for PackageVersionMeta { fn type_info() -> ::TypeInfo { - as sqlx::Type>::type_info( - ) + as sqlx::Type>::type_info() } } @@ -1140,131 +1139,6 @@ pub enum WebhookPayload { }, } -#[derive(Serialize)] -struct ProviderEmbed { - color: String, - title: &'static str, - url: String, - description: String, -} - -impl WebhookPayload { - fn into_embed_data(self) -> ProviderEmbed { - match self { - WebhookPayload::PackageVersionPublished { - scope, - package, - version, - } => ProviderEmbed { - color: "".to_string(), - title: "Package version published", - url: format!("jsr.io/@{scope}/{package}/{version}"), - description: format!("@{scope}/{package}/{version} has been published"), - }, - WebhookPayload::PackageVersionYanked { - scope, - package, - version, - yanked, - } => ProviderEmbed { - color: "".to_string(), - title: if yanked { - "Package version yanked" - } else { - "Package version unyanked" - }, - url: format!("jsr.io/@{scope}/{package}/{version}"), - description: format!( - "@{scope}/{package}/{version} has been {}", - if yanked { "yanked" } else { "unyanked" } - ), - }, - WebhookPayload::PackageVersionDeleted { - scope, - package, - version, - } => ProviderEmbed { - color: "".to_string(), - title: "Package version deleted", - url: format!("jsr.io/@{scope}/{package}/{version}"), - description: format!("@{scope}/{package}/{version} has been deleted"), - }, - WebhookPayload::ScopePackageCreated { scope, package } => ProviderEmbed { - color: "".to_string(), - title: "Package created", - url: format!("jsr.io/@{scope}/{package}"), - description: format!("@{scope}/{package} has been created"), - }, - WebhookPayload::ScopePackageDeleted { scope, package } => ProviderEmbed { - color: "".to_string(), - title: "Package deleted", - url: format!("jsr.io/@{scope}"), - description: format!("@{scope}/{package} has been deleted"), - }, - WebhookPayload::ScopePackageArchived { - scope, - package, - archived, - } => ProviderEmbed { - color: "".to_string(), - title: if archived { - "Package archived" - } else { - "Package unarchived" - }, - url: format!("jsr.io/@{scope}"), - description: format!( - "@{scope}/{package} has been {}", - if archived { "archived" } else { "unarchived" } - ), - }, - WebhookPayload::ScopeMemberAdded { scope, user_id } => ProviderEmbed { - color: "".to_string(), - title: "Scope member added", - url: format!("jsr.io/@{scope}"), - description: format!("{user_id} has been added to @{scope}"), - }, - WebhookPayload::ScopeMemberRemoved { scope, user_id } => ProviderEmbed { - color: "".to_string(), - title: "Scope member removed", - url: format!("jsr.io/@{scope}"), - description: format!("{user_id} has been removed from @{scope}"), - }, - } - } - - pub fn discord_format(self) -> serde_json::Value { - let embed = self.into_embed_data(); - - serde_json::json!({ - "username": "JSR", - "avatar_url": "https://jsr.io/logo-square.svg", - "embeds": [embed], - }) - } - - pub fn slack_format(self) -> serde_json::Value { - let embed = self.into_embed_data(); - - serde_json::json!({ - "attachments": [ - { - "color": embed.color, - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": format!("[{}]({})\n{}", embed.title, embed.url, embed.description), - } - } - ] - } - ] - }) - } -} - impl sqlx::Decode<'_, sqlx::Postgres> for WebhookPayload { fn decode( value: sqlx::postgres::PgValueRef<'_>, @@ -1305,7 +1179,7 @@ pub struct WebhookEvent { } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::Type)] -#[sqlx(type_name = "task_status", rename_all = "lowercase")] +#[sqlx(type_name = "webhook_delivery_status", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] pub enum WebhookDeliveryStatus { Pending, @@ -1322,6 +1196,7 @@ pub struct WebhookDelivery { pub status: WebhookDeliveryStatus, pub request_headers: Option, + pub request_body: Option, pub response_http_code: Option, pub response_headers: Option, diff --git a/api/src/tasks.rs b/api/src/tasks.rs index f10da7120..4de9b211d 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -8,10 +8,10 @@ use crate::analysis::rebuild_npm_tarball; use crate::api::ApiError; use crate::buckets::Buckets; use crate::buckets::UploadTaskBody; -use crate::db::DownloadKind; use crate::db::NewNpmTarball; use crate::db::VersionDownloadCount; use crate::db::{Database, WebhookPayloadFormat}; +use crate::db::{DownloadKind, WebhookPayload}; use crate::gcp; use crate::gcp::CACHE_CONTROL_DO_NOT_CACHE; use crate::gcp::CACHE_CONTROL_IMMUTABLE; @@ -468,6 +468,7 @@ fn deserialize_version_download_count_from_bigquery( pub async fn webhook_dispatch_handler(mut req: Request) -> ApiResult<()> { let webhook_dispatch_id: Uuid = decode_json(&mut req).await?; let db = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); let retry_count = req .headers() @@ -480,17 +481,23 @@ pub async fn webhook_dispatch_handler(mut req: Request) -> ApiResult<()> { + 1; // sync retry count value with terraform - dispatch_webhook(db, webhook_dispatch_id, 3 - retry_count).await?; + dispatch_webhook(db, registry_url, webhook_dispatch_id, 3 - retry_count) + .await?; Ok(()) } const WEBHOOK_DISPATCH_ENQUEUE_PARALLELISM: usize = 32; -#[instrument(name = "enqueue_webhook_dispatches", skip(queue, db), err)] +#[instrument( + name = "enqueue_webhook_dispatches", + skip(queue, db, registry_url), + err +)] pub async fn enqueue_webhook_dispatches( queue: &WebhookDispatchQueue, db: &Database, + registry_url: &RegistryUrl, webhook_dispatch_ids: Vec, ) -> ApiResult<()> { let mut futs = stream::iter(webhook_dispatch_ids) @@ -500,7 +507,7 @@ pub async fn enqueue_webhook_dispatches( queue.task_buffer(None, Some(body.into())).boxed() } else { let span = Span::current(); - let fut = dispatch_webhook(db, webhook_dispatch_id, 0) + let fut = dispatch_webhook(db, registry_url, webhook_dispatch_id, 0) .instrument(span) .map_err(anyhow::Error::from); fut.boxed() @@ -515,12 +522,114 @@ pub async fn enqueue_webhook_dispatches( Ok(()) } -#[instrument(name = "dispatch_webhook", skip(db), err)] +#[instrument(name = "dispatch_webhook", skip(db, registry_url), err)] async fn dispatch_webhook( db: &Database, + registry_url: &RegistryUrl, webhook_dispatch_id: Uuid, retries_left: usize, ) -> ApiResult<()> { + #[derive(Serialize)] + struct ProviderEmbed { + color: u32, + title: &'static str, + url: String, + description: String, + } + + fn payload_to_embed_data( + payload: WebhookPayload, + registry_url: &RegistryUrl, + ) -> ProviderEmbed { + const GREEN: u32 = 0x22c55e; + const YELLOW: u32 = 0xf7df1e; + const RED: u32 = 0xef4444; + + let url = ®istry_url.0; + + match payload { + WebhookPayload::PackageVersionPublished { + scope, + package, + version, + } => ProviderEmbed { + color: GREEN, + title: "Package version published", + url: format!("{url}/@{scope}/{package}/{version}"), + description: format!("@{scope}/{package}/{version} has been published"), + }, + WebhookPayload::PackageVersionYanked { + scope, + package, + version, + yanked, + } => ProviderEmbed { + color: if yanked { RED } else { GREEN }, + title: if yanked { + "Package version yanked" + } else { + "Package version unyanked" + }, + url: format!("{url}/@{scope}/{package}/{version}"), + description: format!( + "@{scope}/{package}/{version} has been {}", + if yanked { "yanked" } else { "unyanked" } + ), + }, + WebhookPayload::PackageVersionDeleted { + scope, + package, + version, + } => ProviderEmbed { + color: RED, + title: "Package version deleted", + url: format!("{url}/@{scope}/{package}/{version}"), + description: format!("@{scope}/{package}/{version} has been deleted"), + }, + WebhookPayload::ScopePackageCreated { scope, package } => ProviderEmbed { + color: GREEN, + title: "Package created", + url: format!("{url}/@{scope}/{package}"), + description: format!("@{scope}/{package} has been created"), + }, + WebhookPayload::ScopePackageDeleted { scope, package } => ProviderEmbed { + color: RED, + title: "Package deleted", + url: format!("{url}/@{scope}"), + description: format!("@{scope}/{package} has been deleted"), + }, + WebhookPayload::ScopePackageArchived { + scope, + package, + archived, + } => ProviderEmbed { + color: YELLOW, + title: if archived { + "Package archived" + } else { + "Package unarchived" + }, + url: format!("{url}/@{scope}"), + description: format!( + "@{scope}/{package} has been {}", + if archived { "archived" } else { "unarchived" } + ), + }, + WebhookPayload::ScopeMemberAdded { scope, user_id } => ProviderEmbed { + color: GREEN, + title: "Scope member added", + url: format!("{url}/@{scope}"), + description: format!("{user_id} has been added to @{scope}"), + }, + WebhookPayload::ScopeMemberRemoved { scope, user_id } => ProviderEmbed { + color: RED, + title: "Scope member removed", + url: format!("{url}/@{scope}"), + description: format!("{user_id} has been removed from @{scope}"), + }, + } + } + let webhook = db.get_webhook_for_dispatch(webhook_dispatch_id).await?; let (json, signature) = match webhook.payload_format { @@ -539,8 +648,37 @@ async fn dispatch_webhook( (json, signature) } - WebhookPayloadFormat::Discord => (webhook.payload.discord_format(), None), - WebhookPayloadFormat::Slack => (webhook.payload.slack_format(), None), + WebhookPayloadFormat::Discord => ( + json!({ + "username": "JSR", + "avatar_url": format!("{}/logo-square.png", registry_url.0), + "embeds": [payload_to_embed_data(webhook.payload, registry_url)], + }), + None, + ), + WebhookPayloadFormat::Slack => { + let embed = payload_to_embed_data(webhook.payload, registry_url); + + ( + json!({ + "attachments": [ + { + "color": embed.color, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!("[{}]({})\n{}", embed.title, embed.url, embed.description), + } + } + ] + } + ] + }), + None, + ) + } }; let mut headers = reqwest::header::HeaderMap::new(); @@ -599,6 +737,7 @@ async fn dispatch_webhook( crate::db::models::WebhookDeliveryStatus::Failure }, request_headers, + json, response_http_status, response_headers, response_body, diff --git a/frontend/components/WebhookDelivery.tsx b/frontend/components/WebhookDelivery.tsx index 7e6946c21..76028169f 100644 --- a/frontend/components/WebhookDelivery.tsx +++ b/frontend/components/WebhookDelivery.tsx @@ -54,11 +54,11 @@ export function WebhookDelivery( )} - {delivery.payload && ( + {delivery.requestBody && (

Payload

- {JSON.stringify(delivery.payload, null, 2)} + {JSON.stringify(delivery.requestBody, null, 2)}
)} @@ -71,7 +71,14 @@ export function WebhookDelivery( {delivery.responseHttpCode && (

HTTP Status:

- = 200 && delivery.responseHttpCode <= 299) ? "text-green-500" : "text-red-500"}>{delivery.responseHttpCode} + = 200 && + delivery.responseHttpCode <= 299) + ? "text-green-500" + : "text-red-500"} + > + {delivery.responseHttpCode} +
)} diff --git a/frontend/static/logo-square.png b/frontend/static/logo-square.png new file mode 100644 index 0000000000000000000000000000000000000000..c388ded2dd8f1fc730574afb38a05c0996d33307 GIT binary patch literal 4355 zcmeHJeM}Q)96nG!E9$01hB(iIL>+6dA5cc^QIQtuqztiWaT`+F18%gv(jHJMqWDcB zBZ49#qM1`BIzJ=Igh8T)8N!s!DG5{2je&|2B8i}=d+&-x25kSCNc`iP-uwK%p6C6& zzxVQ@qZWI(d%Hsrl+Nk%ADlu8jP(vW(NT1zJ49E;uv za0n7hEJn2^6{p}tJee?H$fNUh2ux@(WJREYt1yP)DMW zA`n22Q)<|v*BQ(r3x+UpMc_ZZj3O{&LZxDeN)Zi*ktQ4tNJpA9V7<}3rHwy+ImUpj;>h7>nzOoWjl zNCQmAR40<@6ow!GhX;e>7Gfy6!8}MG2!YZF%Hwd+*UgP7Bt@E2NTa=?VLonix+>`9dX^FA@ku zf=~RpJQ0`6WMS+{Es>P3Q3Gew%Rm?c}QFT>MpZ?;z*!Sp!Y<+Qz8 zL$kvWqlwg}Yj7=-nmGeBY9^DYOtlFQO$M8cA)!f01kfQ12FHg;HsB_h5A!*KL6PlV znJGAk2-pR%xqzKO$>WIxT#+D%%@xw@&{6AQI&aK+Z0K0^MAL_lDMrW%ctyeHVlQS4 zsRxS@qx2YfrIi>Rp{C8?wYdRE*w(;20)q{;Pd4ykpN+Ty3`7%{e1UUZwnLCZE&+OV zM2|wyyzS}RZ~Jdp>eV1=-C8n_72QAcosN5#%<@dDS~x_DN9?bp>m z)g1WSZNV&6XChqLdEdhI+Fx0FGveEp*nM$FtiRV?KR5k`=ZR~qg87xjGs>q=KKf^D zQP8H=9ZE{xr)oKP*=f^p1-AUJ#YIK<3vL{1OLl_(y$rv#Xg3BR$^fp-(HC#%H-Meku0ECUWgPd zxwc+e&{#SZCV+$8XPv`(nkm7hKG(r2J2wHKt$Des&BxlRRNF%I8Mf#8L`4$#D{A<#Og*#(*kKLsB* z(%I0$b#J3l)d838D#_ke2ZFNir&Sk}cb~jq^1<$C1lcav)tetf1MLUL8ROwwb^iIi z0dLH)?FdafS1#r}X`WVcanh5*UE^bahqJYJZ&&z~zR3lRYkD^p%qsq))9r+}pzPiU z=XQZTf7BLve8aeB-}aF1_Ikg`jcL6Tg+B*mx5uw^LC50>w|78G{)gSO7{03fhV~Gv zW4)yO?1PM6=Nd52{Km;Vv$M5N>jW^>k6wO3<>cRjxO+MtRt7DvU-Gt&>yh+EUPf=? gu6&n+QJ`;CokPHC|Ju8E^^x>~ToxrgwkTo!KQYTQ?f?J) literal 0 HcmV?d00001 diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index 98370d827..d8d1160ed 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -438,10 +438,10 @@ export interface WebhookDelivery { id: string; status: WebhookDeliveryStatus; requestHeaders: Record | null; + requestBody: unknown | null; responseHttpCode: number | null; responseHeaders: Record | null; responseBody: string | null; - payload: unknown; event: WebhookEventKind; updatedAt: string; createdAt: string; From 4d2d4c79d4e41ef7cbd854376638e803910c7ac3 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 26 Dec 2025 16:23:49 +0100 Subject: [PATCH 05/17] fixes and further the impl --- ...ace63c1c06fdd0b3881468c26c0fe6b4101fd.json | 30 +++++ ...3f8903efbb5467a9f5ddcf09564ebaf3368f.json} | 6 +- ...a119a435a2c31a4fd2d443872e697d649058.json} | 5 +- ...cda39905c678e3269855726331f7bc432f66.json} | 36 ++++-- ...97621a49b31d8ae6fe3386da792e4de9aa23.json} | 36 ++++-- ...2dd858c5b403cd4315ad0de5706e1ad5f0166.json | 2 +- ...3dea89d8e91de9054f48cee1471cca07bedf.json} | 9 +- api/migrations/20251221141049_webhook.sql | 8 +- api/src/api/package.rs | 16 ++- api/src/api/scope.rs | 8 +- api/src/api/self_user.rs | 2 + api/src/api/types.rs | 9 +- api/src/db/database.rs | 47 ++++++-- api/src/db/models.rs | 11 +- api/src/publish.rs | 1 + api/src/tasks.rs | 44 +++++-- frontend/components/WebhookDeliveries.tsx | 4 +- frontend/components/WebhookDelivery.tsx | 68 ++++++----- frontend/islands/WebhookEdit.tsx | 114 ++++++++++++++---- frontend/routes/@[scope]/~/settings/index.tsx | 8 +- .../~/settings/webhooks/[delivery].tsx | 3 +- .../~/settings/webhooks/[webhook].tsx | 2 +- .../@[scope]/~/settings/webhooks/new.tsx | 2 +- frontend/routes/package/settings/index.tsx | 2 +- .../package/settings/webhooks/[webhook].tsx | 6 +- .../routes/package/settings/webhooks/new.tsx | 6 +- frontend/utils/api_types.ts | 1 + 27 files changed, 355 insertions(+), 131 deletions(-) create mode 100644 api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json rename api/.sqlx/{query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json => query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json} (93%) rename api/.sqlx/{query-af7893e4c7fa3c2d29438e8237e08b78c171c8ce08269a4af8708700c04b5934.json => query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json} (70%) rename api/.sqlx/{query-662dbf96892e836e46d4b476f02b210f5743f53248dd9edc2737356663a18c08.json => query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json} (71%) rename api/.sqlx/{query-db02cb7baac490f3485ca349de8ae7ca20eff5b3ddadc93147f0ef7966e81181.json => query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json} (71%) rename api/.sqlx/{query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json => query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json} (88%) diff --git a/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json b/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json new file mode 100644 index 000000000..fdd295fed --- /dev/null +++ b/api/.sqlx/query-09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE webhook_deliveries\n SET status = $2, error = $3, request_headers = $4, request_body = $5, response_http_code = null, response_headers = null, response_body = null\n WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + { + "Custom": { + "name": "webhook_delivery_status", + "kind": { + "Enum": [ + "pending", + "success", + "failure", + "retrying" + ] + } + } + }, + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "09bf7bd130da658422a73df9c96ace63c1c06fdd0b3881468c26c0fe6b4101fd" +} diff --git a/api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json b/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json similarity index 93% rename from api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json rename to api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json index 6e4965008..138b535cb 100644 --- a/api/.sqlx/query-e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a.json +++ b/api/.sqlx/query-10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2)", + "query": "SELECT id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at\n FROM webhook_endpoints\n WHERE scope = $1 AND ($2::text IS NULL OR package = $2) ORDER BY created_at DESC", "describe": { "columns": [ { @@ -104,7 +104,7 @@ false, true, false, - true, + false, true, false, false, @@ -113,5 +113,5 @@ false ] }, - "hash": "e6e64fa567092f23a2f2233ee09119f4e41e142947a244743e5890a37800825a" + "hash": "10136f30939a45abc06f1d1aba703f8903efbb5467a9f5ddcf09564ebaf3368f" } diff --git a/api/.sqlx/query-af7893e4c7fa3c2d29438e8237e08b78c171c8ce08269a4af8708700c04b5934.json b/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json similarity index 70% rename from api/.sqlx/query-af7893e4c7fa3c2d29438e8237e08b78c171c8ce08269a4af8708700c04b5934.json rename to api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json index b4f77c1a7..abb55ae57 100644 --- a/api/.sqlx/query-af7893e4c7fa3c2d29438e8237e08b78c171c8ce08269a4af8708700c04b5934.json +++ b/api/.sqlx/query-2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE webhook_deliveries\n SET status = $2, request_headers = $3, response_http_code = $4, response_headers = $5, response_body = $6\n WHERE id = $1", + "query": "UPDATE webhook_deliveries\n SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7, error = null\n WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -20,6 +20,7 @@ } }, "Jsonb", + "Jsonb", "Int4", "Jsonb", "Text" @@ -27,5 +28,5 @@ }, "nullable": [] }, - "hash": "af7893e4c7fa3c2d29438e8237e08b78c171c8ce08269a4af8708700c04b5934" + "hash": "2bd331aab8239aa2273a0866beefa119a435a2c31a4fd2d443872e697d649058" } diff --git a/api/.sqlx/query-662dbf96892e836e46d4b476f02b210f5743f53248dd9edc2737356663a18c08.json b/api/.sqlx/query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json similarity index 71% rename from api/.sqlx/query-662dbf96892e836e46d4b476f02b210f5743f53248dd9edc2737356663a18c08.json rename to api/.sqlx/query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json index f92812f59..5f877f005 100644 --- a/api/.sqlx/query-662dbf96892e836e46d4b476f02b210f5743f53248dd9edc2737356663a18c08.json +++ b/api/.sqlx/query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE endpoint_id = $1", + "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_deliveries.id = $1", "describe": { "columns": [ { @@ -42,46 +42,56 @@ }, { "ordinal": 5, + "name": "webhook_delivery_request_body", + "type_info": "Jsonb" + }, + { + "ordinal": 6, "name": "webhook_delivery_response_http_code", "type_info": "Int4" }, { - "ordinal": 6, + "ordinal": 7, "name": "webhook_delivery_response_headers", "type_info": "Jsonb" }, { - "ordinal": 7, + "ordinal": 8, "name": "webhook_delivery_response_body", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, + "name": "webhook_delivery_error", + "type_info": "Text" + }, + { + "ordinal": 10, "name": "webhook_delivery_updated_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 11, "name": "webhook_delivery_created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 12, "name": "webhook_event_id", "type_info": "Uuid" }, { - "ordinal": 11, + "ordinal": 13, "name": "webhook_event_scope: ScopeName", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 14, "name": "webhook_event_package: PackageName", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "webhook_event_event: WebhookEventKind", "type_info": { "Custom": { @@ -102,12 +112,12 @@ } }, { - "ordinal": 14, + "ordinal": 16, "name": "webhook_event_payload: WebhookPayload", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 17, "name": "webhook_event_created_at", "type_info": "Timestamptz" } @@ -126,6 +136,8 @@ true, true, true, + true, + true, false, false, false, @@ -136,5 +148,5 @@ false ] }, - "hash": "662dbf96892e836e46d4b476f02b210f5743f53248dd9edc2737356663a18c08" + "hash": "312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66" } diff --git a/api/.sqlx/query-db02cb7baac490f3485ca349de8ae7ca20eff5b3ddadc93147f0ef7966e81181.json b/api/.sqlx/query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json similarity index 71% rename from api/.sqlx/query-db02cb7baac490f3485ca349de8ae7ca20eff5b3ddadc93147f0ef7966e81181.json rename to api/.sqlx/query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json index 75eed5aba..520fcdbcc 100644 --- a/api/.sqlx/query-db02cb7baac490f3485ca349de8ae7ca20eff5b3ddadc93147f0ef7966e81181.json +++ b/api/.sqlx/query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_deliveries.id = $1", + "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE endpoint_id = $1 ORDER BY webhook_deliveries.created_at DESC", "describe": { "columns": [ { @@ -42,46 +42,56 @@ }, { "ordinal": 5, + "name": "webhook_delivery_request_body", + "type_info": "Jsonb" + }, + { + "ordinal": 6, "name": "webhook_delivery_response_http_code", "type_info": "Int4" }, { - "ordinal": 6, + "ordinal": 7, "name": "webhook_delivery_response_headers", "type_info": "Jsonb" }, { - "ordinal": 7, + "ordinal": 8, "name": "webhook_delivery_response_body", "type_info": "Text" }, { - "ordinal": 8, + "ordinal": 9, + "name": "webhook_delivery_error", + "type_info": "Text" + }, + { + "ordinal": 10, "name": "webhook_delivery_updated_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 11, "name": "webhook_delivery_created_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 12, "name": "webhook_event_id", "type_info": "Uuid" }, { - "ordinal": 11, + "ordinal": 13, "name": "webhook_event_scope: ScopeName", "type_info": "Text" }, { - "ordinal": 12, + "ordinal": 14, "name": "webhook_event_package: PackageName", "type_info": "Text" }, { - "ordinal": 13, + "ordinal": 15, "name": "webhook_event_event: WebhookEventKind", "type_info": { "Custom": { @@ -102,12 +112,12 @@ } }, { - "ordinal": 14, + "ordinal": 16, "name": "webhook_event_payload: WebhookPayload", "type_info": "Jsonb" }, { - "ordinal": 15, + "ordinal": 17, "name": "webhook_event_created_at", "type_info": "Timestamptz" } @@ -126,6 +136,8 @@ true, true, true, + true, + true, false, false, false, @@ -136,5 +148,5 @@ false ] }, - "hash": "db02cb7baac490f3485ca349de8ae7ca20eff5b3ddadc93147f0ef7966e81181" + "hash": "5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23" } diff --git a/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json index 2978f5f6a..0ff6302f4 100644 --- a/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json +++ b/api/.sqlx/query-7f3c9b05c2366415a18b97f1a3c2dd858c5b403cd4315ad0de5706e1ad5f0166.json @@ -105,7 +105,7 @@ false, true, false, - true, + false, true, false, false, diff --git a/api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json b/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json similarity index 88% rename from api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json rename to api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json index 1f104fdbd..353e611c6 100644 --- a/api/.sqlx/query-21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36.json +++ b/api/.sqlx/query-dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at", + "query": "INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format, is_active)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at", "describe": { "columns": [ { @@ -135,7 +135,8 @@ ] } } - } + }, + "Bool" ] }, "nullable": [ @@ -143,7 +144,7 @@ false, true, false, - true, + false, true, false, false, @@ -152,5 +153,5 @@ false ] }, - "hash": "21cf2ccd67c9678928b5b03d6079af7d074973f208679c8507cd5c7b20b03b36" + "hash": "dd4a183981823896391889dddbf73dea89d8e91de9054f48cee1471cca07bedf" } diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql index dc9971f4f..9a14d26cb 100644 --- a/api/migrations/20251221141049_webhook.sql +++ b/api/migrations/20251221141049_webhook.sql @@ -17,10 +17,10 @@ CREATE TYPE webhook_payload_format AS ENUM ( CREATE TABLE webhook_endpoints ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - scope TEXT NOT NULL references scopes (scope), + scope TEXT NOT NULL references scopes (scope) ON DELETE CASCADE, package TEXT, url TEXT NOT NULL, - description TEXT, + description TEXT NOT NULL DEFAULT '', secret VARCHAR(255), events webhook_event_kind[] NOT NULL, payload_format webhook_payload_format NOT NULL, @@ -35,7 +35,7 @@ SELECT manage_updated_at('webhook_endpoints'); CREATE TABLE webhook_events ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), - scope TEXT NOT NULL references scopes (scope), + scope TEXT NOT NULL references scopes (scope) ON DELETE CASCADE, package TEXT, event webhook_event_kind NOT NULL, payload JSONB NOT NULL, @@ -61,6 +61,8 @@ CREATE TABLE webhook_deliveries ( response_headers JSONB, response_body TEXT, + error TEXT, + updated_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now() ); diff --git a/api/src/api/package.rs b/api/src/api/package.rs index 014309abb..6ebf9c377 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -318,6 +318,7 @@ pub async fn create_handler(mut req: Request) -> ApiResult { let db = req.data::().unwrap(); let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); if db.check_is_bad_word(&package_name.to_string()).await? { return Err(ApiError::PackageNameNotAllowed); @@ -343,6 +344,7 @@ pub async fn create_handler(mut req: Request) -> ApiResult { crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries, ) .await?; @@ -512,10 +514,12 @@ pub async fn update_handler(mut req: Request) -> ApiResult { .await?; let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries, ) .await?; @@ -722,6 +726,7 @@ pub async fn delete_handler(req: Request) -> ApiResult> { let db: &Database = req.data::().unwrap(); let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); let _ = db .get_package(&scope, &package) @@ -740,6 +745,7 @@ pub async fn delete_handler(req: Request) -> ApiResult> { crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries, ) .await?; @@ -1034,6 +1040,7 @@ pub async fn version_update_handler( let buckets = req.data::().unwrap().clone(); let npm_url = &req.data::().unwrap().0; let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); let iam = req.iam(); let (user, sudo) = iam.check_scope_admin_access(&scope).await?; @@ -1052,6 +1059,7 @@ pub async fn version_update_handler( crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries, ) .await?; @@ -1139,9 +1147,11 @@ pub async fn version_delete_handler( .await?; let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries, ) .await?; @@ -2461,6 +2471,7 @@ pub async fn create_webhook_handler( secret, events, payload_format, + is_active, } = decode_json(&mut req).await?; let db = req.data::().unwrap(); @@ -2474,10 +2485,11 @@ pub async fn create_webhook_handler( scope: &scope, package: Some(&package), url: &url, - description: description.as_deref(), - secret: &secret, + description: &description, + secret: secret.as_deref(), events, payload_format, + is_active, }, &user.id, sudo, diff --git a/api/src/api/scope.rs b/api/src/api/scope.rs index bdf938b20..ac4f5f588 100644 --- a/api/src/api/scope.rs +++ b/api/src/api/scope.rs @@ -438,6 +438,7 @@ pub async fn delete_member_handler( let db = req.data::().unwrap(); let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); db.get_scope(&scope).await?.ok_or(ApiError::ScopeNotFound)?; @@ -454,6 +455,7 @@ pub async fn delete_member_handler( crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries.unwrap(), ) .await?; @@ -554,6 +556,7 @@ pub async fn create_webhook_handler( secret, events, payload_format, + is_active, } = decode_json(&mut req).await?; let db = req.data::().unwrap(); @@ -567,10 +570,11 @@ pub async fn create_webhook_handler( scope: &scope, package: None, url: &url, - description: description.as_deref(), - secret: &secret, + description: &description, + secret: secret.as_deref(), events, payload_format, + is_active, }, &user.id, sudo, diff --git a/api/src/api/self_user.rs b/api/src/api/self_user.rs index 8187b98aa..2c40dfb1a 100644 --- a/api/src/api/self_user.rs +++ b/api/src/api/self_user.rs @@ -136,6 +136,7 @@ pub async fn accept_invite_handler( let db = req.data::().unwrap(); let webhook_dispatch_queue = req.data::().unwrap(); + let registry_url = req.data::().unwrap(); let (member, webhook_deliveries) = db .accept_scope_invite(¤t_user.id, &scope) @@ -145,6 +146,7 @@ pub async fn accept_invite_handler( crate::tasks::enqueue_webhook_dispatches( webhook_dispatch_queue, db, + registry_url, webhook_deliveries, ) .await?; diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 8cb1db5b5..e73da59a7 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1154,10 +1154,11 @@ impl From<(AuditLog, UserPublic)> for ApiAuditLog { #[serde(rename_all = "camelCase")] pub struct ApiCreateWebhookEndpointRequest { pub url: String, - pub description: Option, - pub secret: String, + pub description: String, + pub secret: Option, pub events: Vec, pub payload_format: WebhookPayloadFormat, + pub is_active: bool, } #[derive(Debug, Serialize, Deserialize)] @@ -1167,7 +1168,7 @@ pub struct ApiWebhookEndpoint { pub scope: ScopeName, pub package: Option, pub url: String, - pub description: Option, + pub description: String, pub has_secret: bool, pub events: Vec, pub payload_format: WebhookPayloadFormat, @@ -1205,6 +1206,7 @@ pub struct ApiWebhookDelivery { pub response_http_code: Option, pub response_headers: Option, pub response_body: Option, + pub error: Option, pub updated_at: DateTime, pub created_at: DateTime, } @@ -1220,6 +1222,7 @@ impl From<(WebhookDelivery, WebhookEvent)> for ApiWebhookDelivery { response_http_code: delivery.response_http_code, response_headers: delivery.response_headers, response_body: delivery.response_body, + error: delivery.error, updated_at: delivery.updated_at, created_at: delivery.created_at, } diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 0e0662066..c531ba479 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -3066,7 +3066,7 @@ impl Database { &mut tx, &scope, None, - WebhookEventKind::ScopeMemberLeft, + WebhookEventKind::ScopeMemberRemoved, WebhookPayload::ScopeMemberRemoved { scope: scope.clone(), user_id, @@ -5115,8 +5115,8 @@ impl Database { let res = sqlx::query_as!( WebhookEndpoint, - r#"INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format) - VALUES ($1, $2, $3, $4, $5, $6, $7) + r#"INSERT INTO webhook_endpoints (scope, package, url, description, secret, events, payload_format, is_active) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at"#, new_webhook.scope, new_webhook.package as _, @@ -5125,6 +5125,7 @@ impl Database { new_webhook.secret, new_webhook.events as _, new_webhook.payload_format as _, + new_webhook.is_active, ) .fetch_one(&mut *tx) .await?; @@ -5229,9 +5230,10 @@ impl Database { .await } + #[allow(clippy::too_many_arguments)] #[instrument( name = "Database::update_webhook_delivery", - skip(self, request_headers, response_headers, response_body), + skip(self, request_headers, request_body, response_headers, response_body), err )] pub async fn update_webhook_delivery( @@ -5246,7 +5248,7 @@ impl Database { ) -> Result<()> { sqlx::query!( r#"UPDATE webhook_deliveries - SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7 + SET status = $2, request_headers = $3, request_body = $4, response_http_code = $5, response_headers = $6, response_body = $7, error = null WHERE id = $1"#, id, status as _, @@ -5261,6 +5263,35 @@ impl Database { Ok(()) } + #[allow(clippy::too_many_arguments)] + #[instrument( + name = "Database::update_webhook_delivery", + skip(self, error, request_headers, request_body), + err + )] + pub async fn update_webhook_delivery_for_error( + &self, + id: Uuid, + status: WebhookDeliveryStatus, + error: String, + request_headers: serde_json::Value, + request_body: serde_json::Value, + ) -> Result<()> { + sqlx::query!( + r#"UPDATE webhook_deliveries + SET status = $2, error = $3, request_headers = $4, request_body = $5, response_http_code = null, response_headers = null, response_body = null + WHERE id = $1"#, + id, + status as _, + error, + request_headers, + request_body, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + #[instrument(name = "Database::list_webhook_deliveries", skip(self), err)] pub async fn list_webhook_deliveries( &self, @@ -5268,7 +5299,7 @@ impl Database { ) -> Result> { sqlx::query!( r#"SELECT - webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", + webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.error as "webhook_delivery_error", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" FROM webhook_deliveries INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id @@ -5286,6 +5317,7 @@ impl Database { response_http_code: r.webhook_delivery_response_http_code, response_headers: r.webhook_delivery_response_headers, response_body: r.webhook_delivery_response_body, + error: r.webhook_delivery_error, updated_at: r.webhook_delivery_updated_at, created_at: r.webhook_delivery_created_at, }; @@ -5311,7 +5343,7 @@ impl Database { ) -> Result<(WebhookDelivery, WebhookEvent)> { sqlx::query!( r#"SELECT - webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", + webhook_deliveries.id as "webhook_delivery_id", webhook_deliveries.endpoint_id as "webhook_delivery_endpoint_id", webhook_deliveries.event_id as "webhook_delivery_event_id", webhook_deliveries.status as "webhook_delivery_status: WebhookDeliveryStatus", webhook_deliveries.request_headers as "webhook_delivery_request_headers", webhook_deliveries.request_body as "webhook_delivery_request_body", webhook_deliveries.response_http_code as "webhook_delivery_response_http_code", webhook_deliveries.response_headers as "webhook_delivery_response_headers", webhook_deliveries.response_body as "webhook_delivery_response_body", webhook_deliveries.error as "webhook_delivery_error", webhook_deliveries.updated_at as "webhook_delivery_updated_at", webhook_deliveries.created_at as "webhook_delivery_created_at", webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" FROM webhook_deliveries INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id @@ -5329,6 +5361,7 @@ impl Database { response_http_code: r.webhook_delivery_response_http_code, response_headers: r.webhook_delivery_response_headers, response_body: r.webhook_delivery_response_body, + error: r.webhook_delivery_error, updated_at: r.webhook_delivery_updated_at, created_at: r.webhook_delivery_created_at, }; diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 17002ab62..6085cc6e2 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -1053,7 +1053,7 @@ pub enum WebhookEventKind { ScopePackageDeleted, ScopePackageArchived, ScopeMemberAdded, - ScopeMemberLeft, + ScopeMemberRemoved, } impl sqlx::postgres::PgHasArrayType for WebhookEventKind { @@ -1078,7 +1078,7 @@ pub struct WebhookEndpoint { pub scope: ScopeName, pub package: Option, pub url: String, - pub description: Option, + pub description: String, pub secret: Option, pub events: Vec, pub payload_format: WebhookPayloadFormat, @@ -1091,10 +1091,11 @@ pub struct NewWebhookEndpoint<'s> { pub scope: &'s ScopeName, pub package: Option<&'s PackageName>, pub url: &'s str, - pub description: Option<&'s str>, - pub secret: &'s str, + pub description: &'s str, + pub secret: Option<&'s str>, pub events: Vec, pub payload_format: WebhookPayloadFormat, + pub is_active: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -1202,6 +1203,8 @@ pub struct WebhookDelivery { pub response_headers: Option, pub response_body: Option, + pub error: Option, + pub updated_at: DateTime, pub created_at: DateTime, } diff --git a/api/src/publish.rs b/api/src/publish.rs index 7cc5e1115..cb041cba1 100644 --- a/api/src/publish.rs +++ b/api/src/publish.rs @@ -153,6 +153,7 @@ pub async fn publish_task( crate::tasks::enqueue_webhook_dispatches( &webhook_dispatch_queue, &db, + &RegistryUrl(registry_url.clone()), webhook_deliveries, ) .await?; diff --git a/api/src/tasks.rs b/api/src/tasks.rs index 4de9b211d..8e37f92f6 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -555,7 +555,7 @@ async fn dispatch_webhook( } => ProviderEmbed { color: GREEN, title: "Package version published", - url: format!("{url}/@{scope}/{package}/{version}"), + url: format!("{url}@{scope}/{package}/{version}"), description: format!("@{scope}/{package}/{version} has been published"), }, WebhookPayload::PackageVersionYanked { @@ -570,7 +570,7 @@ async fn dispatch_webhook( } else { "Package version unyanked" }, - url: format!("{url}/@{scope}/{package}/{version}"), + url: format!("{url}@{scope}/{package}/{version}"), description: format!( "@{scope}/{package}/{version} has been {}", if yanked { "yanked" } else { "unyanked" } @@ -583,19 +583,19 @@ async fn dispatch_webhook( } => ProviderEmbed { color: RED, title: "Package version deleted", - url: format!("{url}/@{scope}/{package}/{version}"), + url: format!("{url}@{scope}/{package}/{version}"), description: format!("@{scope}/{package}/{version} has been deleted"), }, WebhookPayload::ScopePackageCreated { scope, package } => ProviderEmbed { color: GREEN, title: "Package created", - url: format!("{url}/@{scope}/{package}"), + url: format!("{url}@{scope}/{package}"), description: format!("@{scope}/{package} has been created"), }, WebhookPayload::ScopePackageDeleted { scope, package } => ProviderEmbed { color: RED, title: "Package deleted", - url: format!("{url}/@{scope}"), + url: format!("{url}@{scope}"), description: format!("@{scope}/{package} has been deleted"), }, WebhookPayload::ScopePackageArchived { @@ -609,7 +609,7 @@ async fn dispatch_webhook( } else { "Package unarchived" }, - url: format!("{url}/@{scope}"), + url: format!("{url}@{scope}"), description: format!( "@{scope}/{package} has been {}", if archived { "archived" } else { "unarchived" } @@ -618,13 +618,13 @@ async fn dispatch_webhook( WebhookPayload::ScopeMemberAdded { scope, user_id } => ProviderEmbed { color: GREEN, title: "Scope member added", - url: format!("{url}/@{scope}"), + url: format!("{url}@{scope}"), description: format!("{user_id} has been added to @{scope}"), }, WebhookPayload::ScopeMemberRemoved { scope, user_id } => ProviderEmbed { color: RED, title: "Scope member removed", - url: format!("{url}/@{scope}"), + url: format!("{url}@{scope}"), description: format!("{user_id} has been removed from @{scope}"), }, } @@ -651,7 +651,7 @@ async fn dispatch_webhook( WebhookPayloadFormat::Discord => ( json!({ "username": "JSR", - "avatar_url": format!("{}/logo-square.png", registry_url.0), + "avatar_url": format!("{}logo-square.png", registry_url.0), "embeds": [payload_to_embed_data(webhook.payload, registry_url)], }), None, @@ -663,13 +663,14 @@ async fn dispatch_webhook( json!({ "attachments": [ { - "color": embed.color, + "fallback": embed.description, + "color": format!("#{:x}", embed.color), "blocks": [ { "type": "section", "text": { "type": "mrkdwn", - "text": format!("[{}]({})\n{}", embed.title, embed.url, embed.description), + "text": format!("*<{}|{}>*\n{}", embed.url, embed.title, embed.description), } } ] @@ -713,13 +714,30 @@ async fn dispatch_webhook( let request_headers = serde_json::to_value(headers_to_map(&headers))?; - let response = reqwest::Client::new() + let response = match reqwest::Client::new() .post(webhook.url) .headers(headers) .json(&json) .send() .await - .map_err(anyhow::Error::from)?; + { + Ok(response) => response, + Err(err) => { + db.update_webhook_delivery_for_error( + webhook_dispatch_id, + if retries_left != 0 { + crate::db::models::WebhookDeliveryStatus::Retrying + } else { + crate::db::models::WebhookDeliveryStatus::Failure + }, + err.to_string(), + request_headers, + json, + ) + .await?; + return Err(anyhow::Error::from(err).into()); + } + }; let success = response.status().is_success(); let response_http_status = response.status().as_u16() as i32; diff --git a/frontend/components/WebhookDeliveries.tsx b/frontend/components/WebhookDeliveries.tsx index d3fa23b98..4ea9467dd 100644 --- a/frontend/components/WebhookDeliveries.tsx +++ b/frontend/components/WebhookDeliveries.tsx @@ -6,6 +6,7 @@ import type { } from "../utils/api_types.ts"; import { ListDisplay } from "./List.tsx"; import { TbAlertCircle, TbCheck, TbClockHour3, TbRefresh } from "tb-icons"; +import { WEBHOOK_EVENTS } from "../islands/WebhookEdit.tsx"; export function WebhookDeliveries( { webhook, deliveries }: { @@ -16,7 +17,6 @@ export function WebhookDeliveries( return (

Deliveries

- {deliveries.map((entry) => ({ href: `./${webhook.id}/deliveries/${entry.id}`, @@ -31,7 +31,7 @@ export function WebhookDeliveries(
- {entry.event.replaceAll("_", " ")} + {WEBHOOK_EVENTS.find((event) => event.id === entry.event)!.name}
), diff --git a/frontend/components/WebhookDelivery.tsx b/frontend/components/WebhookDelivery.tsx index 76028169f..f4c1264d4 100644 --- a/frontend/components/WebhookDelivery.tsx +++ b/frontend/components/WebhookDelivery.tsx @@ -4,13 +4,12 @@ import twas from "twas"; import type { WebhookDelivery, WebhookDeliveryStatus, - WebhookEndpoint, } from "../utils/api_types.ts"; import { TbAlertCircle, TbCheck, TbClockHour3, TbRefresh } from "tb-icons"; +import { WEBHOOK_EVENTS } from "../islands/WebhookEdit.tsx"; export function WebhookDelivery( - { webhook, delivery }: { - webhook: WebhookEndpoint; + { delivery }: { delivery: WebhookDelivery; }, ) { @@ -21,7 +20,7 @@ export function WebhookDelivery(
- {delivery.event.replaceAll("_", " ")} + {WEBHOOK_EVENTS.find((event) => event.id === delivery.event)!.name}
@@ -84,31 +83,44 @@ export function WebhookDelivery(
- {delivery.responseHeaders && ( -
-

Headers

- - {Object.entries(delivery.responseHeaders) - .map(([k, vs]) => - vs.map((v) => ( -
- {k}: {v} -
- )) - ) - .flat()} -
-
- )} + {delivery.error + ? ( +
+

Error

+ + {delivery.error} + +
+ ) + : ( + <> + {delivery.responseHeaders && ( +
+

Headers

+ + {Object.entries(delivery.responseHeaders) + .map(([k, vs]) => + vs.map((v) => ( +
+ {k}: {v} +
+ )) + ) + .flat()} +
+
+ )} - {delivery.responseBody && ( -
-

Body

- - {delivery.responseBody} - -
- )} + {delivery.responseBody && ( +
+

Body

+ + {delivery.responseBody} + +
+ )} + + )} ); diff --git a/frontend/islands/WebhookEdit.tsx b/frontend/islands/WebhookEdit.tsx index 778bc95e1..617ad996f 100644 --- a/frontend/islands/WebhookEdit.tsx +++ b/frontend/islands/WebhookEdit.tsx @@ -1,6 +1,9 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. import { WebhookEndpoint, WebhookEventKind } from "../utils/api_types.ts"; +import { useSignal } from "@preact/signals"; +import { api, path } from "../utils/api.ts"; -const events: Array<{ +export const WEBHOOK_EVENTS: Array<{ id: WebhookEventKind; name: string; description: string; @@ -61,13 +64,50 @@ function Required() { } export function WebhookEdit( - { webhook, packageLevel }: { + { webhook, scope, package: pkg }: { + scope: string; + package?: string; webhook: WebhookEndpoint | null; - packageLevel?: boolean; }, ) { + const description = useSignal(webhook?.description ?? ""); + const url = useSignal(webhook?.url ?? ""); + const payloadFormat = useSignal(webhook?.payloadFormat ?? "json"); + const secret = useSignal(""); + const events = useSignal(new Set(webhook?.events ?? [])); + const isActive = useSignal(webhook?.isActive ?? true); + return ( - + { + e.preventDefault(); + + const body = { + description: description.value, + url: url.value, + payloadFormat: payloadFormat.value, + secret: secret.value || null, + events: Array.from(events.value), + isActive: isActive.value, + }; + + webhook + ? api.patch( + pkg + ? path`/scopes/${scope}/packages/${pkg}/webhooks/${webhook.id}` + : path`/scopes/${scope}/webhooks/${webhook.id}`, + body, + ) + : api.post( + pkg + ? path`/scopes/${scope}/packages/${pkg}/webhooks` + : path`/scopes/${scope}/webhooks`, + body, + ); + }} + >
@@ -100,18 +142,18 @@ export function WebhookEdit( className="input-container input select w-full max-w-lg block px-3 py-2 text-sm mt-3" required > - @@ -120,15 +162,37 @@ export function WebhookEdit(
@@ -138,8 +202,8 @@ export function WebhookEdit(
- {events.filter((event) => { - if (packageLevel) { + {WEBHOOK_EVENTS.filter((event) => { + if (pkg) { return event.packageLevel; } else { return true; @@ -151,7 +215,14 @@ export function WebhookEdit( class="-ml-6 mt-1.5 float-left" name="events" value={event.id} - checked={webhook?.events.includes(event.id)} + checked={events.value.has(event.id)} + onInput={(e) => { + if (e.currentTarget.checked) { + events.value.add(event.id); + } else { + events.value.delete(event.id); + } + }} />

{event.name} @@ -171,7 +242,8 @@ export function WebhookEdit( isActive.value = e.currentTarget.checked} />

Active

diff --git a/frontend/routes/@[scope]/~/settings/index.tsx b/frontend/routes/@[scope]/~/settings/index.tsx index 5c122584a..0dd504e81 100644 --- a/frontend/routes/@[scope]/~/settings/index.tsx +++ b/frontend/routes/@[scope]/~/settings/index.tsx @@ -8,7 +8,6 @@ import { ScopeNav } from "../../(_components)/ScopeNav.tsx"; import { ScopeDescriptionForm } from "../../(_islands)/ScopeDescriptionForm.tsx"; import { FullScope, - type Package, User, WebhookEndpoint, } from "../../../../utils/api_types.ts"; @@ -18,7 +17,6 @@ import { QuotaCard } from "../../../../components/QuotaCard.tsx"; import { scopeIAM } from "../../../../utils/iam.ts"; import { TicketModal } from "../../../../islands/TicketModal.tsx"; import { ListDisplay } from "../../../../components/List.tsx"; -import { PackageHit } from "../../../../components/PackageHit.tsx"; export default define.page(function ScopeSettingsPage( { data, state }, @@ -31,7 +29,7 @@ export default define.page(function ScopeSettingsPage( - +
); @@ -231,7 +229,7 @@ function RequirePublishingFromCI({ scope }: { scope: FullScope }) { } function Webhooks( - { scope, webhooks }: { scope: FullScope; webhooks: WebhookEndpoint[] }, + { webhooks }: { webhooks: WebhookEndpoint[] }, ) { return (
@@ -247,7 +245,7 @@ function Webhooks( content: (
- {entry.description ?? entry.url} + {entry.description || entry.url}
diff --git a/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx b/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx index c634647ba..e69931630 100644 --- a/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx +++ b/frontend/routes/@[scope]/~/settings/webhooks/[delivery].tsx @@ -3,7 +3,6 @@ import { HttpError, RouteConfig } from "fresh"; import { define } from "../../../../../util.ts"; import { ScopeHeader } from "../../../(_components)/ScopeHeader.tsx"; import { ScopeNav } from "../../../(_components)/ScopeNav.tsx"; -import { WebhookEdit } from "../../../../../islands/WebhookEdit.tsx"; import type { FullScope, WebhookDelivery as ApiWebhookDelivery, @@ -21,7 +20,7 @@ export default define.page(function ScopeSettingsPage(
- +
); }); diff --git a/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx b/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx index 1a9cb4daa..12c6c7e49 100644 --- a/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx +++ b/frontend/routes/@[scope]/~/settings/webhooks/[webhook].tsx @@ -23,7 +23,7 @@ export default define.page(function ScopeSettingsPage(
- + (function ScopeSettingsPage(
- +
); }); diff --git a/frontend/routes/package/settings/index.tsx b/frontend/routes/package/settings/index.tsx index 88bfd14db..e6856119c 100644 --- a/frontend/routes/package/settings/index.tsx +++ b/frontend/routes/package/settings/index.tsx @@ -333,7 +333,7 @@ function Webhooks({ webhooks }: { webhooks: WebhookEndpoint[] }) { content: (
- {entry.description ?? entry.url} + {entry.description || entry.url}
diff --git a/frontend/routes/package/settings/webhooks/[webhook].tsx b/frontend/routes/package/settings/webhooks/[webhook].tsx index 99123e876..143d81143 100644 --- a/frontend/routes/package/settings/webhooks/[webhook].tsx +++ b/frontend/routes/package/settings/webhooks/[webhook].tsx @@ -32,7 +32,11 @@ export default define.page(function ScopeSettingsPage( latestVersion={data.package.latestVersion} /> - +
diff --git a/frontend/routes/package/settings/webhooks/new.tsx b/frontend/routes/package/settings/webhooks/new.tsx index b53de7d29..e07a4a30d 100644 --- a/frontend/routes/package/settings/webhooks/new.tsx +++ b/frontend/routes/package/settings/webhooks/new.tsx @@ -27,7 +27,11 @@ export default define.page(function ScopeSettingsPage( latestVersion={data.package.latestVersion} /> - +
); }); diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index d8d1160ed..41a3ebb6e 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -442,6 +442,7 @@ export interface WebhookDelivery { responseHttpCode: number | null; responseHeaders: Record | null; responseBody: string | null; + error: string | null; event: WebhookEventKind; updatedAt: string; createdAt: string; From 41b7128d3d760883cc0d56f1c7fc8a8128d732fb Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 26 Dec 2025 16:24:32 +0100 Subject: [PATCH 06/17] fix --- api/.env.local | 1 - 1 file changed, 1 deletion(-) delete mode 100644 api/.env.local diff --git a/api/.env.local b/api/.env.local deleted file mode 100644 index 66b0d2827..000000000 --- a/api/.env.local +++ /dev/null @@ -1 +0,0 @@ -DATABASE_URL=postgres://postgres:password@localhost:/registry_webhooks From 30459bf3f370c86175591f2763eb1e8e7d060335 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Sat, 27 Dec 2025 22:44:15 +0100 Subject: [PATCH 07/17] fixes for tests --- ...27ee4ced0cd03477ae28bb5d771bc7b5f42a.json} | 6 +- ...c8e4d983080a59b6105cf43ffa3c42c8cef0a.json | 158 ++++++++++++++++++ ...e912aaccf5f610465408f9a49ce925b838f9.json} | 7 +- api/src/api/package.rs | 158 +++++++++++++++--- api/src/api/scope.rs | 62 ++++++- api/src/api/types.rs | 11 ++ api/src/db/database.rs | 71 +++++++- api/src/db/models.rs | 9 + api/src/db/tests.rs | 8 +- api/src/publish.rs | 1 + api/src/util.rs | 1 + frontend/islands/WebhookEdit.tsx | 83 +++++++-- frontend/routes/@[scope]/~/settings/index.tsx | 2 +- frontend/routes/package/settings/index.tsx | 2 +- 14 files changed, 527 insertions(+), 52 deletions(-) rename api/.sqlx/{query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json => query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json} (94%) create mode 100644 api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json rename api/.sqlx/{query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json => query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json} (94%) diff --git a/api/.sqlx/query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json b/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json similarity index 94% rename from api/.sqlx/query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json rename to api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json index 5f877f005..bf3ce600a 100644 --- a/api/.sqlx/query-312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66.json +++ b/api/.sqlx/query-5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_deliveries.id = $1", + "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND endpoint_id = $3 ORDER BY webhook_deliveries.created_at DESC", "describe": { "columns": [ { @@ -124,6 +124,8 @@ ], "parameters": { "Left": [ + "Text", + "Text", "Uuid" ] }, @@ -148,5 +150,5 @@ false ] }, - "hash": "312983e9f1f3a62aa81a1e50398ccda39905c678e3269855726331f7bc432f66" + "hash": "5e1e0196eefc009cc3c400e84e7d27ee4ced0cd03477ae28bb5d771bc7b5f42a" } diff --git a/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json b/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json new file mode 100644 index 000000000..1cee4896e --- /dev/null +++ b/api/.sqlx/query-a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a.json @@ -0,0 +1,158 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE webhook_endpoints SET url = COALESCE($4, url), description = COALESCE($5, description), secret = COALESCE($6, secret), events = COALESCE($7, events), payload_format = COALESCE($8, payload_format), is_active = COALESCE($9, is_active) WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3\n RETURNING id, scope AS \"scope: ScopeName\", package AS \"package: PackageName\", url, description, secret, events AS \"events: _\", payload_format AS \"payload_format: _\", is_active, updated_at, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "scope: ScopeName", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "package: PackageName", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "secret", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "events: _", + "type_info": { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + } + }, + { + "ordinal": 7, + "name": "payload_format: _", + "type_info": { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + } + }, + { + "ordinal": 8, + "name": "is_active", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Uuid", + "Text", + "Text", + "Varchar", + { + "Custom": { + "name": "_webhook_event_kind", + "kind": { + "Array": { + "Custom": { + "name": "webhook_event_kind", + "kind": { + "Enum": [ + "package_version_published", + "package_version_yanked", + "package_version_deleted", + "scope_package_created", + "scope_package_deleted", + "scope_package_archived", + "scope_member_added", + "scope_member_removed" + ] + } + } + } + } + } + }, + { + "Custom": { + "name": "webhook_payload_format", + "kind": { + "Enum": [ + "json", + "discord", + "slack" + ] + } + } + }, + "Bool" + ] + }, + "nullable": [ + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "a8e8bfc9567dfeacaf3da1bc354c8e4d983080a59b6105cf43ffa3c42c8cef0a" +} diff --git a/api/.sqlx/query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json b/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json similarity index 94% rename from api/.sqlx/query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json rename to api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json index 520fcdbcc..6f3b636fa 100644 --- a/api/.sqlx/query-5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23.json +++ b/api/.sqlx/query-cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE endpoint_id = $1 ORDER BY webhook_deliveries.created_at DESC", + "query": "SELECT\n webhook_deliveries.id as \"webhook_delivery_id\", webhook_deliveries.endpoint_id as \"webhook_delivery_endpoint_id\", webhook_deliveries.event_id as \"webhook_delivery_event_id\", webhook_deliveries.status as \"webhook_delivery_status: WebhookDeliveryStatus\", webhook_deliveries.request_headers as \"webhook_delivery_request_headers\", webhook_deliveries.request_body as \"webhook_delivery_request_body\", webhook_deliveries.response_http_code as \"webhook_delivery_response_http_code\", webhook_deliveries.response_headers as \"webhook_delivery_response_headers\", webhook_deliveries.response_body as \"webhook_delivery_response_body\", webhook_deliveries.error as \"webhook_delivery_error\", webhook_deliveries.updated_at as \"webhook_delivery_updated_at\", webhook_deliveries.created_at as \"webhook_delivery_created_at\",\n webhook_events.id as \"webhook_event_id\", webhook_events.scope as \"webhook_event_scope: ScopeName\", webhook_events.package as \"webhook_event_package: PackageName\", webhook_events.event as \"webhook_event_event: WebhookEventKind\", webhook_events.payload as \"webhook_event_payload: WebhookPayload\", webhook_events.created_at as \"webhook_event_created_at\"\n FROM webhook_deliveries\n INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id\n WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND webhook_deliveries.endpoint_id = $3 AND webhook_deliveries.id = $4", "describe": { "columns": [ { @@ -124,6 +124,9 @@ ], "parameters": { "Left": [ + "Text", + "Text", + "Uuid", "Uuid" ] }, @@ -148,5 +151,5 @@ false ] }, - "hash": "5e864659fe35feb32cb538b2f68297621a49b31d8ae6fe3386da792e4de9aa23" + "hash": "cdf070f5a93e4748f7a166bf7094e912aaccf5f610465408f9a49ce925b838f9" } diff --git a/api/src/api/package.rs b/api/src/api/package.rs index 6ebf9c377..af7138188 100644 --- a/api/src/api/package.rs +++ b/api/src/api/package.rs @@ -55,7 +55,6 @@ use crate::auth::GithubOauth2Client; use crate::auth::access_token; use crate::buckets::Buckets; use crate::buckets::UploadTaskBody; -use crate::db::CreatePackageResult; use crate::db::CreatePublishingTaskResult; use crate::db::Database; use crate::db::NewGithubRepository; @@ -64,6 +63,7 @@ use crate::db::NewWebhookEndpoint; use crate::db::Package; use crate::db::RuntimeCompat; use crate::db::User; +use crate::db::{CreatePackageResult, UpdateWebhookEndpoint}; use crate::docs::DocNodesByUrl; use crate::docs::DocsRequest; use crate::docs::GeneratedDocsOutput; @@ -92,8 +92,6 @@ use crate::util::decode_json; use crate::util::pagination; use crate::util::search; -use super::ApiCreatePackageRequest; -use super::ApiCreateWebhookEndpointRequest; use super::ApiDependency; use super::ApiDependencyGraphItem; use super::ApiDependent; @@ -119,6 +117,8 @@ use super::ApiUpdatePackageGithubRepositoryRequest; use super::ApiUpdatePackageRequest; use super::ApiUpdatePackageVersionRequest; use super::ApiWebhookEndpoint; +use super::{ApiCreatePackageRequest, ApiUpdateWebhookEndpointRequest}; +use super::{ApiCreateWebhookEndpointRequest, ApiWebhookDelivery}; const MAX_PUBLISH_TARBALL_SIZE: u64 = 20 * 1024 * 1024; // 20mb @@ -207,10 +207,22 @@ pub fn package_router() -> Router { "/:package/webhooks", util::auth(util::json(list_webhooks_handler)), ) + .patch( + "/:package/webhooks/:webhook", + util::auth(util::json(update_webhook_handler)), + ) .delete( "/:package/webhooks/:webhook", util::auth(delete_webhook_handler), ) + .get( + "/:package/webhooks/:webhook/deliveries", + util::auth(util::json(list_webhook_deliveries_handler)), + ) + .get( + "/:package/webhooks/:webhook/deliveries/:delivery", + util::auth(util::json(get_webhook_delivery_handler)), + ) .build() .unwrap() } @@ -2525,6 +2537,55 @@ pub async fn get_webhook_handler( Ok(webhook_endpoint.into()) } +#[instrument( + name = "PATCH /api/scopes/:scope/packages/:package/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn update_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let ApiUpdateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + is_active, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .update_webhook_endpoint( + &scope, + Some(&package), + webhook_id, + UpdateWebhookEndpoint { + url, + description, + secret, + events, + payload_format, + is_active, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + #[instrument( name = "GET /api/scopes/:scope/packages/:package/webhooks", skip(req), @@ -2584,6 +2645,59 @@ pub async fn delete_webhook_handler( Ok(res) } +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks/:webhook/deliveries", + skip(req), + err, + fields(scope) +)] +pub async fn list_webhook_deliveries_handler( + req: Request, +) -> ApiResult> { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoints = db + .list_webhook_deliveries(&scope, Some(&package), webhook_id) + .await?; + + Ok(webhook_endpoints.into_iter().map(Into::into).collect()) +} + +#[instrument( + name = "GET /api/scopes/:scope/packages/:package/webhooks/:webhook/deliveries/:delivery", + skip(req), + err, + fields(scope) +)] +pub async fn get_webhook_delivery_handler( + req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let package = req.param_package()?; + let webhook_id = req.param_uuid("webhook")?; + let delivery_id = req.param_uuid("delivery")?; + Span::current().record("scope", field::display(&scope)); + + let db = req.data::().unwrap(); + + let iam = req.iam(); + iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .get_webhook_delivery(&scope, Some(&package), webhook_id, delivery_id) + .await?; + + Ok(webhook_endpoint.into()) +} + #[cfg(test)] mod test { use hyper::Body; @@ -2655,7 +2769,7 @@ mod test { ) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); } let mut resp = t.http().get("/api/packages").call().await.unwrap(); @@ -2852,7 +2966,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -2906,7 +3020,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -2963,7 +3077,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3017,7 +3131,7 @@ mod test { .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); t.ephemeral_database .create_package_version_for_test(NewPackageVersion { @@ -3186,7 +3300,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); t.ephemeral_database .create_package_version_for_test(NewPackageVersion { @@ -3252,7 +3366,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3348,7 +3462,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3440,7 +3554,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3506,7 +3620,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() @@ -3576,7 +3690,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let res = t.ephemeral_database.create_package(&scope, &name).await; if i < 11 { - assert!(matches!(res.unwrap(), CreatePackageResult::Ok(_))); + assert!(matches!(res.unwrap(), CreatePackageResult::Ok { .. })); } else { assert!(matches!( res.unwrap(), @@ -3607,7 +3721,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let res = t.ephemeral_database.create_package(&scope, &name).await; if i < 11 { - assert!(matches!(res.unwrap(), CreatePackageResult::Ok(_))); + assert!(matches!(res.unwrap(), CreatePackageResult::Ok { .. })); } else { assert!(matches!( res.unwrap(), @@ -3636,7 +3750,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let name = PackageName::new("foo".to_owned()).unwrap(); let config_file = PackagePath::try_from("/jsr.json").unwrap(); - let CreatePackageResult::Ok(package) = + let CreatePackageResult::Ok { package, .. } = t.db().create_package(&scope, &name).await.unwrap() else { unreachable!(); @@ -3701,7 +3815,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== let name = PackageName::new("foo".to_owned()).unwrap(); - let CreatePackageResult::Ok(_) = + let CreatePackageResult::Ok { .. } = t.db().create_package(&scope, &name).await.unwrap() else { unreachable!(); @@ -4214,7 +4328,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let url = format!("/api/scopes/{}/packages/{}", scope, name); let mut resp = t.http().delete(url).call().await.unwrap(); @@ -4250,7 +4364,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let url = format!("/api/scopes/{}/packages/{}", scope, name); let token = t.user2.token.clone(); @@ -4278,7 +4392,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let url = format!("/api/scopes/{}/packages/{}", scope, name); let token = t.user3.token.clone(); @@ -4331,7 +4445,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let version = Version::try_from("1.2.3").unwrap(); let config_file = PackagePath::try_from("/jsr.json").unwrap(); @@ -4365,7 +4479,7 @@ ggHohNAjhbzDaY2iBW/m3NC5dehGUP4T2GBo/cwGhg== .create_package(&scope, &name) .await .unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let mut resp = t .http() diff --git a/api/src/api/scope.rs b/api/src/api/scope.rs index ac4f5f588..4ce5b63d0 100644 --- a/api/src/api/scope.rs +++ b/api/src/api/scope.rs @@ -68,6 +68,10 @@ pub fn scope_router() -> Router { "/:scope/webhooks/:webhook", util::auth(util::json(get_webhook_handler)), ) + .patch( + "/:scope/webhooks/:webhook", + util::auth(util::json(update_webhook_handler)), + ) .delete( "/:scope/webhooks/:webhook", util::auth(delete_webhook_handler), @@ -608,6 +612,54 @@ pub async fn get_webhook_handler( Ok(webhook_endpoint.into()) } +#[instrument( + name = "PATCH /api/scopes/:scope/webhooks/:webhook", + skip(req), + err, + fields(scope) +)] +pub async fn update_webhook_handler( + mut req: Request, +) -> ApiResult { + let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; + Span::current().record("scope", field::display(&scope)); + + let ApiUpdateWebhookEndpointRequest { + url, + description, + secret, + events, + payload_format, + is_active, + } = decode_json(&mut req).await?; + + let db = req.data::().unwrap(); + + let iam = req.iam(); + let (user, sudo) = iam.check_scope_admin_access(&scope).await?; + + let webhook_endpoint = db + .update_webhook_endpoint( + &scope, + None, + webhook_id, + UpdateWebhookEndpoint { + url, + description, + secret, + events, + payload_format, + is_active, + }, + &user.id, + sudo, + ) + .await?; + + Ok(webhook_endpoint.into()) +} + #[instrument( name = "GET /api/scopes/:scope/webhooks", skip(req), @@ -676,13 +728,14 @@ pub async fn list_webhook_deliveries_handler( let iam = req.iam(); iam.check_scope_admin_access(&scope).await?; - let webhook_endpoints = db.list_webhook_deliveries(webhook_id).await?; + let webhook_endpoints = + db.list_webhook_deliveries(&scope, None, webhook_id).await?; Ok(webhook_endpoints.into_iter().map(Into::into).collect()) } #[instrument( - name = "GET /api/scopes/:scope/webhooks/deliveries/:delivery", + name = "GET /api/scopes/:scope/webhooks/:webhook/deliveries/:delivery", skip(req), err, fields(scope) @@ -691,6 +744,7 @@ pub async fn get_webhook_delivery_handler( req: Request, ) -> ApiResult { let scope = req.param_scope()?; + let webhook_id = req.param_uuid("webhook")?; let delivery_id = req.param_uuid("delivery")?; Span::current().record("scope", field::display(&scope)); @@ -699,7 +753,9 @@ pub async fn get_webhook_delivery_handler( let iam = req.iam(); iam.check_scope_admin_access(&scope).await?; - let webhook_endpoint = db.get_webhook_delivery(delivery_id).await?; + let webhook_endpoint = db + .get_webhook_delivery(&scope, None, webhook_id, delivery_id) + .await?; Ok(webhook_endpoint.into()) } diff --git a/api/src/api/types.rs b/api/src/api/types.rs index e73da59a7..44c340131 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -1161,6 +1161,17 @@ pub struct ApiCreateWebhookEndpointRequest { pub is_active: bool, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiUpdateWebhookEndpointRequest { + pub url: Option, + pub description: Option, + pub secret: Option, // TODO: it already is an option, how to distinguish between clearing and not changing it? + pub events: Option>, + pub payload_format: Option, + pub is_active: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiWebhookEndpoint { diff --git a/api/src/db/database.rs b/api/src/db/database.rs index c531ba479..4e27a204c 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -5135,6 +5135,63 @@ impl Database { Ok(res) } + #[instrument( + name = "Database::update_webhook_endpoint", + skip(self, webhook), + err + )] + pub async fn update_webhook_endpoint( + &self, + scope: &ScopeName, + package: Option<&PackageName>, + webhook_id: Uuid, + webhook: UpdateWebhookEndpoint, + actor_id: &Uuid, + is_sudo: bool, + ) -> Result { + let mut tx = self.pool.begin().await?; + + audit_log( + &mut tx, + actor_id, + is_sudo, + "update_webhook_endpoint", + json!({ + "scope": scope, + "package": package, + "webhook": webhook_id, + "url": webhook.url, + "description": webhook.description, + "secret": webhook.secret, + "events": webhook.events, + "payload_format": webhook.payload_format, + "is_active": webhook.is_active, + }), + ) + .await?; + + let webhook_endpoint = sqlx::query_as!( + WebhookEndpoint, + r#"UPDATE webhook_endpoints SET url = COALESCE($4, url), description = COALESCE($5, description), secret = COALESCE($6, secret), events = COALESCE($7, events), payload_format = COALESCE($8, payload_format), is_active = COALESCE($9, is_active) WHERE scope = $1 AND ($2::text IS NULL OR package = $2) AND id = $3 + RETURNING id, scope AS "scope: ScopeName", package AS "package: PackageName", url, description, secret, events AS "events: _", payload_format AS "payload_format: _", is_active, updated_at, created_at"#, + scope as _, + package as _, + webhook_id, + webhook.url, + webhook.description, + webhook.secret, // TODO + webhook.events as _, + webhook.payload_format as _, + webhook.is_active, + ) + .fetch_one(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(webhook_endpoint) + } + #[instrument(name = "Database::get_webhook_endpoint", skip(self), err)] pub async fn get_webhook_endpoint( &self, @@ -5295,6 +5352,8 @@ impl Database { #[instrument(name = "Database::list_webhook_deliveries", skip(self), err)] pub async fn list_webhook_deliveries( &self, + scope: &ScopeName, + package: Option<&PackageName>, webhook_endpoint_id: Uuid, ) -> Result> { sqlx::query!( @@ -5303,7 +5362,9 @@ impl Database { webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" FROM webhook_deliveries INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id - WHERE endpoint_id = $1 ORDER BY webhook_deliveries.created_at DESC"#, + WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND endpoint_id = $3 ORDER BY webhook_deliveries.created_at DESC"#, + scope as _, + package as _, webhook_endpoint_id, ) .try_map(|r| { @@ -5339,6 +5400,9 @@ impl Database { #[instrument(name = "Database::get_webhook_deliveries", skip(self), err)] pub async fn get_webhook_delivery( &self, + scope: &ScopeName, + package: Option<&PackageName>, + webhook_endpoint_id: Uuid, webhook_delivery_id: Uuid, ) -> Result<(WebhookDelivery, WebhookEvent)> { sqlx::query!( @@ -5347,7 +5411,10 @@ impl Database { webhook_events.id as "webhook_event_id", webhook_events.scope as "webhook_event_scope: ScopeName", webhook_events.package as "webhook_event_package: PackageName", webhook_events.event as "webhook_event_event: WebhookEventKind", webhook_events.payload as "webhook_event_payload: WebhookPayload", webhook_events.created_at as "webhook_event_created_at" FROM webhook_deliveries INNER JOIN webhook_events ON webhook_deliveries.event_id = webhook_events.id - WHERE webhook_deliveries.id = $1"#, + WHERE webhook_events.scope = $1 AND ($2::text IS NULL OR webhook_events.package = $2) AND webhook_deliveries.endpoint_id = $3 AND webhook_deliveries.id = $4"#, + scope as _, + package as _, + webhook_endpoint_id, webhook_delivery_id, ) .try_map(|r| { diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 6085cc6e2..28d0e2637 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -1098,6 +1098,15 @@ pub struct NewWebhookEndpoint<'s> { pub is_active: bool, } +pub struct UpdateWebhookEndpoint { + pub url: Option, + pub description: Option, + pub secret: Option, + pub events: Option>, + pub payload_format: Option, + pub is_active: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum WebhookPayload { diff --git a/api/src/db/tests.rs b/api/src/db/tests.rs index eb86c57f4..2b5174f13 100644 --- a/api/src/db/tests.rs +++ b/api/src/db/tests.rs @@ -30,7 +30,7 @@ async fn publishing_tasks() { .await .unwrap(); let res = db.create_package(&scope_name, &package_name).await.unwrap(); - assert!(matches!(res, CreatePackageResult::Ok(_))); + assert!(matches!(res, CreatePackageResult::Ok { .. })); let CreatePublishingTaskResult::Created((pt, _)) = db .create_publishing_task(NewPublishingTask { @@ -244,7 +244,7 @@ async fn packages() { .is_some() ); - let CreatePackageResult::Ok(package) = + let CreatePackageResult::Ok { package, .. } = db.create_package(&scope_name, &package_name).await.unwrap() else { unreachable!() @@ -374,7 +374,7 @@ async fn create_package_version_and_finalize_publishing_task() { .await .unwrap(); - let CreatePackageResult::Ok(_package) = + let CreatePackageResult::Ok { .. } = db.create_package(&scope, &package_name).await.unwrap() else { unreachable!() @@ -491,7 +491,7 @@ async fn package_files() { .await .unwrap(); - let CreatePackageResult::Ok(package) = + let CreatePackageResult::Ok { package, .. } = db.create_package(&scope_name, &package_name).await.unwrap() else { unreachable!() diff --git a/api/src/publish.rs b/api/src/publish.rs index cb041cba1..4b8fae06e 100644 --- a/api/src/publish.rs +++ b/api/src/publish.rs @@ -548,6 +548,7 @@ pub mod tests { t.registry_url(), t.npm_url(), t.db(), + WebhookDispatchQueue(None), None, ) .await diff --git a/api/src/util.rs b/api/src/util.rs index b68e61700..0ff05d4e4 100644 --- a/api/src/util.rs +++ b/api/src/util.rs @@ -585,6 +585,7 @@ pub mod test { npm_url: "http://npm.jsr-tests.test".parse().unwrap(), publish_queue: None, // no queue locally npm_tarball_build_queue: None, // no queue locally + webhook_dispatch_queue: None, // no queue locally logs_bigquery_table: None, // no bigquery locally expose_api: true, // api enabled expose_tasks: true, // task endpoints enabled diff --git a/frontend/islands/WebhookEdit.tsx b/frontend/islands/WebhookEdit.tsx index 617ad996f..2dfb587d1 100644 --- a/frontend/islands/WebhookEdit.tsx +++ b/frontend/islands/WebhookEdit.tsx @@ -76,6 +76,7 @@ export function WebhookEdit( const secret = useSignal(""); const events = useSignal(new Set(webhook?.events ?? [])); const isActive = useSignal(webhook?.isActive ?? true); + const processing = useSignal(false); return ( { e.preventDefault(); - const body = { - description: description.value, - url: url.value, - payloadFormat: payloadFormat.value, - secret: secret.value || null, - events: Array.from(events.value), - isActive: isActive.value, - }; + processing.value = true; - webhook + (webhook ? api.patch( pkg ? path`/scopes/${scope}/packages/${pkg}/webhooks/${webhook.id}` : path`/scopes/${scope}/webhooks/${webhook.id}`, - body, + { + description: description.value === webhook.description + ? undefined + : description.value, + url: url.value === webhook.url ? undefined : url.value, + payloadFormat: payloadFormat.value === webhook.payloadFormat + ? undefined + : payloadFormat.value, + secret: secret.value || null, // TODO + events: events.value.symmetricDifference(new Set(webhook.events)) + .size === 0 + ? undefined + : Array.from(events.value), + isActive: isActive.value === webhook.isActive + ? undefined + : isActive.value, + }, ) : api.post( pkg ? path`/scopes/${scope}/packages/${pkg}/webhooks` : path`/scopes/${scope}/webhooks`, - body, - ); + { + description: description.value, + url: url.value, + payloadFormat: payloadFormat.value, + secret: secret.value || null, + events: Array.from(events.value), + isActive: isActive.value, + }, + )).then(() => { + if (webhook) { + location.reload(); + } else { + location.href = pkg + ? `/@${scope}/${pkg}/settings#webhooks` + : `/@${scope}/~/settings#webhooks`; + } + }); }} >
@@ -118,6 +143,7 @@ export function WebhookEdit( placeholder="My webhook..." value={description} onInput={(e) => description.value = e.currentTarget.value} + disabled={processing} />
- {webhook && } - + )} + diff --git a/frontend/routes/@[scope]/~/settings/index.tsx b/frontend/routes/@[scope]/~/settings/index.tsx index 0dd504e81..86e681c94 100644 --- a/frontend/routes/@[scope]/~/settings/index.tsx +++ b/frontend/routes/@[scope]/~/settings/index.tsx @@ -232,7 +232,7 @@ function Webhooks( { webhooks }: { webhooks: WebhookEndpoint[] }, ) { return ( -
+

Webhooks

Webhooks let you receive notifications when packages are published or diff --git a/frontend/routes/package/settings/index.tsx b/frontend/routes/package/settings/index.tsx index e6856119c..32c12797b 100644 --- a/frontend/routes/package/settings/index.tsx +++ b/frontend/routes/package/settings/index.tsx @@ -320,7 +320,7 @@ function FeaturePackage(props: { package: Package }) { function Webhooks({ webhooks }: { webhooks: WebhookEndpoint[] }) { return ( -

+

Webhooks

Webhooks let you receive notifications when packages are published or From 44324c35d2e5467d56572d7f65fe91dffe1519a9 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 1 Jan 2026 21:04:39 +0100 Subject: [PATCH 08/17] fixes --- api/src/db/database.rs | 16 ++++++++-------- api/src/db/models.rs | 1 + api/src/publish.rs | 9 ++------- api/src/tasks.rs | 1 + 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 4e27a204c..6d3e572ba 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -3518,6 +3518,7 @@ impl Database { Ok(task) } + #[instrument( name = "Database::process_webhooks_for_publish", skip(self), @@ -3525,21 +3526,20 @@ impl Database { )] pub async fn process_webhooks_for_publish( &self, - scope: &ScopeName, - name: &PackageName, - version: &Version, + task: &PublishingTask, ) -> Result> { let mut tx = self.pool.begin().await?; let webhook_deliveries = insert_webhook_event( &mut tx, - scope, - Some(name), + &task.package_scope, + Some(&task.package_name), WebhookEventKind::PackageVersionPublished, WebhookPayload::PackageVersionPublished { - scope: scope.clone(), - package: name.clone(), - version: version.clone(), + scope: task.package_scope.clone(), + package: task.package_name.clone(), + version: task.package_version.clone(), + user_id: task.user_id.clone(), }, ) .await?; diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 28d0e2637..2a07d87b1 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -1114,6 +1114,7 @@ pub enum WebhookPayload { scope: ScopeName, package: PackageName, version: Version, + user_id: Option, }, PackageVersionYanked { scope: ScopeName, diff --git a/api/src/publish.rs b/api/src/publish.rs index 4b8fae06e..48d2951ac 100644 --- a/api/src/publish.rs +++ b/api/src/publish.rs @@ -142,13 +142,8 @@ pub async fn publish_task( } PublishingTaskStatus::Failure => return Ok(()), PublishingTaskStatus::Success => { - let webhook_deliveries = db - .process_webhooks_for_publish( - &publishing_task.package_scope, - &publishing_task.package_name, - &publishing_task.package_version, - ) - .await?; + let webhook_deliveries = + db.process_webhooks_for_publish(&publishing_task).await?; crate::tasks::enqueue_webhook_dispatches( &webhook_dispatch_queue, diff --git a/api/src/tasks.rs b/api/src/tasks.rs index 8e37f92f6..36a362c75 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -552,6 +552,7 @@ async fn dispatch_webhook( scope, package, version, + user_id: _, } => ProviderEmbed { color: GREEN, title: "Package version published", From 4d91711122666e6b0695c457820999ab109a45a3 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Thu, 15 Jan 2026 09:25:41 +0100 Subject: [PATCH 09/17] fixes --- frontend/routes/package/settings/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/routes/package/settings/index.tsx b/frontend/routes/package/settings/index.tsx index 391d9215b..fcca4dfe9 100644 --- a/frontend/routes/package/settings/index.tsx +++ b/frontend/routes/package/settings/index.tsx @@ -325,7 +325,7 @@ function FeaturePackage(props: { package: Package }) { function Webhooks({ webhooks }: { webhooks: WebhookEndpoint[] }) { return ( -

+

Webhooks

Webhooks let you receive notifications when packages are published or @@ -350,7 +350,7 @@ function Webhooks({ webhooks }: { webhooks: WebhookEndpoint[] }) { )} - + Create

From 1eb3b04859d4caff5468b7c7f2970131e422a008 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Fri, 23 Jan 2026 17:25:35 +0100 Subject: [PATCH 10/17] Update api/src/db/database.rs Co-authored-by: KnorpelSenf --- api/src/db/database.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 6d3e572ba..ef0a0b2e7 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -5538,7 +5538,7 @@ async fn audit_log( Ok(()) } -pub async fn insert_webhook_event( +async fn insert_webhook_event( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, scope: &ScopeName, package: Option<&PackageName>, From 3470b7164542bac9581fac48b89c0568ba9ee828 Mon Sep 17 00:00:00 2001 From: Leo Kettmeir Date: Sun, 1 Feb 2026 14:37:58 +0100 Subject: [PATCH 11/17] fixes, docs, and npm tarball event --- api/migrations/20251221141049_webhook.sql | 1 + api/src/api/errors.rs | 5 + api/src/db/database.rs | 30 +++ api/src/db/models.rs | 8 +- api/src/tasks.rs | 23 +- frontend/components/Help.tsx | 11 + frontend/docs/toc.ts | 5 + frontend/docs/webhooks.md | 216 ++++++++++++++++++ frontend/islands/WebhookEdit.tsx | 15 +- frontend/routes/@[scope]/~/settings/index.tsx | 5 +- frontend/routes/package/settings/index.tsx | 5 +- frontend/utils/api_types.ts | 1 + 12 files changed, 317 insertions(+), 8 deletions(-) create mode 100644 frontend/components/Help.tsx create mode 100644 frontend/docs/webhooks.md diff --git a/api/migrations/20251221141049_webhook.sql b/api/migrations/20251221141049_webhook.sql index 9a14d26cb..92e9d0e15 100644 --- a/api/migrations/20251221141049_webhook.sql +++ b/api/migrations/20251221141049_webhook.sql @@ -1,4 +1,5 @@ CREATE TYPE webhook_event_kind AS ENUM ( + 'package_version_npm_tarball_ready', 'package_version_published', 'package_version_yanked', 'package_version_deleted', diff --git a/api/src/api/errors.rs b/api/src/api/errors.rs index 52affa89b..918c07dc6 100644 --- a/api/src/api/errors.rs +++ b/api/src/api/errors.rs @@ -253,6 +253,11 @@ errors!( status: BAD_REQUEST, "The metadata for the ticket is not in a valid format, should be a key-value of strings.", }, + WebhookResponseFailure { + status: BAD_REQUEST, + fields: { status: reqwest::StatusCode }, + ({ status }) => "The webhook target responded with status {status}.", + }, ); pub fn map_unique_violation(err: sqlx::Error, new_err: ApiError) -> ApiError { diff --git a/api/src/db/database.rs b/api/src/db/database.rs index ef0a0b2e7..68537f528 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -2464,6 +2464,36 @@ impl Database { .fetch_one(&self.pool) .await } + #[instrument( + name = "Database::process_webhooks_for_npm_tarball", + skip(self), + err + )] + pub async fn process_webhooks_for_npm_tarball( + &self, + package_scope: &ScopeName, + package_name: &PackageName, + package_version: &Version, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let webhook_deliveries = insert_webhook_event( + &mut tx, + package_scope, + Some(package_name), + WebhookEventKind::PackageVersionNpmTarballReady, + WebhookPayload::PackageVersionNpmTarballReady { + scope: package_scope.clone(), + package: package_name.clone(), + version: package_version.clone(), + }, + ) + .await?; + + tx.commit().await?; + + Ok(webhook_deliveries) + } #[instrument(name = "Database::get_scope_member", skip(self), err)] pub async fn get_scope_member( diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 2a07d87b1..74c1bd344 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -1046,6 +1046,7 @@ impl FromRow<'_, sqlx::postgres::PgRow> for AuditLog { #[serde(rename_all = "snake_case")] #[sqlx(type_name = "webhook_event_kind", rename_all = "snake_case")] pub enum WebhookEventKind { + PackageVersionNpmTarballReady, PackageVersionPublished, PackageVersionYanked, PackageVersionDeleted, @@ -1110,10 +1111,15 @@ pub struct UpdateWebhookEndpoint { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum WebhookPayload { - PackageVersionPublished { + PackageVersionNpmTarballReady { scope: ScopeName, package: PackageName, version: Version, + }, + PackageVersionPublished { + version: Version, + scope: ScopeName, + package: PackageName, user_id: Option, }, PackageVersionYanked { diff --git a/api/src/tasks.rs b/api/src/tasks.rs index 36a362c75..6549118ab 100644 --- a/api/src/tasks.rs +++ b/api/src/tasks.rs @@ -214,6 +214,9 @@ pub async fn npm_tarball_build_handler( ) .await?; + db.process_webhooks_for_npm_tarball(&job.scope, &job.name, &job.version) + .await?; + Ok(()) } @@ -548,6 +551,18 @@ async fn dispatch_webhook( let url = ®istry_url.0; match payload { + WebhookPayload::PackageVersionNpmTarballReady { + scope, + package, + version, + } => ProviderEmbed { + color: GREEN, + title: "Package version NPM tarball ready", + url: format!("{url}@{scope}/{package}/{version}"), + description: format!( + "NPM tarball for @{scope}/{package}/{version} is ready" + ), + }, WebhookPayload::PackageVersionPublished { scope, package, @@ -740,8 +755,9 @@ async fn dispatch_webhook( } }; - let success = response.status().is_success(); - let response_http_status = response.status().as_u16() as i32; + let status = response.status(); + let success = status.is_success(); + let response_http_status = status.as_u16() as i32; let response_headers = serde_json::to_value(headers_to_map(response.headers()))?; let response_body = response.text().await.map_err(anyhow::Error::from)?; @@ -764,8 +780,7 @@ async fn dispatch_webhook( .await?; if !success { - todo!("error"); - Ok(()) + Err(ApiError::WebhookResponseFailure { status }) } else { Ok(()) } diff --git a/frontend/components/Help.tsx b/frontend/components/Help.tsx new file mode 100644 index 000000000..588af0231 --- /dev/null +++ b/frontend/components/Help.tsx @@ -0,0 +1,11 @@ +// Copyright 2024 the JSR authors. All rights reserved. MIT license. + +import HelpIcon from "tb-icons/TbHelp"; + +export function Help({ href }: { href: string }) { + return ( + + + + ); +} diff --git a/frontend/docs/toc.ts b/frontend/docs/toc.ts index 035922c3a..81083a48d 100644 --- a/frontend/docs/toc.ts +++ b/frontend/docs/toc.ts @@ -103,6 +103,11 @@ export default [ id: "trust", group: "Reference", }, + { + title: "Webhooks", + id: "webhooks", + group: "Reference", + }, { title: "Badges", id: "badges", diff --git a/frontend/docs/webhooks.md b/frontend/docs/webhooks.md new file mode 100644 index 000000000..d33c14bd5 --- /dev/null +++ b/frontend/docs/webhooks.md @@ -0,0 +1,216 @@ +--- +title: Webhooks +description: Webhooks allow you to receive HTTP notifications when events occur in your scope or packages. +--- + +Webhooks allow you to receive real-time HTTP notifications when events occur in +your JSR scope or packages. You can use webhooks to trigger CI/CD pipelines, +send notifications to chat services, or integrate with other tools. + +## Creating a webhook + +Webhooks can be created at two levels: + +- **Scope-level webhooks**: Receive notifications for all events in a scope and + its packages. +- **Package-level webhooks**: Receive notifications only for events related to a + specific package. + +To create a webhook: + +1. Navigate to your scope or package settings +2. Go to the "Webhooks" section +3. Click "Create webhook" +4. Configure the webhook URL, events, and optional secret + +## Events + +### Package events + +These events are triggered for specific packages. For scope-level webhooks, +these events are triggered for all packages in the scope. + +| Event | Description | +| ----------------------------------- | ------------------------------------------------- | +| `package_version_published` | A new version of a package was published | +| `package_version_yanked` | A package version was yanked or unyanked | +| `package_version_deleted` | A package version was deleted | +| `package_version_npm_tarball_ready` | The npm-compatible tarball for a version is ready | + +### Scope events + +These events are triggered at the scope level and are only available for +scope-level webhooks. + +| Event | Description | +| ------------------------ | -------------------------------------- | +| `scope_package_created` | A new package was created in the scope | +| `scope_package_deleted` | A package was deleted from the scope | +| `scope_package_archived` | A package was archived or unarchived | +| `scope_member_added` | A new member was added to the scope | +| `scope_member_removed` | A member was removed from the scope | + +## Payload format + +Webhooks support three payload formats: + +- **JSON**: Standard JSON payload +- **Discord**: Pre-formatted for Discord webhook endpoints +- **Slack**: Pre-formatted for Slack webhook endpoints + +### JSON payloads + +All JSON payloads include an `event` field that identifies the event type. The +remaining fields vary by event. + +#### `package_version_published` + +```json +{ + "event": "package_version_published", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0", + "user_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +The `user_id` field contains the UUID of the user who published the version, or +`null` if published via CI without user context. + +#### `package_version_yanked` + +```json +{ + "event": "package_version_yanked", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0", + "yanked": true +} +``` + +The `yanked` field is `true` when a version is yanked, and `false` when a +version is unyanked. + +#### `package_version_deleted` + +```json +{ + "event": "package_version_deleted", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0" +} +``` + +#### `package_version_npm_tarball_ready` + +```json +{ + "event": "package_version_npm_tarball_ready", + "scope": "myorg", + "package": "mylib", + "version": "1.0.0" +} +``` + +This event is triggered after a version is published and the npm-compatible +tarball has been built. Use this event if you need to wait for npm compatibility +before taking action. + +#### `scope_package_created` + +```json +{ + "event": "scope_package_created", + "scope": "myorg", + "package": "mylib" +} +``` + +#### `scope_package_deleted` + +```json +{ + "event": "scope_package_deleted", + "scope": "myorg", + "package": "mylib" +} +``` + +#### `scope_package_archived` + +```json +{ + "event": "scope_package_archived", + "scope": "myorg", + "package": "mylib", + "archived": true +} +``` + +The `archived` field is `true` when a package is archived, and `false` when a +package is unarchived. + +#### `scope_member_added` + +```json +{ + "event": "scope_member_added", + "scope": "myorg", + "user_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +#### `scope_member_removed` + +```json +{ + "event": "scope_member_removed", + "scope": "myorg", + "user_id": "550e8400-e29b-41d4-a716-446655440000" +} +``` + +## HTTP headers + +Each webhook request includes the following HTTP headers: + +| Header | Description | +| ----------------- | ----------------------------------------------------- | +| `X-JSR-Event` | The event type (e.g., `"package_version_published"`) | +| `X-JSR-Event-Id` | Unique identifier for this event | +| `X-JSR-Signature` | HMAC signature of the request body (if secret is set) | + +## Secrets and signature verification + +Webhook secrets allow you to verify that incoming webhook requests genuinely +originate from JSR and have not been tampered with in transit. + +### How secrets work + +When you configure a secret for your webhook: + +1. JSR computes an HMAC-SHA256 signature of the request body using your secret +2. The signature is included in the `X-JSR-Signature` header +3. Your server can verify the signature to authenticate the request + +The signature format is: `sha256=` + +### Verifying signatures + +To verify a webhook signature: + +1. Extract the signature from the `X-JSR-Signature` header +2. Compute an HMAC-SHA256 hash of the raw request body using your secret +3. Compare the computed hash with the signature from the header + +## Delivery and retries + +JSR delivers webhooks with the following behavior: + +- Webhooks are delivered asynchronously after events occur +- Failed deliveries (non-2xx responses) are retried up to 3 times +- You can view delivery history and debug failed deliveries in the webhook + settings diff --git a/frontend/islands/WebhookEdit.tsx b/frontend/islands/WebhookEdit.tsx index 2dfb587d1..fdb479ea3 100644 --- a/frontend/islands/WebhookEdit.tsx +++ b/frontend/islands/WebhookEdit.tsx @@ -2,6 +2,7 @@ import { WebhookEndpoint, WebhookEventKind } from "../utils/api_types.ts"; import { useSignal } from "@preact/signals"; import { api, path } from "../utils/api.ts"; +import { Help } from "../components/Help.tsx"; export const WEBHOOK_EVENTS: Array<{ id: WebhookEventKind; @@ -9,6 +10,12 @@ export const WEBHOOK_EVENTS: Array<{ description: string; packageLevel: boolean; }> = [ + { + id: "package_version_npm_tarball_ready", + name: "Package version NPM tarball ready", + description: "A NPM tarball for a published version is available.", + packageLevel: true, + }, { id: "package_version_published", name: "Package version published", @@ -169,6 +176,9 @@ export function WebhookEdit( className="input-container input select w-full max-w-lg block px-3 py-2 text-sm mt-3" required disabled={processing} + onChange={(e) => + payloadFormat.value = e.currentTarget + .value as WebhookEndpoint["payloadFormat"]} >