From 6666bb64ee1fee9e96b71fc2db66cb0c0df93e18 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 4 Oct 2025 19:26:24 -0500 Subject: [PATCH 01/27] register webhook --- Cargo.lock | 8 + Cargo.toml | 4 + crates/sage-api/Cargo.toml | 1 + crates/sage-api/endpoints.json | 6 +- crates/sage-api/src/requests.rs | 2 + crates/sage-api/src/requests/webhooks.rs | 33 + crates/sage/Cargo.toml | 5 + crates/sage/src/endpoints.rs | 1 + crates/sage/src/endpoints/webhooks.rs | 27 + crates/sage/src/lib.rs | 1 + crates/sage/src/sage.rs | 4 +- crates/sage/src/webhook_manager.rs | 179 +++ src-tauri/src/lib.rs | 2 + src/bindings.ts | 10 + webhook_testharness/.gitignore | 1 + webhook_testharness/app.js | 175 +++ webhook_testharness/bin/www | 90 ++ webhook_testharness/env-example.txt | 12 + webhook_testharness/package.json | 18 + webhook_testharness/pnpm-lock.yaml | 1275 +++++++++++++++++ .../public/stylesheets/style.css | 8 + webhook_testharness/routes/index.js | 9 + webhook_testharness/views/error.pug | 6 + webhook_testharness/views/index.pug | 74 + webhook_testharness/views/layout.pug | 8 + 25 files changed, 1956 insertions(+), 3 deletions(-) create mode 100644 crates/sage-api/src/requests/webhooks.rs create mode 100644 crates/sage/src/endpoints/webhooks.rs create mode 100644 crates/sage/src/webhook_manager.rs create mode 100644 webhook_testharness/.gitignore create mode 100644 webhook_testharness/app.js create mode 100755 webhook_testharness/bin/www create mode 100644 webhook_testharness/env-example.txt create mode 100644 webhook_testharness/package.json create mode 100644 webhook_testharness/pnpm-lock.yaml create mode 100644 webhook_testharness/public/stylesheets/style.css create mode 100644 webhook_testharness/routes/index.js create mode 100644 webhook_testharness/views/error.pug create mode 100644 webhook_testharness/views/index.pug create mode 100644 webhook_testharness/views/layout.pug diff --git a/Cargo.lock b/Cargo.lock index 197609c19..fbf3eb462 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1491,8 +1491,10 @@ checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -5802,13 +5804,16 @@ dependencies = [ "chia", "chia-puzzles", "chia-wallet-sdk", + "chrono", "clvmr", "hex", + "hmac", "indexmap 2.11.4", "itertools 0.13.0", "log", "rand 0.8.5", "rand_chacha 0.3.1", + "reqwest", "sage-api", "sage-assets", "sage-config", @@ -5817,6 +5822,7 @@ dependencies = [ "sage-wallet", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "thiserror 1.0.69", "tokio", @@ -5824,6 +5830,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] @@ -5832,6 +5839,7 @@ version = "0.12.4" dependencies = [ "sage-config", "serde", + "serde_json", "specta", "tauri-specta", ] diff --git a/Cargo.toml b/Cargo.toml index 29772d905..cf4c41856 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,10 @@ futures-util = "0.3.30" futures-lite = "2.3.0" sqlx = "0.8.0" reqwest = { version = "0.12.22", default-features = false } +uuid = { version = "1.10.0", features = ["v4", "serde"] } +chrono = { version = "0.4.38", features = ["serde"] } +hmac = "0.12.1" +sha2 = "0.10.8" # Utilities indexmap = "2.3.0" diff --git a/crates/sage-api/Cargo.toml b/crates/sage-api/Cargo.toml index dbbc29d07..c75a5a309 100644 --- a/crates/sage-api/Cargo.toml +++ b/crates/sage-api/Cargo.toml @@ -20,5 +20,6 @@ tauri = ["dep:tauri-specta", "dep:specta"] [dependencies] sage-config = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tauri-specta = { workspace = true, features = ["derive"], optional = true } specta = { workspace = true, features = ["derive", "bigdecimal"], optional = true } diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 277ef307a..046128f4e 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -96,5 +96,7 @@ "update_nft_collection": true, "redownload_nft": true, "increase_derivation_index": true, - "is_asset_owned": true -} + "is_asset_owned": true, + "register_webhook": true, + "unregister_webhook": true +} \ No newline at end of file diff --git a/crates/sage-api/src/requests.rs b/crates/sage-api/src/requests.rs index 99ac74a54..7e115d987 100644 --- a/crates/sage-api/src/requests.rs +++ b/crates/sage-api/src/requests.rs @@ -4,6 +4,7 @@ mod keys; mod offers; mod settings; mod transactions; +mod webhooks; pub use actions::*; pub use data::*; @@ -11,5 +12,6 @@ pub use keys::*; pub use offers::*; pub use settings::*; pub use transactions::*; +pub use webhooks::*; pub mod wallet_connect; diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs new file mode 100644 index 000000000..38f45353b --- /dev/null +++ b/crates/sage-api/src/requests/webhooks.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct RegisterWebhook { + pub url: String, + pub event_types: Vec, + pub secret: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct RegisterWebhookResponse { + pub webhook_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct UnregisterWebhook { + pub webhook_id: String, +} + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct UnregisterWebhookResponse {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookEvent { + pub id: String, + pub event_type: String, + pub timestamp: i64, + pub data: serde_json::Value, +} diff --git a/crates/sage/Cargo.toml b/crates/sage/Cargo.toml index 48b7a9b24..d14afc5d6 100644 --- a/crates/sage/Cargo.toml +++ b/crates/sage/Cargo.toml @@ -44,3 +44,8 @@ clvmr = { workspace = true } serde = { workspace = true, features = ["derive"] } bincode = { workspace = true } serde_json = { workspace = true } +reqwest = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/sage/src/endpoints.rs b/crates/sage/src/endpoints.rs index f2c605b41..b7690ff54 100644 --- a/crates/sage/src/endpoints.rs +++ b/crates/sage/src/endpoints.rs @@ -6,3 +6,4 @@ mod settings; mod themes; mod transactions; mod wallet_connect; +mod webhooks; diff --git a/crates/sage/src/endpoints/webhooks.rs b/crates/sage/src/endpoints/webhooks.rs new file mode 100644 index 000000000..e8732689f --- /dev/null +++ b/crates/sage/src/endpoints/webhooks.rs @@ -0,0 +1,27 @@ +use sage_api::{ + RegisterWebhook, RegisterWebhookResponse, UnregisterWebhook, UnregisterWebhookResponse, +}; + +use crate::{Result, Sage}; + +impl Sage { + pub async fn register_webhook(&self, req: RegisterWebhook) -> Result { + let webhook_id = self + .webhook_manager + .register_webhook(req.url, req.event_types, req.secret) + .await; + + Ok(RegisterWebhookResponse { webhook_id }) + } + + pub async fn unregister_webhook( + &self, + req: UnregisterWebhook, + ) -> Result { + self.webhook_manager + .unregister_webhook(&req.webhook_id) + .await; + + Ok(UnregisterWebhookResponse {}) + } +} diff --git a/crates/sage/src/lib.rs b/crates/sage/src/lib.rs index 5d6d4a713..61e88de17 100644 --- a/crates/sage/src/lib.rs +++ b/crates/sage/src/lib.rs @@ -5,6 +5,7 @@ mod error; mod peers; mod sage; mod utils; +mod webhook_manager; pub use error::*; pub use sage::*; diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index cc5f314b8..3bdf04aaa 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -32,7 +32,7 @@ use tracing_subscriber::{ filter::filter_fn, fmt, layer::SubscriberExt, EnvFilter, Layer, Registry, }; -use crate::{peers::Peers, Error, Result}; +use crate::{peers::Peers, webhook_manager::WebhookManager, Error, Result}; #[derive(Debug)] pub struct Sage { @@ -45,6 +45,7 @@ pub struct Sage { pub peer_state: Arc>, pub command_sender: mpsc::Sender, pub unit: Unit, + pub webhook_manager: WebhookManager, } impl Sage { @@ -59,6 +60,7 @@ impl Sage { peer_state: Arc::new(Mutex::new(PeerState::default())), command_sender: mpsc::channel(1).0, unit: XCH.clone(), + webhook_manager: WebhookManager::new(), } } diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs new file mode 100644 index 000000000..b31d8a366 --- /dev/null +++ b/crates/sage/src/webhook_manager.rs @@ -0,0 +1,179 @@ +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookConfig { + pub id: String, + pub url: String, + pub events: Vec, + pub secret: Option, + pub active: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebhookEventPayload { + pub id: String, + pub event_type: String, + pub timestamp: i64, + pub data: serde_json::Value, +} + +// Webhook manager +#[derive(Debug)] +pub struct WebhookManager { + webhooks: Arc>>, + client: Client, +} + +impl Default for WebhookManager { + fn default() -> Self { + Self { + webhooks: Arc::new(RwLock::new(HashMap::new())), + client: Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .expect("Failed to create HTTP client"), + } + } +} + +impl WebhookManager { + pub fn new() -> Self { + Self::default() + } + + // Register a new webhook + pub async fn register_webhook( + &self, + url: String, + events: Vec, + secret: Option, + ) -> String { + let id = Uuid::new_v4().to_string(); + let config = WebhookConfig { + id: id.clone(), + url, + events, + secret, + active: true, + }; + + let mut webhooks = self.webhooks.write().await; + webhooks.insert(id.clone(), config); + id + } + + pub async fn unregister_webhook(&self, id: &str) -> bool { + let mut webhooks = self.webhooks.write().await; + webhooks.remove(id).is_some() + } + + pub async fn update_webhook(&self, id: &str, active: bool) -> bool { + let mut webhooks = self.webhooks.write().await; + if let Some(webhook) = webhooks.get_mut(id) { + webhook.active = active; + true + } else { + false + } + } + + pub async fn send_event(&self, event_type: String, data: serde_json::Value) { + let event = WebhookEventPayload { + id: Uuid::new_v4().to_string(), + event_type: event_type.clone(), + timestamp: chrono::Utc::now().timestamp(), + data, + }; + + let webhooks = self.webhooks.read().await; + let interested_webhooks: Vec = webhooks + .values() + .filter(|w| w.active && w.events.contains(&event_type)) + .cloned() + .collect(); + + for webhook in interested_webhooks { + let client = self.client.clone(); + let event = event.clone(); + tokio::spawn(async move { + Self::deliver_webhook(client, webhook, event).await; + }); + } + } + + async fn deliver_webhook(client: Client, webhook: WebhookConfig, event: WebhookEventPayload) { + const MAX_RETRIES: u32 = 3; + + for attempt in 0..MAX_RETRIES { + match Self::send_webhook_request(&client, &webhook, &event).await { + Ok(_) => { + println!("✓ Webhook delivered to {}", webhook.url); + return; + } + Err(e) => { + eprintln!( + "✗ Webhook delivery failed (attempt {}/{}): {} - {}", + attempt + 1, + MAX_RETRIES, + webhook.url, + e + ); + if attempt < MAX_RETRIES - 1 { + // Exponential backoff + let delay = std::time::Duration::from_secs(2u64.pow(attempt)); + tokio::time::sleep(delay).await; + } + } + } + } + } + + async fn send_webhook_request( + client: &Client, + webhook: &WebhookConfig, + event: &WebhookEventPayload, + ) -> Result<(), Box> { + let mut request = client.post(&webhook.url).json(event); + + // Add HMAC signature if secret is configured + if let Some(secret) = &webhook.secret { + let signature = Self::generate_signature(event, secret)?; + request = request.header("X-Webhook-Signature", signature); + } + + let response = request.send().await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(format!("HTTP {}", response.status()).into()) + } + } + + fn generate_signature( + event: &WebhookEventPayload, + secret: &str, + ) -> Result> { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + type HmacSha256 = Hmac; + + let payload = serde_json::to_string(event)?; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?; + mac.update(payload.as_bytes()); + let result = mac.finalize(); + Ok(hex::encode(result.into_bytes())) + } + + // List all registered webhooks + pub async fn list_webhooks(&self) -> Vec { + let webhooks = self.webhooks.read().await; + webhooks.values().cloned().collect() + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index abc653fcf..301f532de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -139,6 +139,8 @@ pub fn run() { commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, + commands::register_webhook, + commands::unregister_webhook, ]) .events(collect_events![SyncEvent]); diff --git a/src/bindings.ts b/src/bindings.ts index 4edda3aef..4b1c8d283 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -352,6 +352,12 @@ async getLogs() : Promise { }, async isAssetOwned(req: IsAssetOwned) : Promise { return await TAURI_INVOKE("is_asset_owned", { req }); +}, +async registerWebhook(req: RegisterWebhook) : Promise { + return await TAURI_INVOKE("register_webhook", { req }); +}, +async unregisterWebhook(req: UnregisterWebhook) : Promise { + return await TAURI_INVOKE("unregister_webhook", { req }); } } @@ -541,6 +547,8 @@ export type PerformDatabaseMaintenance = { force_vacuum: boolean } export type PerformDatabaseMaintenanceResponse = { vacuum_duration_ms: number; analyze_duration_ms: number; wal_checkpoint_duration_ms: number; total_duration_ms: number; pages_vacuumed: number; wal_pages_checkpointed: number } export type RedownloadNft = { nft_id: string } export type RedownloadNftResponse = Record +export type RegisterWebhook = { url: string; event_types: string[]; secret: string | null } +export type RegisterWebhookResponse = { webhook_id: string } export type RemovePeer = { ip: string; ban: boolean } export type RenameKey = { fingerprint: number; name: string } export type RenameKeyResponse = Record @@ -590,6 +598,8 @@ export type TransferDids = { did_ids: string[]; address: string; fee: Amount; cl export type TransferNfts = { nft_ids: string[]; address: string; fee: Amount; clawback?: number | null; auto_submit?: boolean } export type TransferOptions = { option_ids: string[]; address: string; fee: Amount; clawback?: number | null; auto_submit?: boolean } export type Unit = { ticker: string; precision: number } +export type UnregisterWebhook = { webhook_id: string } +export type UnregisterWebhookResponse = Record export type UpdateCat = { record: TokenRecord } export type UpdateCatResponse = Record export type UpdateDid = { did_id: string; name: string | null; visible: boolean } diff --git a/webhook_testharness/.gitignore b/webhook_testharness/.gitignore new file mode 100644 index 000000000..2eea525d8 --- /dev/null +++ b/webhook_testharness/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/webhook_testharness/app.js b/webhook_testharness/app.js new file mode 100644 index 000000000..6109f4783 --- /dev/null +++ b/webhook_testharness/app.js @@ -0,0 +1,175 @@ +// Load environment variables from .env file +require('dotenv').config(); + +var createError = require('http-errors'); +var express = require('express'); +var path = require('path'); +var cookieParser = require('cookie-parser'); +var logger = require('morgan'); +const bodyParser = require('body-parser'); +const https = require('https'); +const fs = require('fs'); +var indexRouter = require('./routes/index'); + +var app = express(); + +// view engine setup +app.set('views', path.join(__dirname, 'views')); +app.set('view engine', 'pug'); + +app.use(logger('dev')); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use(cookieParser()); +app.use(express.static(path.join(__dirname, 'public'))); +app.use(bodyParser.json()); +app.use('/', indexRouter); + +app.post('/sage_hook', (req, res) => { + console.log(req.body); + res.status(200).end(); +}); + +// Helper function to create mTLS agent +function createMTLSAgent() { + const certPath = process.env.CLIENT_CERT_PATH; + const keyPath = process.env.CLIENT_KEY_PATH; + const cert = process.env.CLIENT_CERT; + const key = process.env.CLIENT_KEY; + + let certData, keyData; + + if (certPath && keyPath) { + // Read from files + try { + certData = fs.readFileSync(certPath, 'utf8'); + keyData = fs.readFileSync(keyPath, 'utf8'); + } catch (err) { + throw new Error(`Failed to read certificate files: ${err.message}`); + } + } else if (cert && key) { + // Use environment variables directly + certData = cert; + keyData = key; + } else { + throw new Error( + 'Either CLIENT_CERT_PATH/CLIENT_KEY_PATH or CLIENT_CERT/CLIENT_KEY environment variables must be set', + ); + } + + return new https.Agent({ + cert: certData, + key: keyData, + rejectUnauthorized: false, // Set to true if you want to verify the server certificate + }); +} + +// Proxy endpoint for registering webhook with mTLS +app.post('/proxy/register_webhook', (req, res) => { + const agent = createMTLSAgent(); + + const postData = JSON.stringify(req.body); + + const options = { + hostname: 'localhost', + port: 9257, + path: '/register_webhook', + method: 'POST', + agent: agent, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + }; + + const proxyReq = https.request(options, (proxyRes) => { + let data = ''; + + proxyRes.on('data', (chunk) => { + data += chunk; + }); + + proxyRes.on('end', () => { + try { + const jsonData = JSON.parse(data); + res.json(jsonData); + } catch (e) { + res.status(proxyRes.statusCode).send(data); + } + }); + }); + + proxyReq.on('error', (err) => { + console.error('Proxy request error:', err); + res + .status(500) + .json({ error: 'Proxy request failed', details: err.message }); + }); + + proxyReq.write(postData); + proxyReq.end(); +}); + +// Proxy endpoint for unregistering webhook with mTLS +app.post('/proxy/unregister_webhook', (req, res) => { + const agent = createMTLSAgent(); + + const postData = JSON.stringify(req.body); + + const options = { + hostname: 'localhost', + port: 9257, + path: '/unregister_webhook', + method: 'POST', + agent: agent, + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + }; + + const proxyReq = https.request(options, (proxyRes) => { + let data = ''; + + proxyRes.on('data', (chunk) => { + data += chunk; + }); + + proxyRes.on('end', () => { + try { + const jsonData = JSON.parse(data); + res.json(jsonData); + } catch (e) { + res.status(proxyRes.statusCode).send(data); + } + }); + }); + + proxyReq.on('error', (err) => { + console.error('Proxy request error:', err); + res + .status(500) + .json({ error: 'Proxy request failed', details: err.message }); + }); + + proxyReq.write(postData); + proxyReq.end(); +}); + +// catch 404 and forward to error handler +app.use(function (req, res, next) { + next(createError(404)); +}); + +// error handler +app.use(function (err, req, res, next) { + // set locals, only providing error in development + res.locals.message = err.message; + res.locals.error = req.app.get('env') === 'development' ? err : {}; + + // render the error page + res.status(err.status || 500); + res.render('error'); +}); + +module.exports = app; diff --git a/webhook_testharness/bin/www b/webhook_testharness/bin/www new file mode 100755 index 000000000..2db9106c1 --- /dev/null +++ b/webhook_testharness/bin/www @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +var app = require('../app'); +var debug = require('debug')('webhook-testharness:server'); +var http = require('http'); + +/** + * Get port from environment and store in Express. + */ + +var port = normalizePort(process.env.PORT || '3000'); +app.set('port', port); + +/** + * Create HTTP server. + */ + +var server = http.createServer(app); + +/** + * Listen on provided port, on all network interfaces. + */ + +server.listen(port); +server.on('error', onError); +server.on('listening', onListening); + +/** + * Normalize a port into a number, string, or false. + */ + +function normalizePort(val) { + var port = parseInt(val, 10); + + if (isNaN(port)) { + // named pipe + return val; + } + + if (port >= 0) { + // port number + return port; + } + + return false; +} + +/** + * Event listener for HTTP server "error" event. + */ + +function onError(error) { + if (error.syscall !== 'listen') { + throw error; + } + + var bind = typeof port === 'string' + ? 'Pipe ' + port + : 'Port ' + port; + + // handle specific listen errors with friendly messages + switch (error.code) { + case 'EACCES': + console.error(bind + ' requires elevated privileges'); + process.exit(1); + break; + case 'EADDRINUSE': + console.error(bind + ' is already in use'); + process.exit(1); + break; + default: + throw error; + } +} + +/** + * Event listener for HTTP server "listening" event. + */ + +function onListening() { + var addr = server.address(); + var bind = typeof addr === 'string' + ? 'pipe ' + addr + : 'port ' + addr.port; + debug('Listening on ' + bind); +} diff --git a/webhook_testharness/env-example.txt b/webhook_testharness/env-example.txt new file mode 100644 index 000000000..40c8a895b --- /dev/null +++ b/webhook_testharness/env-example.txt @@ -0,0 +1,12 @@ +# Option 1: Using file paths (recommended for certificates) +CLIENT_CERT_PATH="./certs/client.crt" +CLIENT_KEY_PATH="./certs/client.key" + +# Option 2: Using environment variables with \n escape sequences +# CLIENT_CERT="-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAK...\n-----END CERTIFICATE-----" +# CLIENT_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----" + +# Option 3: Using multiline strings (works with some .env parsers) +# CLIENT_CERT="-----BEGIN CERTIFICATE----- +# MIIBkTCB+wIJAK... +# -----END CERTIFICATE-----" diff --git a/webhook_testharness/package.json b/webhook_testharness/package.json new file mode 100644 index 000000000..858ed3a85 --- /dev/null +++ b/webhook_testharness/package.json @@ -0,0 +1,18 @@ +{ + "name": "webhook-testharness", + "version": "0.0.0", + "private": true, + "scripts": { + "start": "node ./bin/www" + }, + "dependencies": { + "body-parser": "^2.2.0", + "cookie-parser": "~1.4.4", + "debug": "~2.6.9", + "dotenv": "^17.2.3", + "express": "~4.16.4", + "http-errors": "~1.6.3", + "morgan": "~1.9.1", + "pug": "2.0.0-beta11" + } +} diff --git a/webhook_testharness/pnpm-lock.yaml b/webhook_testharness/pnpm-lock.yaml new file mode 100644 index 000000000..2c308d661 --- /dev/null +++ b/webhook_testharness/pnpm-lock.yaml @@ -0,0 +1,1275 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + body-parser: + specifier: ^2.2.0 + version: 2.2.0 + cookie-parser: + specifier: ~1.4.4 + version: 1.4.7 + debug: + specifier: ~2.6.9 + version: 2.6.9 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + express: + specifier: ~4.16.4 + version: 4.16.4 + http-errors: + specifier: ~1.6.3 + version: 1.6.3 + morgan: + specifier: ~1.9.1 + version: 1.9.1 + pug: + specifier: 2.0.0-beta11 + version: 2.0.0-beta11 + +packages: + + '@types/babel-types@7.0.16': + resolution: {integrity: sha512-5QXs9GBFTNTmilLlWBhnsprqpjfrotyrnzUdwDrywEL/DA4LuCWQT300BTOXA3Y9ngT9F2uvmCoIxI6z8DlJEA==} + + '@types/babylon@6.16.9': + resolution: {integrity: sha512-sEKyxMVEowhcr8WLfN0jJYe4gS4Z9KC2DGz0vqfC7+MXFbmvOF7jSjALC77thvAO2TLgFUPa9vDeOak+AcUrZA==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-globals@3.1.0: + resolution: {integrity: sha512-uWttZCk96+7itPxK8xCzY86PnxKTMrReKDqrHzv42VQY0K30PUO8WY13WMOuI+cOdX4EIdzdvQ8k6jkuGRFMYw==} + + acorn@3.3.0: + resolution: {integrity: sha512-OLUyIIZ7mF5oaAUT1w0TFqQS81q3saT46x8t7ukpPjMNk+nbs4ZHhs7ToV8EWnLYLepjETXd4XaCE4uxkMeqUw==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@4.0.13: + resolution: {integrity: sha512-fu2ygVGuMmlzG8ZeRJ0bvR41nsAkxxhbyk8bZ1SS521Z7vmgJFTQQlfz/Mp/nJexGBz+v8sC9bM6+lNgskt4Ug==} + engines: {node: '>=0.4.0'} + hasBin: true + + align-text@0.1.4: + resolution: {integrity: sha512-GrTZLRpmp6wIC2ztrWW9MjjTgSKccffgFagbNDOX95/dcjEcYZibYTeaOntySQLcdw1ztBoFkviiUvTMbb9MYg==} + engines: {node: '>=0.10.0'} + + amdefine@1.0.1: + resolution: {integrity: sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==} + engines: {node: '>=0.4.2'} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + babel-runtime@6.26.0: + resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + + babel-types@6.26.0: + resolution: {integrity: sha512-zhe3V/26rCWsEZK8kZN+HaQj5yQ1CilTObixFzKW1UWjqG7618Twz6YEsCnjfg5gBcJh02DrpCkS9h98ZqDY+g==} + + babylon@6.18.0: + resolution: {integrity: sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==} + hasBin: true + + basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + + body-parser@1.18.3: + resolution: {integrity: sha512-YQyoqQG3sO8iCmf8+hyVpgHHOv0/hCEFiS4zTGUwTA1HjAFX66wRcNQrVCeJq9pgESMRvUAOvSil5MJlmccuKQ==} + engines: {node: '>= 0.8'} + + body-parser@2.2.0: + resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} + engines: {node: '>=18'} + + bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + camelcase@1.2.1: + resolution: {integrity: sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g==} + engines: {node: '>=0.10.0'} + + center-align@0.1.3: + resolution: {integrity: sha512-Baz3aNe2gd2LP2qk5U+sDk/m4oSuwSDcBfayTCTBoWpfIGO5XFxPmjILQII4NGiZjD6DoDI6kf7gKaxkf7s3VQ==} + engines: {node: '>=0.10.0'} + + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + + clean-css@3.4.28: + resolution: {integrity: sha512-aTWyttSdI2mYi07kWqHi24NUU9YlELFKGOAgFzZjDN1064DMAOy2FBuoyGmkKRlXkbpXd0EVHmiVkbKhKoirTw==} + engines: {node: '>=0.10.0'} + hasBin: true + + cliui@2.1.0: + resolution: {integrity: sha512-GIOYRizG+TGoc7Wgc1LiOTLare95R3mzKgoln+Q/lE4ceiYH19gUpl0l0Ffq4lJDEf3FxujMe6IBfOCs7pfqNA==} + + commander@2.8.1: + resolution: {integrity: sha512-+pJLBFVk+9ZZdlAOB5WuIElVPPth47hILFkmGym57aq8kwxsowvByvB0DHs1vQAhyMZzdcpTtF0VDKGkSDR4ZQ==} + engines: {node: '>= 0.6.x'} + + constantinople@3.1.2: + resolution: {integrity: sha512-yePcBqEFhLOqSBtwYOGGS1exHo/s1xjekXiinh4itpNQGCu4KA1euPh1fg07N2wMITZXQkBz75Ntdt1ctGZouw==} + + content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie@0.3.1: + resolution: {integrity: sha512-+IJOX0OqlHCszo2mBUq+SrEbCj6w7Kpffqx60zYbPTFaO4+yYgRjHwcZNpWvaTylDHaV7PPmBHzSecZiMhtPgw==} + engines: {node: '>= 0.6'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + core-js@2.6.12: + resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + depd@1.1.2: + resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} + engines: {node: '>= 0.6'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.0.4: + resolution: {integrity: sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg==} + + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@4.16.4: + resolution: {integrity: sha512-j12Uuyb4FMrd/qQAm6uCHAkPtO8FDTRJZBDd5D2KOL2eLaz1yUNdUB/NOIyq0iU4q4cFarsUCrnFDPBcnksuOg==} + engines: {node: '>= 0.10.0'} + + finalhandler@1.1.1: + resolution: {integrity: sha512-Y1GUDo39ez4aHAw7MysnUD5JzYX+WaIj8I57kO3aEPT1fFRL4sr7mjei97FgnwhAyyzRYmQZaTHb2+9uZ1dPtg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-readlink@1.0.1: + resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + http-errors@1.6.3: + resolution: {integrity: sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==} + engines: {node: '>= 0.6'} + + http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.4.23: + resolution: {integrity: sha512-neyTUVFtahjf0mB3dZT77u+8O0QB89jFdnBkd5P1JgYPbPaia3gXXOVL2fq8VyU2gMMD7SaN7QukTB/pmXYvDA==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.0: + resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} + engines: {node: '>=0.10.0'} + + inherits@2.0.3: + resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-buffer@1.1.6: + resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-expression@3.0.0: + resolution: {integrity: sha512-vyMeQMq+AiH5uUnoBfMTwf18tO3bM6k1QXBE9D6ueAAquEfCZe3AJPtud9g6qS0+4X8xA7ndpZiDyeb2l2qOBw==} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + + kind-of@3.2.2: + resolution: {integrity: sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==} + engines: {node: '>=0.10.0'} + + lazy-cache@1.0.4: + resolution: {integrity: sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==} + engines: {node: '>=0.10.0'} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + longest@1.0.1: + resolution: {integrity: sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg==} + engines: {node: '>=0.10.0'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.1: + resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} + engines: {node: '>= 0.6'} + + mime@1.4.1: + resolution: {integrity: sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==} + hasBin: true + + morgan@1.9.1: + resolution: {integrity: sha512-HQStPIV4y3afTiCYVxirakhlCfGkI161c76kKFca7Fk1JusM//Qeo1ej2XaMniiNeaZklMVrh3vTtIzpzwbpmA==} + engines: {node: '>= 0.8.0'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-to-regexp@0.1.7: + resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + pug-attrs@2.0.4: + resolution: {integrity: sha512-TaZ4Z2TWUPDJcV3wjU3RtUXMrd3kM4Wzjbe3EWnSsZPsJ3LDI0F3yCnf2/W7PPFF+edUFQ0HgDL1IoxSz5K8EQ==} + + pug-code-gen@1.1.1: + resolution: {integrity: sha512-UwZaJVhjhy2kYntLqXjSV1ae+K96ve6bG+N5bLFfA6yyGJTEkguct19MWDyUM9D8CDU3NNxVctUAh5McF19E6w==} + + pug-error@1.3.3: + resolution: {integrity: sha512-qE3YhESP2mRAWMFJgKdtT5D7ckThRScXRwkfo+Erqga7dyJdY3ZquspprMCj/9sJ2ijm5hXFWQE/A3l4poMWiQ==} + + pug-filters@2.1.5: + resolution: {integrity: sha512-xkw71KtrC4sxleKiq+cUlQzsiLn8pM5+vCgkChW2E6oNOzaqTSIBKIQ5cl4oheuDzvJYCTSYzRaVinMUrV4YLQ==} + + pug-lexer@3.1.0: + resolution: {integrity: sha512-DxXOrmCIDVEwzN2ozZBK1t4QRTR6pLv5YkqM6dLdaSHnm+LJJRBngVn4IDMMBZQR9xUpxrRm9rffmku2OEqkJw==} + + pug-linker@2.0.3: + resolution: {integrity: sha512-ZqKljvFUl1K5L4G5WABJ5FUYWOY0K2AXLmwj2QfM7nPCUcxfsmr05SikjgXGXVoIrygGzM/iWSsXwnkWId4AHw==} + + pug-load@2.0.12: + resolution: {integrity: sha512-UqpgGpyyXRYgJs/X60sE6SIf8UBsmcHYKNaOccyVLEuT6OPBIMo6xMPhoJnqtB3Q3BbO4Z3Bjz5qDsUWh4rXsg==} + + pug-parser@2.0.2: + resolution: {integrity: sha512-PW8kKDLN07MbFljR/GaYHPBGW+64YldtFFZUEGltJ67RRzebI/DxZy4njlxacy9JeheosyVprZ9C5DIexG1D/Q==} + + pug-runtime@2.0.5: + resolution: {integrity: sha512-P+rXKn9un4fQY77wtpcuFyvFaBww7/91f3jHa154qU26qFAnOe6SW1CbIDcxiG5lLK9HazYrMCCuDvNgDQNptw==} + + pug-strip-comments@1.0.4: + resolution: {integrity: sha512-i5j/9CS4yFhSxHp5iKPHwigaig/VV9g+FgReLJWWHEHbvKsbqL0oP/K5ubuLco6Wu3Kan5p7u7qk8A4oLLh6vw==} + + pug-walk@1.1.8: + resolution: {integrity: sha512-GMu3M5nUL3fju4/egXwZO0XLi6fW/K3T3VTgFQ14GxNi8btlxgT5qZL//JwZFm/2Fa64J/PNS8AZeys3wiMkVA==} + + pug@2.0.0-beta11: + resolution: {integrity: sha512-iV0ibDCWLJGw8eEtBKAqbJZecOabQa6hpFeH+GCBzsAsCNSvpjo4wuHMPcmqtaZhxoO3ElbMePf8jkrM9TKulw==} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + qs@6.5.2: + resolution: {integrity: sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.3.3: + resolution: {integrity: sha512-9esiElv1BrZoI3rCDuOuKCBRbuApGGaDPQfjSflGxdy4oyzqghxu6klEkkVIvBje+FF0BX9coEv8KqW6X/7njw==} + engines: {node: '>= 0.8'} + + raw-body@3.0.1: + resolution: {integrity: sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==} + engines: {node: '>= 0.10'} + + regenerator-runtime@0.11.1: + resolution: {integrity: sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + resolve@1.22.10: + resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} + engines: {node: '>= 0.4'} + hasBin: true + + right-align@0.1.3: + resolution: {integrity: sha512-yqINtL/G7vs2v+dFIZmFUDbnVyFUJFKd6gK22Kgo6R4jfJGFtisKyncWDDULgjfqf4ASQuIQyjJ7XZ+3aWpsAg==} + engines: {node: '>=0.10.0'} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@0.16.2: + resolution: {integrity: sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==} + engines: {node: '>= 0.8.0'} + + serve-static@1.13.2: + resolution: {integrity: sha512-p/tdJrO4U387R9oMjb1oj7qSMaMfmOyd4j9hOFoxZe2baQszgHcSWjuya/CiT5kgZZKRudHNOA0pYXOl8rQ5nw==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.1.0: + resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map@0.4.4: + resolution: {integrity: sha512-Y8nIfcb1s/7DcobUz1yOO1GSp7gyL+D9zLHDehT7iRESqGSxjJ448Sg7rvfgsRJCnKLdSl11uGf0s9X80cH0/A==} + engines: {node: '>=0.8.0'} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + statuses@1.4.0: + resolution: {integrity: sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==} + engines: {node: '>= 0.6'} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + to-fast-properties@1.0.3: + resolution: {integrity: sha512-lxrWP8ejsq+7E3nNjwYmUBMAgjMTZoTI+sdBOpvNyijeDLa29LUn9QaoXAHv4+Z578hbmHHJKZknzxVtvo77og==} + engines: {node: '>=0.10.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-stream@0.0.1: + resolution: {integrity: sha512-nfjOAu/zAWmX9tgwi5NRp7O7zTDUD1miHiB40klUnAh9qnL1iXdgzcz/i5dMaL5jahcBAaSfmNOBBJBLJW8TEg==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + uglify-js@2.8.29: + resolution: {integrity: sha512-qLq/4y2pjcU3vhlhseXGGJ7VbFO4pBANu0kwl8VCa9KEI0V8VfZIx2Fy3w01iSTA/pGwKZSmu/+I4etLNDdt5w==} + engines: {node: '>=0.8.0'} + hasBin: true + + uglify-to-browserify@1.0.2: + resolution: {integrity: sha512-vb2s1lYx2xBtUgy+ta+b2J/GLVUR+wmpINwHePmPRhOsIVCG2wDzKJ0n14GslH1BifsqVzSOwQhRaCAsZ/nI4Q==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + void-elements@2.0.1: + resolution: {integrity: sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==} + engines: {node: '>=0.10.0'} + + window-size@0.1.0: + resolution: {integrity: sha512-1pTPQDKTdd61ozlKGNCjhNRd+KPmgLSGa3mZTHoOliaGcESD8G1PXhh7c1fgiPjVbNVfgy2Faw4BI8/m0cC8Mg==} + engines: {node: '>= 0.8.0'} + + with@5.1.1: + resolution: {integrity: sha512-uAnSsFGfSpF6DNhBXStvlZILfHJfJu4eUkfbRGk94kGO1Ta7bg6FwfvoOhhyHAJuFbCw+0xk4uJ3u57jLvlCJg==} + + wordwrap@0.0.2: + resolution: {integrity: sha512-xSBsCeh+g+dinoBv3GAOWM4LcVVO68wLXRanibtBSdUvkGWQRGeE9P7IwU9EmDDi4jA6L44lz15CGMwdw9N5+Q==} + engines: {node: '>=0.4.0'} + + yargs@3.10.0: + resolution: {integrity: sha512-QFzUah88GAGy9lyDKGBqZdkYApt63rCXYBGYnEP4xDJPXNqXXnBDACnbrXnViV6jRSqAePwrATi2i8mfYm4L1A==} + +snapshots: + + '@types/babel-types@7.0.16': {} + + '@types/babylon@6.16.9': + dependencies: + '@types/babel-types': 7.0.16 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-globals@3.1.0: + dependencies: + acorn: 4.0.13 + + acorn@3.3.0: {} + + acorn@4.0.13: {} + + align-text@0.1.4: + dependencies: + kind-of: 3.2.2 + longest: 1.0.1 + repeat-string: 1.6.1 + + amdefine@1.0.1: {} + + array-flatten@1.1.1: {} + + asap@2.0.6: {} + + babel-runtime@6.26.0: + dependencies: + core-js: 2.6.12 + regenerator-runtime: 0.11.1 + + babel-types@6.26.0: + dependencies: + babel-runtime: 6.26.0 + esutils: 2.0.3 + lodash: 4.17.21 + to-fast-properties: 1.0.3 + + babylon@6.18.0: {} + + basic-auth@2.0.1: + dependencies: + safe-buffer: 5.1.2 + + body-parser@1.18.3: + dependencies: + bytes: 3.0.0 + content-type: 1.0.5 + debug: 2.6.9 + depd: 1.1.2 + http-errors: 1.6.3 + iconv-lite: 0.4.23 + on-finished: 2.3.0 + qs: 6.5.2 + raw-body: 2.3.3 + type-is: 1.6.18 + transitivePeerDependencies: + - supports-color + + body-parser@2.2.0: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.0 + iconv-lite: 0.6.3 + on-finished: 2.4.1 + qs: 6.14.0 + raw-body: 3.0.1 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + bytes@3.0.0: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + camelcase@1.2.1: {} + + center-align@0.1.3: + dependencies: + align-text: 0.1.4 + lazy-cache: 1.0.4 + + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + + clean-css@3.4.28: + dependencies: + commander: 2.8.1 + source-map: 0.4.4 + + cliui@2.1.0: + dependencies: + center-align: 0.1.3 + right-align: 0.1.3 + wordwrap: 0.0.2 + + commander@2.8.1: + dependencies: + graceful-readlink: 1.0.1 + + constantinople@3.1.2: + dependencies: + '@types/babel-types': 7.0.16 + '@types/babylon': 6.16.9 + babel-types: 6.26.0 + babylon: 6.18.0 + + content-disposition@0.5.2: {} + + content-type@1.0.5: {} + + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie@0.3.1: {} + + cookie@0.7.2: {} + + core-js@2.6.12: {} + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + depd@1.1.2: {} + + depd@2.0.0: {} + + destroy@1.0.4: {} + + doctypes@1.1.0: {} + + dotenv@17.2.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@1.0.2: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + express@4.16.4: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.18.3 + content-disposition: 0.5.2 + content-type: 1.0.5 + cookie: 0.3.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 1.1.2 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.1.1 + fresh: 0.5.2 + merge-descriptors: 1.0.1 + methods: 1.1.2 + on-finished: 2.3.0 + parseurl: 1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: 2.0.7 + qs: 6.5.2 + range-parser: 1.2.1 + safe-buffer: 5.1.2 + send: 0.16.2 + serve-static: 1.13.2 + setprototypeof: 1.1.0 + statuses: 1.4.0 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@1.1.1: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.4.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + graceful-readlink@1.0.1: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + http-errors@1.6.3: + dependencies: + depd: 1.1.2 + inherits: 2.0.3 + setprototypeof: 1.1.0 + statuses: 1.5.0 + + http-errors@2.0.0: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + iconv-lite@0.4.23: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.0: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.3: {} + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-buffer@1.1.6: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-expression@3.0.0: + dependencies: + acorn: 4.0.13 + object-assign: 4.1.1 + + is-promise@2.2.2: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + js-stringify@1.0.2: {} + + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + + kind-of@3.2.2: + dependencies: + is-buffer: 1.1.6 + + lazy-cache@1.0.4: {} + + lodash@4.17.21: {} + + longest@1.0.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@1.0.1: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.1: + dependencies: + mime-db: 1.54.0 + + mime@1.4.1: {} + + morgan@1.9.1: + dependencies: + basic-auth: 2.0.1 + debug: 2.6.9 + depd: 1.1.2 + on-finished: 2.3.0 + on-headers: 1.0.2 + transitivePeerDependencies: + - supports-color + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@0.6.3: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + on-headers@1.0.2: {} + + parseurl@1.3.3: {} + + path-parse@1.0.7: {} + + path-to-regexp@0.1.7: {} + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + pug-attrs@2.0.4: + dependencies: + constantinople: 3.1.2 + js-stringify: 1.0.2 + pug-runtime: 2.0.5 + + pug-code-gen@1.1.1: + dependencies: + constantinople: 3.1.2 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 2.0.4 + pug-error: 1.3.3 + pug-runtime: 2.0.5 + void-elements: 2.0.1 + with: 5.1.1 + + pug-error@1.3.3: {} + + pug-filters@2.1.5: + dependencies: + clean-css: 3.4.28 + constantinople: 3.1.2 + jstransformer: 1.0.0 + pug-error: 1.3.3 + pug-walk: 1.1.8 + resolve: 1.22.10 + uglify-js: 2.8.29 + + pug-lexer@3.1.0: + dependencies: + character-parser: 2.2.0 + is-expression: 3.0.0 + pug-error: 1.3.3 + + pug-linker@2.0.3: + dependencies: + pug-error: 1.3.3 + pug-walk: 1.1.8 + + pug-load@2.0.12: + dependencies: + object-assign: 4.1.1 + pug-walk: 1.1.8 + + pug-parser@2.0.2: + dependencies: + pug-error: 1.3.3 + token-stream: 0.0.1 + + pug-runtime@2.0.5: {} + + pug-strip-comments@1.0.4: + dependencies: + pug-error: 1.3.3 + + pug-walk@1.1.8: {} + + pug@2.0.0-beta11: + dependencies: + pug-code-gen: 1.1.1 + pug-filters: 2.1.5 + pug-lexer: 3.1.0 + pug-linker: 2.0.3 + pug-load: 2.0.12 + pug-parser: 2.0.2 + pug-runtime: 2.0.5 + pug-strip-comments: 1.0.4 + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + qs@6.5.2: {} + + range-parser@1.2.1: {} + + raw-body@2.3.3: + dependencies: + bytes: 3.0.0 + http-errors: 1.6.3 + iconv-lite: 0.4.23 + unpipe: 1.0.0 + + raw-body@3.0.1: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.7.0 + unpipe: 1.0.0 + + regenerator-runtime@0.11.1: {} + + repeat-string@1.6.1: {} + + resolve@1.22.10: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + right-align@0.1.3: + dependencies: + align-text: 0.1.4 + + safe-buffer@5.1.2: {} + + safer-buffer@2.1.2: {} + + send@0.16.2: + dependencies: + debug: 2.6.9 + depd: 1.1.2 + destroy: 1.0.4 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 1.6.3 + mime: 1.4.1 + ms: 2.0.0 + on-finished: 2.3.0 + range-parser: 1.2.1 + statuses: 1.4.0 + transitivePeerDependencies: + - supports-color + + serve-static@1.13.2: + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.16.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.1.0: {} + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map@0.4.4: + dependencies: + amdefine: 1.0.1 + + source-map@0.5.7: {} + + statuses@1.4.0: {} + + statuses@1.5.0: {} + + statuses@2.0.1: {} + + supports-preserve-symlinks-flag@1.0.0: {} + + to-fast-properties@1.0.3: {} + + toidentifier@1.0.1: {} + + token-stream@0.0.1: {} + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.1 + + uglify-js@2.8.29: + dependencies: + source-map: 0.5.7 + yargs: 3.10.0 + optionalDependencies: + uglify-to-browserify: 1.0.2 + + uglify-to-browserify@1.0.2: + optional: true + + unpipe@1.0.0: {} + + utils-merge@1.0.1: {} + + vary@1.1.2: {} + + void-elements@2.0.1: {} + + window-size@0.1.0: {} + + with@5.1.1: + dependencies: + acorn: 3.3.0 + acorn-globals: 3.1.0 + + wordwrap@0.0.2: {} + + yargs@3.10.0: + dependencies: + camelcase: 1.2.1 + cliui: 2.1.0 + decamelize: 1.2.0 + window-size: 0.1.0 diff --git a/webhook_testharness/public/stylesheets/style.css b/webhook_testharness/public/stylesheets/style.css new file mode 100644 index 000000000..9453385b9 --- /dev/null +++ b/webhook_testharness/public/stylesheets/style.css @@ -0,0 +1,8 @@ +body { + padding: 50px; + font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; +} + +a { + color: #00B7FF; +} diff --git a/webhook_testharness/routes/index.js b/webhook_testharness/routes/index.js new file mode 100644 index 000000000..6a15086a3 --- /dev/null +++ b/webhook_testharness/routes/index.js @@ -0,0 +1,9 @@ +var express = require('express'); +var router = express.Router(); + +/* GET home page. */ +router.get('/', function (req, res, next) { + res.render('index', { title: 'Sage Webhooks' }); +}); + +module.exports = router; diff --git a/webhook_testharness/views/error.pug b/webhook_testharness/views/error.pug new file mode 100644 index 000000000..51ec12c6a --- /dev/null +++ b/webhook_testharness/views/error.pug @@ -0,0 +1,6 @@ +extends layout + +block content + h1= message + h2= error.status + pre #{error.stack} diff --git a/webhook_testharness/views/index.pug b/webhook_testharness/views/index.pug new file mode 100644 index 000000000..6f305d128 --- /dev/null +++ b/webhook_testharness/views/index.pug @@ -0,0 +1,74 @@ +extends layout + +block content + h1= title + + button#register-btn(onclick="registerWebhook()") Register Webhook + button#unregister-btn(onclick="unregisterWebhook()" style="display: none;") Unregister Webhook + + h2 Response: + pre#response-display No response yet + + script. + let webhookId = null; + + function updateButtonStates() { + if (webhookId) { + $('#register-btn').hide(); + $('#unregister-btn').show(); + } else { + $('#register-btn').show(); + $('#unregister-btn').hide(); + } + } + + function registerWebhook() { + fetch('/proxy/register_webhook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: 'http://localhost:3000/sage_hook', event_types:['coin_state','puzzle_batch_synced','nft_data'] }), + }) + .then(response => response.json()) + .then(data => { + if (data.webhook_id) { + webhookId = data.webhook_id; + updateButtonStates(); + } + $('#response-display').text(JSON.stringify(data, null, 2)); + }) + .catch(error => { + console.error('Error:', error); + $('#response-display').text('Error: ' + error.message); + }); + } + + function unregisterWebhook() { + if (!webhookId) { + $('#response-display').text('Error: No webhook_id available. Please register a webhook first.'); + return; + } + + fetch('/proxy/unregister_webhook', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ webhook_id: webhookId }), + }) + .then(response => response.json()) + .then(data => { + webhookId = null; + updateButtonStates(); + $('#response-display').text(JSON.stringify(data, null, 2)); + }) + .catch(error => { + console.error('Error:', error); + $('#response-display').text('Error: ' + error.message); + }); + } + + $(document).ready(function() { + updateButtonStates(); + }); \ No newline at end of file diff --git a/webhook_testharness/views/layout.pug b/webhook_testharness/views/layout.pug new file mode 100644 index 000000000..d2e2d5e80 --- /dev/null +++ b/webhook_testharness/views/layout.pug @@ -0,0 +1,8 @@ +doctype html +html + head + title= title + link(rel='stylesheet', href='/stylesheets/style.css') + script(src="https://code.jquery.com/jquery-3.7.1.min.js") + body + block content From 86ca03fab807184acd0a32967c9dc818126f2833 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 4 Oct 2025 19:57:09 -0500 Subject: [PATCH 02/27] https://github.com/xch-dev/sage/issues/628 --- crates/sage-api/endpoints.json | 2 +- crates/sage/src/sage.rs | 81 ++++++++++++++++- crates/sage/src/webhook_manager.rs | 2 +- webhook_testharness/app.js | 62 +++++++++++++ webhook_testharness/bin/www | 8 +- .../public/stylesheets/style.css | 8 +- webhook_testharness/views/index.pug | 91 ++++++++++++++++++- webhook_testharness/views/layout.pug | 1 + 8 files changed, 243 insertions(+), 12 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 046128f4e..07c87264d 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -99,4 +99,4 @@ "is_asset_owned": true, "register_webhook": true, "unregister_webhook": true -} \ No newline at end of file +} diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index 3bdf04aaa..59f9115bf 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -240,7 +240,86 @@ impl Sage { tokio::spawn(sync_manager.sync()); self.command_sender = command_sender; - Ok(receiver) + // Create a broadcast channel to split events between Tauri app and webhook consumer + let (tx, _rx) = tokio::sync::broadcast::channel(100); + + // Create a receiver for the Tauri app before we move tx + let tauri_receiver = tx.subscribe(); + + // Spawn a task that forwards events from the sync manager to the broadcast channel + let webhook_manager = self.webhook_manager.clone(); + tokio::spawn(async move { + let mut receiver = receiver; + while let Some(event) = receiver.recv().await { + // Send to broadcast channel (for Tauri app) + let _ = tx.send(event.clone()); + + // Also handle for webhooks directly + Self::handle_sync_event_for_webhooks(&webhook_manager, event).await; + } + }); + + // Convert broadcast receiver to mpsc receiver for compatibility + let (tauri_tx, tauri_rx) = mpsc::channel(100); + tokio::spawn(async move { + let mut tauri_receiver = tauri_receiver; + while let Ok(event) = tauri_receiver.recv().await { + let _ = tauri_tx.send(event).await; + } + }); + + Ok(tauri_rx) + } + + async fn handle_sync_event_for_webhooks(webhook_manager: &WebhookManager, event: SyncEvent) { + let (event_type, data) = match event { + SyncEvent::Start(ip) => ( + "start", + serde_json::json!({ + "ip": ip.to_string() + }), + ), + SyncEvent::Stop => ("stop", serde_json::json!({})), + SyncEvent::Subscribed => ("subscribed", serde_json::json!({})), + SyncEvent::DerivationIndex { next_index } => ( + "derivation_index", + serde_json::json!({ + "next_index": next_index + }), + ), + SyncEvent::CoinsUpdated => ("coin_state", serde_json::json!({})), + SyncEvent::TransactionUpdated { transaction_id } => ( + "transaction_updated", + serde_json::json!({ + "transaction_id": transaction_id.to_string() + }), + ), + SyncEvent::TransactionFailed { + transaction_id, + error, + } => ( + "transaction_failed", + serde_json::json!({ + "transaction_id": transaction_id.to_string(), + "error": error + }), + ), + SyncEvent::OfferUpdated { offer_id, status } => ( + "offer_updated", + serde_json::json!({ + "offer_id": offer_id.to_string(), + "status": format!("{:?}", status) + }), + ), + SyncEvent::PuzzleBatchSynced => ("puzzle_batch_synced", serde_json::json!({})), + SyncEvent::CatInfo => ("cat_info", serde_json::json!({})), + SyncEvent::DidInfo => ("did_info", serde_json::json!({})), + SyncEvent::NftData => ("nft_data", serde_json::json!({})), + }; + + webhook_manager + .send_event(event_type.to_string(), data) + .await; } pub async fn switch_network(&mut self) -> Result<()> { diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index b31d8a366..b5cc9ba6c 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -23,7 +23,7 @@ pub struct WebhookEventPayload { } // Webhook manager -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct WebhookManager { webhooks: Arc>>, client: Client, diff --git a/webhook_testharness/app.js b/webhook_testharness/app.js index 6109f4783..5236f4da5 100644 --- a/webhook_testharness/app.js +++ b/webhook_testharness/app.js @@ -11,6 +11,9 @@ const https = require('https'); const fs = require('fs'); var indexRouter = require('./routes/index'); +// Store SSE connections for broadcasting webhook events +const sseConnections = new Set(); + var app = express(); // view engine setup @@ -27,9 +30,68 @@ app.use('/', indexRouter); app.post('/sage_hook', (req, res) => { console.log(req.body); + + // Broadcast the webhook event to all SSE connections + const eventData = { + id: Date.now(), + event: 'webhook', + data: JSON.stringify({ + timestamp: new Date().toISOString(), + body: req.body, + }), + }; + + broadcastSSEEvent(eventData); + res.status(200).end(); }); +// SSE endpoint for webhook events +app.get('/events', (req, res) => { + // Set SSE headers + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Headers': 'Cache-Control', + }); + + // Send initial connection event + res.write( + `data: ${JSON.stringify({ + id: Date.now(), + event: 'connected', + data: 'Connected to webhook event stream', + })}\n\n`, + ); + + // Add this connection to our set + sseConnections.add(res); + + // Handle client disconnect + req.on('close', () => { + sseConnections.delete(res); + console.log('SSE client disconnected'); + }); + + console.log('SSE client connected'); +}); + +// Function to broadcast events to all SSE connections +function broadcastSSEEvent(eventData) { + const message = `id: ${eventData.id}\nevent: ${eventData.event}\ndata: ${eventData.data}\n\n`; + + sseConnections.forEach((res) => { + try { + res.write(message); + } catch (error) { + console.error('Error sending SSE message:', error); + sseConnections.delete(res); + } + }); +} + // Helper function to create mTLS agent function createMTLSAgent() { const certPath = process.env.CLIENT_CERT_PATH; diff --git a/webhook_testharness/bin/www b/webhook_testharness/bin/www index 2db9106c1..a08c7fbe7 100755 --- a/webhook_testharness/bin/www +++ b/webhook_testharness/bin/www @@ -58,9 +58,7 @@ function onError(error) { throw error; } - var bind = typeof port === 'string' - ? 'Pipe ' + port - : 'Port ' + port; + var bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; // handle specific listen errors with friendly messages switch (error.code) { @@ -83,8 +81,6 @@ function onError(error) { function onListening() { var addr = server.address(); - var bind = typeof addr === 'string' - ? 'pipe ' + addr - : 'port ' + addr.port; + var bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port; debug('Listening on ' + bind); } diff --git a/webhook_testharness/public/stylesheets/style.css b/webhook_testharness/public/stylesheets/style.css index 9453385b9..9333bb431 100644 --- a/webhook_testharness/public/stylesheets/style.css +++ b/webhook_testharness/public/stylesheets/style.css @@ -1,8 +1,12 @@ body { padding: 50px; - font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; + font: + 14px 'Lucida Grande', + Helvetica, + Arial, + sans-serif; } a { - color: #00B7FF; + color: #00b7ff; } diff --git a/webhook_testharness/views/index.pug b/webhook_testharness/views/index.pug index 6f305d128..3eb65f033 100644 --- a/webhook_testharness/views/index.pug +++ b/webhook_testharness/views/index.pug @@ -9,6 +9,9 @@ block content h2 Response: pre#response-display No response yet + h2 Live Webhook Events: + pre#webhook-events No webhook events yet + script. let webhookId = null; @@ -28,7 +31,7 @@ block content headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: 'http://localhost:3000/sage_hook', event_types:['coin_state','puzzle_batch_synced','nft_data'] }), + body: JSON.stringify({ url: 'http://localhost:3000/sage_hook', event_types:['start', 'stop', 'subscribed', 'derivation', 'coin_state','puzzle_batch_synced','nft_data'] }), }) .then(response => response.json()) .then(data => { @@ -69,6 +72,92 @@ block content }); } + let eventSource = null; + let webhookEventCount = 0; + + function setupEventSource() { + // Create EventSource connection to receive webhook events + eventSource = new EventSource('/events'); + + eventSource.onopen = function(event) { + console.log('SSE connection opened'); + $('#webhook-events').text('Connected to webhook event stream. Waiting for events...'); + }; + + eventSource.onmessage = function(event) { + console.log('SSE message received:', event); + try { + const data = JSON.parse(event.data); + displayWebhookEvent(data); + } catch (error) { + console.error('Error parsing SSE data:', error); + } + }; + + eventSource.addEventListener('webhook', function(event) { + console.log('Webhook event received:', event); + try { + const data = JSON.parse(event.data); + displayWebhookEvent(data); + } catch (error) { + console.error('Error parsing webhook event:', error); + } + }); + + eventSource.addEventListener('connected', function(event) { + console.log('SSE connected:', event); + displayWebhookEvent({ + event: 'connected', + data: event.data, + timestamp: new Date().toISOString() + }); + }); + + eventSource.onerror = function(event) { + console.error('SSE error:', event); + $('#webhook-events').append('\n[ERROR] Connection lost. Attempting to reconnect...'); + + // Close current connection + if (eventSource) { + eventSource.close(); + } + + // Attempt to reconnect after 3 seconds + setTimeout(function() { + setupEventSource(); + }, 3000); + }; + } + + function displayWebhookEvent(eventData) { + webhookEventCount++; + const timestamp = new Date().toLocaleTimeString(); + + const eventHeader = `[${timestamp}] Event #${webhookEventCount} (${eventData.event}):`; + const eventJson = JSON.stringify(eventData, null, 2); + const eventDisplay = eventHeader + '\n' + eventJson + '\n\n'; + + // Append new event to the display + const currentContent = $('#webhook-events').text(); + if (currentContent === 'No webhook events yet' || currentContent === 'Connected to webhook event stream. Waiting for events...') { + $('#webhook-events').text(eventDisplay); + } else { + $('#webhook-events').append(eventDisplay); + } + + // Scroll to bottom of the pre element + const preElement = document.getElementById('webhook-events'); + preElement.scrollTop = preElement.scrollHeight; + } + $(document).ready(function() { updateButtonStates(); + setupEventSource(); + }); + + // Clean up EventSource connection when page is unloaded + window.addEventListener('beforeunload', function() { + if (eventSource) { + eventSource.close(); + } }); \ No newline at end of file diff --git a/webhook_testharness/views/layout.pug b/webhook_testharness/views/layout.pug index d2e2d5e80..80ad0420c 100644 --- a/webhook_testharness/views/layout.pug +++ b/webhook_testharness/views/layout.pug @@ -4,5 +4,6 @@ html title= title link(rel='stylesheet', href='/stylesheets/style.css') script(src="https://code.jquery.com/jquery-3.7.1.min.js") + link(href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous") body block content From 47f40e0ec98e74a57e6de04bdc285f0f9e15fe7f Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 5 Oct 2025 11:07:02 -0500 Subject: [PATCH 03/27] save and restore hook subscriptions --- crates/sage-api/src/requests/webhooks.rs | 1 - crates/sage-config/src/config.rs | 16 +++++++ crates/sage-config/src/old.rs | 3 +- crates/sage/src/endpoints/webhooks.rs | 15 +++++-- crates/sage/src/sage.rs | 39 ++++++++++++++++ crates/sage/src/webhook_manager.rs | 57 +++++++++++------------- src/bindings.ts | 2 +- webhook_testharness/views/index.pug | 36 ++++++++++++++- 8 files changed, 129 insertions(+), 40 deletions(-) diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs index 38f45353b..7f68037cb 100644 --- a/crates/sage-api/src/requests/webhooks.rs +++ b/crates/sage-api/src/requests/webhooks.rs @@ -5,7 +5,6 @@ use serde::{Deserialize, Serialize}; pub struct RegisterWebhook { pub url: String, pub event_types: Vec, - pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index 4c7a8a0c1..cc752b2bf 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -8,6 +8,7 @@ pub struct Config { pub global: GlobalConfig, pub network: NetworkConfig, pub rpc: RpcConfig, + pub webhooks: WebhookConfig, } impl Default for Config { @@ -17,6 +18,7 @@ impl Default for Config { global: GlobalConfig::default(), network: NetworkConfig::default(), rpc: RpcConfig::default(), + webhooks: WebhookConfig::default(), } } } @@ -70,3 +72,17 @@ impl Default for RpcConfig { } } } + +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)] +#[serde(default)] +pub struct WebhookConfig { + pub webhooks: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +pub struct WebhookEntry { + pub id: String, + pub url: String, + pub events: Vec, + pub enabled: bool, +} diff --git a/crates/sage-config/src/old.rs b/crates/sage-config/src/old.rs index 9f616d04f..607f3c4f1 100644 --- a/crates/sage-config/src/old.rs +++ b/crates/sage-config/src/old.rs @@ -8,7 +8,7 @@ use specta::Type; use crate::{ Config, GlobalConfig, InheritedNetwork, Network, NetworkConfig, NetworkList, RpcConfig, Wallet, - WalletConfig, WalletDefaults, + WalletConfig, WalletDefaults, WebhookConfig, }; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Type)] @@ -140,6 +140,7 @@ pub fn migrate_config(old: OldConfig) -> Result<(Config, WalletConfig), ParseInt enabled: old.rpc.run_on_startup, port: old.rpc.server_port, }, + webhooks: WebhookConfig::default(), }; let mut wallet_config = WalletConfig { diff --git a/crates/sage/src/endpoints/webhooks.rs b/crates/sage/src/endpoints/webhooks.rs index e8732689f..9115f60cd 100644 --- a/crates/sage/src/endpoints/webhooks.rs +++ b/crates/sage/src/endpoints/webhooks.rs @@ -5,23 +5,32 @@ use sage_api::{ use crate::{Result, Sage}; impl Sage { - pub async fn register_webhook(&self, req: RegisterWebhook) -> Result { + pub async fn register_webhook( + &mut self, + req: RegisterWebhook, + ) -> Result { let webhook_id = self .webhook_manager - .register_webhook(req.url, req.event_types, req.secret) + .register_webhook(req.url, req.event_types) .await; + // Save to config + self.save_webhooks_config().await?; + Ok(RegisterWebhookResponse { webhook_id }) } pub async fn unregister_webhook( - &self, + &mut self, req: UnregisterWebhook, ) -> Result { self.webhook_manager .unregister_webhook(&req.webhook_id) .await; + // Save to config + self.save_webhooks_config().await?; + Ok(UnregisterWebhookResponse {}) } } diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index 59f9115bf..e2c77b728 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -70,6 +70,7 @@ impl Sage { self.setup_keys()?; self.setup_config()?; self.setup_logging()?; + self.setup_webhooks().await?; let receiver = self.setup_sync_manager()?; self.setup_peers().await?; @@ -589,4 +590,42 @@ impl Sage { fs::write(self.path.join("keys.bin"), self.keychain.to_bytes()?)?; Ok(()) } + + pub async fn save_webhooks_config(&mut self) -> Result<()> { + use sage_config::WebhookEntry; + + let entries = self.webhook_manager.get_webhook_entries().await; + self.config.webhooks.webhooks = entries + .into_iter() + .map(|(id, url, events, enabled)| WebhookEntry { + id, + url, + events, + enabled, + }) + .collect(); + + self.save_config()?; + Ok(()) + } + + async fn setup_webhooks(&mut self) -> Result<()> { + let entries: Vec<(String, String, Vec, bool)> = self + .config + .webhooks + .webhooks + .iter() + .map(|w| (w.id.clone(), w.url.clone(), w.events.clone(), w.enabled)) + .collect(); + + if !entries.is_empty() { + self.webhook_manager.load_webhooks(entries).await; + info!( + "Loaded {} webhooks from config", + self.config.webhooks.webhooks.len() + ); + } + + Ok(()) + } } diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index b5cc9ba6c..57dffa8f0 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -10,7 +10,6 @@ pub struct WebhookConfig { pub id: String, pub url: String, pub events: Vec, - pub secret: Option, pub active: bool, } @@ -47,18 +46,12 @@ impl WebhookManager { } // Register a new webhook - pub async fn register_webhook( - &self, - url: String, - events: Vec, - secret: Option, - ) -> String { + pub async fn register_webhook(&self, url: String, events: Vec) -> String { let id = Uuid::new_v4().to_string(); let config = WebhookConfig { id: id.clone(), url, events, - secret, active: true, }; @@ -138,14 +131,7 @@ impl WebhookManager { webhook: &WebhookConfig, event: &WebhookEventPayload, ) -> Result<(), Box> { - let mut request = client.post(&webhook.url).json(event); - - // Add HMAC signature if secret is configured - if let Some(secret) = &webhook.secret { - let signature = Self::generate_signature(event, secret)?; - request = request.header("X-Webhook-Signature", signature); - } - + let request = client.post(&webhook.url).json(event); let response = request.send().await?; if response.status().is_success() { @@ -155,25 +141,32 @@ impl WebhookManager { } } - fn generate_signature( - event: &WebhookEventPayload, - secret: &str, - ) -> Result> { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - - type HmacSha256 = Hmac; - - let payload = serde_json::to_string(event)?; - let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?; - mac.update(payload.as_bytes()); - let result = mac.finalize(); - Ok(hex::encode(result.into_bytes())) - } - // List all registered webhooks pub async fn list_webhooks(&self) -> Vec { let webhooks = self.webhooks.read().await; webhooks.values().cloned().collect() } + + // Load webhooks from config entries + pub async fn load_webhooks(&self, entries: Vec<(String, String, Vec, bool)>) { + let mut webhooks = self.webhooks.write().await; + for (id, url, events, enabled) in entries { + let config = WebhookConfig { + id: id.clone(), + url, + events, + active: enabled, + }; + webhooks.insert(id, config); + } + } + + // Get webhooks in a format suitable for saving to config + pub async fn get_webhook_entries(&self) -> Vec<(String, String, Vec, bool)> { + let webhooks = self.webhooks.read().await; + webhooks + .values() + .map(|w| (w.id.clone(), w.url.clone(), w.events.clone(), w.active)) + .collect() + } } diff --git a/src/bindings.ts b/src/bindings.ts index 4b1c8d283..0d51cf9b0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -547,7 +547,7 @@ export type PerformDatabaseMaintenance = { force_vacuum: boolean } export type PerformDatabaseMaintenanceResponse = { vacuum_duration_ms: number; analyze_duration_ms: number; wal_checkpoint_duration_ms: number; total_duration_ms: number; pages_vacuumed: number; wal_pages_checkpointed: number } export type RedownloadNft = { nft_id: string } export type RedownloadNftResponse = Record -export type RegisterWebhook = { url: string; event_types: string[]; secret: string | null } +export type RegisterWebhook = { url: string; event_types: string[] } export type RegisterWebhookResponse = { webhook_id: string } export type RemovePeer = { ip: string; ban: boolean } export type RenameKey = { fingerprint: number; name: string } diff --git a/webhook_testharness/views/index.pug b/webhook_testharness/views/index.pug index 3eb65f033..e7996c4e8 100644 --- a/webhook_testharness/views/index.pug +++ b/webhook_testharness/views/index.pug @@ -7,25 +7,52 @@ block content button#unregister-btn(onclick="unregisterWebhook()" style="display: none;") Unregister Webhook h2 Response: - pre#response-display No response yet + pre#response-display Hook not registered h2 Live Webhook Events: pre#webhook-events No webhook events yet script. - let webhookId = null; + // Cookie helper functions + function setCookie(name, value, days = 365) { + const expires = new Date(Date.now() + days * 864e5).toUTCString(); + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; + } + + function getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return decodeURIComponent(parts.pop().split(';').shift()); + return null; + } + + function deleteCookie(name) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; + } + + // Load webhook ID from cookie on startup + let webhookId = getCookie('webhookId'); function updateButtonStates() { if (webhookId) { $('#register-btn').hide(); $('#unregister-btn').show(); + $('#response-display').text(`Webhook registered (ID: ${webhookId})`); } else { $('#register-btn').show(); $('#unregister-btn').hide(); } } + function clearWebhookEvents() { + $('#webhook-events').text('Webhook events cleared. Waiting for new events...'); + webhookEventCount = 0; + } + function registerWebhook() { + // Clear the webhook events display when registering + clearWebhookEvents(); + fetch('/proxy/register_webhook', { method: 'POST', headers: { @@ -37,6 +64,7 @@ block content .then(data => { if (data.webhook_id) { webhookId = data.webhook_id; + setCookie('webhookId', webhookId); updateButtonStates(); } $('#response-display').text(JSON.stringify(data, null, 2)); @@ -53,6 +81,9 @@ block content return; } + // Clear the webhook events display when unregistering + clearWebhookEvents(); + fetch('/proxy/unregister_webhook', { method: 'POST', headers: { @@ -63,6 +94,7 @@ block content .then(response => response.json()) .then(data => { webhookId = null; + deleteCookie('webhookId'); updateButtonStates(); $('#response-display').text(JSON.stringify(data, null, 2)); }) From fe92b755fc88e9ac355853eac0ae85007eebb434 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sun, 5 Oct 2025 11:52:55 -0500 Subject: [PATCH 04/27] allow subscriber to indicate all events now and in the future --- crates/sage-api/src/requests/webhooks.rs | 2 +- crates/sage-config/src/config.rs | 3 ++- crates/sage/src/sage.rs | 2 +- crates/sage/src/webhook_manager.rs | 19 +++++++++++++------ src/bindings.ts | 2 +- webhook_testharness/views/index.pug | 24 ++++++++---------------- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs index 7f68037cb..45f60cc20 100644 --- a/crates/sage-api/src/requests/webhooks.rs +++ b/crates/sage-api/src/requests/webhooks.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; #[cfg_attr(feature = "tauri", derive(specta::Type))] pub struct RegisterWebhook { pub url: String, - pub event_types: Vec, + pub event_types: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index cc752b2bf..aef51665a 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -83,6 +83,7 @@ pub struct WebhookConfig { pub struct WebhookEntry { pub id: String, pub url: String, - pub events: Vec, + /// None means "all events, including future ones" + pub events: Option>, pub enabled: bool, } diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index e2c77b728..a334e7969 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -610,7 +610,7 @@ impl Sage { } async fn setup_webhooks(&mut self) -> Result<()> { - let entries: Vec<(String, String, Vec, bool)> = self + let entries: Vec<(String, String, Option>, bool)> = self .config .webhooks .webhooks diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index 57dffa8f0..ebf631a47 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -9,7 +9,8 @@ use uuid::Uuid; pub struct WebhookConfig { pub id: String, pub url: String, - pub events: Vec, + /// None means "all events, including future ones" + pub events: Option>, pub active: bool, } @@ -46,7 +47,7 @@ impl WebhookManager { } // Register a new webhook - pub async fn register_webhook(&self, url: String, events: Vec) -> String { + pub async fn register_webhook(&self, url: String, events: Option>) -> String { let id = Uuid::new_v4().to_string(); let config = WebhookConfig { id: id.clone(), @@ -86,7 +87,13 @@ impl WebhookManager { let webhooks = self.webhooks.read().await; let interested_webhooks: Vec = webhooks .values() - .filter(|w| w.active && w.events.contains(&event_type)) + .filter(|w| { + w.active + && match &w.events { + None => true, // None means all events + Some(events) => events.contains(&event_type), + } + }) .cloned() .collect(); @@ -104,7 +111,7 @@ impl WebhookManager { for attempt in 0..MAX_RETRIES { match Self::send_webhook_request(&client, &webhook, &event).await { - Ok(_) => { + Ok(()) => { println!("✓ Webhook delivered to {}", webhook.url); return; } @@ -148,7 +155,7 @@ impl WebhookManager { } // Load webhooks from config entries - pub async fn load_webhooks(&self, entries: Vec<(String, String, Vec, bool)>) { + pub async fn load_webhooks(&self, entries: Vec<(String, String, Option>, bool)>) { let mut webhooks = self.webhooks.write().await; for (id, url, events, enabled) in entries { let config = WebhookConfig { @@ -162,7 +169,7 @@ impl WebhookManager { } // Get webhooks in a format suitable for saving to config - pub async fn get_webhook_entries(&self) -> Vec<(String, String, Vec, bool)> { + pub async fn get_webhook_entries(&self) -> Vec<(String, String, Option>, bool)> { let webhooks = self.webhooks.read().await; webhooks .values() diff --git a/src/bindings.ts b/src/bindings.ts index 0d51cf9b0..fe2a04db8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -547,7 +547,7 @@ export type PerformDatabaseMaintenance = { force_vacuum: boolean } export type PerformDatabaseMaintenanceResponse = { vacuum_duration_ms: number; analyze_duration_ms: number; wal_checkpoint_duration_ms: number; total_duration_ms: number; pages_vacuumed: number; wal_pages_checkpointed: number } export type RedownloadNft = { nft_id: string } export type RedownloadNftResponse = Record -export type RegisterWebhook = { url: string; event_types: string[] } +export type RegisterWebhook = { url: string; event_types: string[] | null } export type RegisterWebhookResponse = { webhook_id: string } export type RemovePeer = { ip: string; ban: boolean } export type RenameKey = { fingerprint: number; name: string } diff --git a/webhook_testharness/views/index.pug b/webhook_testharness/views/index.pug index e7996c4e8..8c59a3337 100644 --- a/webhook_testharness/views/index.pug +++ b/webhook_testharness/views/index.pug @@ -5,15 +5,15 @@ block content button#register-btn(onclick="registerWebhook()") Register Webhook button#unregister-btn(onclick="unregisterWebhook()" style="display: none;") Unregister Webhook + + article + h2 Registration: + pre#response-display Hook not registered - h2 Response: - pre#response-display Hook not registered - - h2 Live Webhook Events: - pre#webhook-events No webhook events yet + h2 Live Webhook Events: + pre#webhook-events No webhook events yet script. - // Cookie helper functions function setCookie(name, value, days = 365) { const expires = new Date(Date.now() + days * 864e5).toUTCString(); document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`; @@ -30,7 +30,6 @@ block content document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; } - // Load webhook ID from cookie on startup let webhookId = getCookie('webhookId'); function updateButtonStates() { @@ -50,7 +49,6 @@ block content } function registerWebhook() { - // Clear the webhook events display when registering clearWebhookEvents(); fetch('/proxy/register_webhook', { @@ -58,7 +56,7 @@ block content headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: 'http://localhost:3000/sage_hook', event_types:['start', 'stop', 'subscribed', 'derivation', 'coin_state','puzzle_batch_synced','nft_data'] }), + body: JSON.stringify({ url: 'http://localhost:3000/sage_hook' }), }) .then(response => response.json()) .then(data => { @@ -81,7 +79,6 @@ block content return; } - // Clear the webhook events display when unregistering clearWebhookEvents(); fetch('/proxy/unregister_webhook', { @@ -112,12 +109,10 @@ block content eventSource = new EventSource('/events'); eventSource.onopen = function(event) { - console.log('SSE connection opened'); $('#webhook-events').text('Connected to webhook event stream. Waiting for events...'); }; eventSource.onmessage = function(event) { - console.log('SSE message received:', event); try { const data = JSON.parse(event.data); displayWebhookEvent(data); @@ -127,9 +122,9 @@ block content }; eventSource.addEventListener('webhook', function(event) { - console.log('Webhook event received:', event); try { const data = JSON.parse(event.data); + data.event = data.event || 'webhook'; displayWebhookEvent(data); } catch (error) { console.error('Error parsing webhook event:', error); @@ -137,7 +132,6 @@ block content }); eventSource.addEventListener('connected', function(event) { - console.log('SSE connected:', event); displayWebhookEvent({ event: 'connected', data: event.data, @@ -149,12 +143,10 @@ block content console.error('SSE error:', event); $('#webhook-events').append('\n[ERROR] Connection lost. Attempting to reconnect...'); - // Close current connection if (eventSource) { eventSource.close(); } - // Attempt to reconnect after 3 seconds setTimeout(function() { setupEventSource(); }, 3000); From fbad44b980930e35e55db322a9d15a386a3394ef Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 6 Oct 2025 09:42:30 -0500 Subject: [PATCH 05/27] add network and fingerprint to payload add get and update rpc methods --- crates/sage-api/endpoints.json | 6 +++-- crates/sage-api/src/requests/webhooks.rs | 24 ++++++++++++++++++ crates/sage/src/endpoints/webhooks.rs | 31 +++++++++++++++++++++--- crates/sage/src/sage.rs | 12 +++++++++ crates/sage/src/webhook_manager.rs | 31 +++++++++++++++++++----- src-tauri/src/lib.rs | 2 ++ src/bindings.ts | 15 ++++++++++++ 7 files changed, 110 insertions(+), 11 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 07c87264d..8c00d07c1 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -98,5 +98,7 @@ "increase_derivation_index": true, "is_asset_owned": true, "register_webhook": true, - "unregister_webhook": true -} + "unregister_webhook": true, + "get_webhooks": true, + "update_webhook": true +} \ No newline at end of file diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs index 45f60cc20..6eb366b4e 100644 --- a/crates/sage-api/src/requests/webhooks.rs +++ b/crates/sage-api/src/requests/webhooks.rs @@ -1,3 +1,4 @@ +use sage_config::WebhookEntry; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -26,7 +27,30 @@ pub struct UnregisterWebhookResponse {} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookEvent { pub id: String, + pub fingerprint: Option, + pub network: String, pub event_type: String, pub timestamp: i64, pub data: serde_json::Value, } + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct GetWebhooks {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct GetWebhooksResponse { + pub webhooks: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct UpdateWebhook { + pub webhook_id: String, + pub enabled: bool, +} + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +pub struct UpdateWebhookResponse {} diff --git a/crates/sage/src/endpoints/webhooks.rs b/crates/sage/src/endpoints/webhooks.rs index 9115f60cd..b3bfd37e2 100644 --- a/crates/sage/src/endpoints/webhooks.rs +++ b/crates/sage/src/endpoints/webhooks.rs @@ -1,6 +1,8 @@ use sage_api::{ - RegisterWebhook, RegisterWebhookResponse, UnregisterWebhook, UnregisterWebhookResponse, + GetWebhooks, GetWebhooksResponse, RegisterWebhook, RegisterWebhookResponse, UnregisterWebhook, + UnregisterWebhookResponse, UpdateWebhook, UpdateWebhookResponse, }; +use sage_config::WebhookEntry; use crate::{Result, Sage}; @@ -14,7 +16,6 @@ impl Sage { .register_webhook(req.url, req.event_types) .await; - // Save to config self.save_webhooks_config().await?; Ok(RegisterWebhookResponse { webhook_id }) @@ -28,9 +29,33 @@ impl Sage { .unregister_webhook(&req.webhook_id) .await; - // Save to config self.save_webhooks_config().await?; Ok(UnregisterWebhookResponse {}) } + + pub async fn get_webhooks(&mut self, _req: GetWebhooks) -> Result { + let webhooks = self.webhook_manager.list_webhooks().await; + Ok(GetWebhooksResponse { + webhooks: webhooks + .into_iter() + .map(|w| WebhookEntry { + id: w.id, + url: w.url, + events: w.events, + enabled: w.active, + }) + .collect(), + }) + } + + pub async fn update_webhook(&mut self, req: UpdateWebhook) -> Result { + self.webhook_manager + .update_webhook(&req.webhook_id, req.enabled) + .await; + + self.save_webhooks_config().await?; + + Ok(UpdateWebhookResponse {}) + } } diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index a334e7969..ad0f0dab5 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -72,6 +72,12 @@ impl Sage { self.setup_logging()?; self.setup_webhooks().await?; + // Initialize webhook manager with current fingerprint and network + self.webhook_manager + .set_fingerprint(self.config.global.fingerprint) + .await; + self.webhook_manager.set_network(self.network_id()).await; + let receiver = self.setup_sync_manager()?; self.setup_peers().await?; @@ -324,6 +330,8 @@ impl Sage { } pub async fn switch_network(&mut self) -> Result<()> { + self.webhook_manager.set_network(self.network_id()).await; + self.command_sender .send(SyncCommand::SwitchNetwork(self.network().clone())) .await?; @@ -336,6 +344,7 @@ impl Sage { let Some(fingerprint) = self.config.global.fingerprint else { self.wallet = None; + self.webhook_manager.set_fingerprint(None).await; self.command_sender .send(SyncCommand::SwitchWallet { @@ -380,6 +389,9 @@ impl Sage { ticker: self.network().ticker.clone(), precision: self.network().precision, }; + self.webhook_manager + .set_fingerprint(Some(fingerprint)) + .await; self.command_sender .send(SyncCommand::SwitchWallet { diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index ebf631a47..0e7c1ff0e 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; +use tracing::{debug, error}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -17,6 +18,8 @@ pub struct WebhookConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookEventPayload { pub id: String, + pub fingerprint: Option, + pub network: String, pub event_type: String, pub timestamp: i64, pub data: serde_json::Value, @@ -27,6 +30,8 @@ pub struct WebhookEventPayload { pub struct WebhookManager { webhooks: Arc>>, client: Client, + fingerprint: Arc>>, + network: Arc>, } impl Default for WebhookManager { @@ -37,6 +42,8 @@ impl Default for WebhookManager { .timeout(std::time::Duration::from_secs(10)) .build() .expect("Failed to create HTTP client"), + fingerprint: Arc::new(RwLock::new(None)), + network: Arc::new(RwLock::new(String::new())), } } } @@ -46,7 +53,14 @@ impl WebhookManager { Self::default() } - // Register a new webhook + pub async fn set_fingerprint(&self, fingerprint: Option) { + *self.fingerprint.write().await = fingerprint; + } + + pub async fn set_network(&self, network: String) { + *self.network.write().await = network; + } + pub async fn register_webhook(&self, url: String, events: Option>) -> String { let id = Uuid::new_v4().to_string(); let config = WebhookConfig { @@ -77,8 +91,12 @@ impl WebhookManager { } pub async fn send_event(&self, event_type: String, data: serde_json::Value) { + let fingerprint = *self.fingerprint.read().await; + let network = self.network.read().await.clone(); let event = WebhookEventPayload { id: Uuid::new_v4().to_string(), + fingerprint, + network, event_type: event_type.clone(), timestamp: chrono::Utc::now().timestamp(), data, @@ -112,12 +130,15 @@ impl WebhookManager { for attempt in 0..MAX_RETRIES { match Self::send_webhook_request(&client, &webhook, &event).await { Ok(()) => { - println!("✓ Webhook delivered to {}", webhook.url); + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f UTC"); + debug!("[{}] Webhook delivered to {}", timestamp, webhook.url); return; } Err(e) => { - eprintln!( - "✗ Webhook delivery failed (attempt {}/{}): {} - {}", + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f UTC"); + error!( + "[{}] Webhook delivery failed (attempt {}/{}): {} - {}", + timestamp, attempt + 1, MAX_RETRIES, webhook.url, @@ -148,13 +169,11 @@ impl WebhookManager { } } - // List all registered webhooks pub async fn list_webhooks(&self) -> Vec { let webhooks = self.webhooks.read().await; webhooks.values().cloned().collect() } - // Load webhooks from config entries pub async fn load_webhooks(&self, entries: Vec<(String, String, Option>, bool)>) { let mut webhooks = self.webhooks.write().await; for (id, url, events, enabled) in entries { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 301f532de..4a33fd4eb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -141,6 +141,8 @@ pub fn run() { commands::is_asset_owned, commands::register_webhook, commands::unregister_webhook, + commands::get_webhooks, + commands::update_webhook, ]) .events(collect_events![SyncEvent]); diff --git a/src/bindings.ts b/src/bindings.ts index fe2a04db8..da8d6d472 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -358,6 +358,12 @@ async registerWebhook(req: RegisterWebhook) : Promise { }, async unregisterWebhook(req: UnregisterWebhook) : Promise { return await TAURI_INVOKE("unregister_webhook", { req }); +}, +async getWebhooks(req: GetWebhooks) : Promise { + return await TAURI_INVOKE("get_webhooks", { req }); +}, +async updateWebhook(req: UpdateWebhook) : Promise { + return await TAURI_INVOKE("update_webhook", { req }); } } @@ -497,6 +503,8 @@ export type GetUserThemes = Record export type GetUserThemesResponse = { themes: string[] } export type GetVersion = Record export type GetVersionResponse = { version: string } +export type GetWebhooks = Record +export type GetWebhooksResponse = { webhooks: WebhookEntry[] } export type ImportKey = { name: string; key: string; derivation_index?: number; hardened?: boolean | null; unhardened?: boolean | null; save_secrets?: boolean; login?: boolean; emoji?: string | null } export type ImportKeyResponse = { fingerprint: number } export type ImportOffer = { offer: string } @@ -610,12 +618,19 @@ export type UpdateNftCollectionResponse = Record export type UpdateNftResponse = Record export type UpdateOption = { option_id: string; visible: boolean } export type UpdateOptionResponse = Record +export type UpdateWebhook = { webhook_id: string; enabled: boolean } +export type UpdateWebhookResponse = Record export type ViewCoinSpends = { coin_spends: CoinSpendJson[] } export type ViewCoinSpendsResponse = { summary: TransactionSummary } export type ViewOffer = { offer: string } export type ViewOfferResponse = { offer: OfferSummary; status: OfferRecordStatus } export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null } export type WalletDefaults = { delta_sync: boolean } +export type WebhookEntry = { id: string; url: string; +/** + * None means "all events, including future ones" + */ +events: string[] | null; enabled: boolean } /** tauri-specta globals **/ From 9d5963531331e7a14583127d306c37f64df69e19 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 6 Oct 2025 12:14:18 -0500 Subject: [PATCH 06/27] simple hook status metrics --- crates/sage-config/src/config.rs | 2 + crates/sage/src/endpoints/webhooks.rs | 2 + crates/sage/src/sage.rs | 2 + crates/sage/src/webhook_manager.rs | 31 ++++- src/bindings.ts | 2 +- src/pages/Settings.tsx | 188 ++++++++++++++++++++++++++ webhook_testharness/env-example.txt | 12 -- webhook_testharness/views/index.pug | 11 +- 8 files changed, 230 insertions(+), 20 deletions(-) delete mode 100644 webhook_testharness/env-example.txt diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index aef51665a..146983f74 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -86,4 +86,6 @@ pub struct WebhookEntry { /// None means "all events, including future ones" pub events: Option>, pub enabled: bool, + pub last_delivered_at: Option, + pub last_delivery_attempt_at: Option, } diff --git a/crates/sage/src/endpoints/webhooks.rs b/crates/sage/src/endpoints/webhooks.rs index b3bfd37e2..0a0864d53 100644 --- a/crates/sage/src/endpoints/webhooks.rs +++ b/crates/sage/src/endpoints/webhooks.rs @@ -44,6 +44,8 @@ impl Sage { url: w.url, events: w.events, enabled: w.active, + last_delivered_at: w.last_delivered_at, + last_delivery_attempt_at: w.last_delivery_attempt_at, }) .collect(), }) diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index ad0f0dab5..6448fb371 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -614,6 +614,8 @@ impl Sage { url, events, enabled, + last_delivered_at: None, + last_delivery_attempt_at: None, }) .collect(); diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index 0e7c1ff0e..527f168e1 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -13,6 +13,8 @@ pub struct WebhookConfig { /// None means "all events, including future ones" pub events: Option>, pub active: bool, + pub last_delivered_at: Option, + pub last_delivery_attempt_at: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -68,6 +70,8 @@ impl WebhookManager { url, events, active: true, + last_delivered_at: None, + last_delivery_attempt_at: None, }; let mut webhooks = self.webhooks.write().await; @@ -118,20 +122,41 @@ impl WebhookManager { for webhook in interested_webhooks { let client = self.client.clone(); let event = event.clone(); + let webhooks = self.webhooks.clone(); tokio::spawn(async move { - Self::deliver_webhook(client, webhook, event).await; + Self::deliver_webhook(client, webhooks, webhook, event).await; }); } } - async fn deliver_webhook(client: Client, webhook: WebhookConfig, event: WebhookEventPayload) { + async fn deliver_webhook( + client: Client, + webhooks: Arc>>, + webhook: WebhookConfig, + event: WebhookEventPayload, + ) { const MAX_RETRIES: u32 = 3; for attempt in 0..MAX_RETRIES { + let now = chrono::Utc::now().timestamp(); + + // block scope to ensure the lock is released + { + let mut webhooks_lock = webhooks.write().await; + if let Some(w) = webhooks_lock.get_mut(&webhook.id) { + w.last_delivery_attempt_at = Some(now); + } + } + match Self::send_webhook_request(&client, &webhook, &event).await { Ok(()) => { let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S%.3f UTC"); debug!("[{}] Webhook delivered to {}", timestamp, webhook.url); + + let mut webhooks_lock = webhooks.write().await; + if let Some(w) = webhooks_lock.get_mut(&webhook.id) { + w.last_delivered_at = Some(now); + } return; } Err(e) => { @@ -182,6 +207,8 @@ impl WebhookManager { url, events, active: enabled, + last_delivered_at: None, + last_delivery_attempt_at: None, }; webhooks.insert(id, config); } diff --git a/src/bindings.ts b/src/bindings.ts index da8d6d472..c7dd03a56 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -630,7 +630,7 @@ export type WebhookEntry = { id: string; url: string; /** * None means "all events, including future ones" */ -events: string[] | null; enabled: boolean } +events: string[] | null; enabled: boolean; last_delivered_at: number | null; last_delivery_attempt_at: number | null } /** tauri-specta globals **/ diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8ef69fa88..7bc2b13e9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -78,6 +78,7 @@ import { PerformDatabaseMaintenanceResponse, Wallet, WalletDefaults, + WebhookEntry, } from '../bindings'; import { ThemeSelectorSimple } from '../components/ThemeSelector'; @@ -155,6 +156,13 @@ export default function Settings() { > Advanced + + + Webhooks + @@ -202,6 +210,12 @@ export default function Settings() { + + +
+ +
+
@@ -1601,3 +1615,177 @@ function WalletSettings({ fingerprint }: { fingerprint: number }) { ); } + +function WebhooksSettings() { + const { addError } = useErrors(); + const [webhooks, setWebhooks] = useState([]); + const [loading, setLoading] = useState(true); + + const loadWebhooks = useCallback(() => { + setLoading(true); + commands + .getWebhooks({}) + .then((response) => { + setWebhooks(response.webhooks); + }) + .catch(addError) + .finally(() => setLoading(false)); + }, [addError]); + + useEffect(() => { + loadWebhooks(); + }, [loadWebhooks]); + + if (loading) { + return ( + +
+ +
+
+ ); + } + + if (webhooks.length === 0) { + return ( + +
+
+ No webhooks registered +
+
+ + Register webhooks via the API to receive real-time notifications + about wallet events + +
+
+
+ ); + } + + return ( + + {webhooks.map((webhook, index) => { + // Determine status color and label + let statusColor = 'bg-gray-400'; + let statusLabel = Disabled; + + if (webhook.enabled) { + // Check health based on delivery attempts + if ( + webhook.last_delivered_at === null && + webhook.last_delivery_attempt_at !== null + ) { + // Never successfully delivered, but attempts have been made + statusColor = 'bg-red-500'; + statusLabel = Failing; + } else if ( + webhook.last_delivery_attempt_at !== null && + webhook.last_delivered_at !== null && + webhook.last_delivery_attempt_at > webhook.last_delivered_at + ) { + // Most recent attempt failed + statusColor = 'bg-yellow-500'; + statusLabel = Warning; + } else { + // Enabled and healthy + statusColor = 'bg-green-500'; + statusLabel = Enabled; + } + } + + return ( +
+
+ {/* Header with ID and Status */} +
+
+
+
+
{statusLabel}
+
+ {webhook.id} +
+
+
+
+ + {/* URL */} +
+ +
+ {webhook.url} +
+
+ + {/* Events */} +
+ +
+ {webhook.events === null ? ( + + All events + + ) : webhook.events.length === 0 ? ( + + No events + + ) : ( +
+ {webhook.events.map((event) => ( + + {event} + + ))} +
+ )} +
+
+ + {/* Last Delivered */} + {webhook.last_delivered_at && ( +
+ +
+ {new Date( + webhook.last_delivered_at * 1000, + ).toLocaleString()} +
+
+ )} + + {/* Last Attempt */} + {webhook.last_delivery_attempt_at && ( +
+ +
+ {new Date( + webhook.last_delivery_attempt_at * 1000, + ).toLocaleString()} +
+
+ )} +
+ + {/* Divider between webhooks, but not after the last one */} + {index < webhooks.length - 1 && ( +
+ )} +
+ ); + })} + + ); +} diff --git a/webhook_testharness/env-example.txt b/webhook_testharness/env-example.txt deleted file mode 100644 index 40c8a895b..000000000 --- a/webhook_testharness/env-example.txt +++ /dev/null @@ -1,12 +0,0 @@ -# Option 1: Using file paths (recommended for certificates) -CLIENT_CERT_PATH="./certs/client.crt" -CLIENT_KEY_PATH="./certs/client.key" - -# Option 2: Using environment variables with \n escape sequences -# CLIENT_CERT="-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJAK...\n-----END CERTIFICATE-----" -# CLIENT_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----" - -# Option 3: Using multiline strings (works with some .env parsers) -# CLIENT_CERT="-----BEGIN CERTIFICATE----- -# MIIBkTCB+wIJAK... -# -----END CERTIFICATE-----" diff --git a/webhook_testharness/views/index.pug b/webhook_testharness/views/index.pug index 8c59a3337..f5676459f 100644 --- a/webhook_testharness/views/index.pug +++ b/webhook_testharness/views/index.pug @@ -1,17 +1,18 @@ extends layout block content - h1= title + h2= title button#register-btn(onclick="registerWebhook()") Register Webhook button#unregister-btn(onclick="unregisterWebhook()" style="display: none;") Unregister Webhook - article - h2 Registration: + article.small + h3 Registration: pre#response-display Hook not registered - h2 Live Webhook Events: - pre#webhook-events No webhook events yet + h3 Live Webhook Events: + div.small + pre#webhook-events.small No webhook events yet script. function setCookie(name, value, days = 365) { From d8d748d0f6e070237d150c77207fd8aabde8bf6e Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Mon, 6 Oct 2025 15:14:58 -0500 Subject: [PATCH 07/27] add hmac verification --- crates/sage-api/endpoints.json | 2 +- crates/sage-api/src/requests/webhooks.rs | 2 + crates/sage-config/src/config.rs | 3 + crates/sage/src/endpoints/webhooks.rs | 3 +- crates/sage/src/sage.rs | 42 ++++---- crates/sage/src/webhook_manager.rs | 59 ++++++++-- src/bindings.ts | 12 ++- webhook_testharness/app.js | 130 ++++++++++++++++++----- webhook_testharness/views/index.pug | 74 ++++++++++++- 9 files changed, 267 insertions(+), 60 deletions(-) diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 8c00d07c1..866e4d874 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -101,4 +101,4 @@ "unregister_webhook": true, "get_webhooks": true, "update_webhook": true -} \ No newline at end of file +} diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs index 6eb366b4e..94b65f2ae 100644 --- a/crates/sage-api/src/requests/webhooks.rs +++ b/crates/sage-api/src/requests/webhooks.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize}; pub struct RegisterWebhook { pub url: String, pub event_types: Option>, + /// Optional secret for HMAC-SHA256 signature verification + pub secret: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/sage-config/src/config.rs b/crates/sage-config/src/config.rs index 146983f74..e24113c36 100644 --- a/crates/sage-config/src/config.rs +++ b/crates/sage-config/src/config.rs @@ -86,6 +86,9 @@ pub struct WebhookEntry { /// None means "all events, including future ones" pub events: Option>, pub enabled: bool, + /// Optional secret for HMAC-SHA256 signature verification + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, pub last_delivered_at: Option, pub last_delivery_attempt_at: Option, } diff --git a/crates/sage/src/endpoints/webhooks.rs b/crates/sage/src/endpoints/webhooks.rs index 0a0864d53..f4fb83fa2 100644 --- a/crates/sage/src/endpoints/webhooks.rs +++ b/crates/sage/src/endpoints/webhooks.rs @@ -13,7 +13,7 @@ impl Sage { ) -> Result { let webhook_id = self .webhook_manager - .register_webhook(req.url, req.event_types) + .register_webhook(req.url, req.event_types, req.secret) .await; self.save_webhooks_config().await?; @@ -44,6 +44,7 @@ impl Sage { url: w.url, events: w.events, enabled: w.active, + secret: None, // Don't expose secret in API responses last_delivered_at: w.last_delivered_at, last_delivery_attempt_at: w.last_delivery_attempt_at, }) diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index 6448fb371..758e0a797 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -279,6 +279,8 @@ impl Sage { } async fn handle_sync_event_for_webhooks(webhook_manager: &WebhookManager, event: SyncEvent) { + // Convert wallet SyncEvent to API SyncEvent format + // Extract event type and data separately (no redundant type in data) let (event_type, data) = match event { SyncEvent::Start(ip) => ( "start", @@ -288,19 +290,7 @@ impl Sage { ), SyncEvent::Stop => ("stop", serde_json::json!({})), SyncEvent::Subscribed => ("subscribed", serde_json::json!({})), - SyncEvent::DerivationIndex { next_index } => ( - "derivation_index", - serde_json::json!({ - "next_index": next_index - }), - ), - SyncEvent::CoinsUpdated => ("coin_state", serde_json::json!({})), - SyncEvent::TransactionUpdated { transaction_id } => ( - "transaction_updated", - serde_json::json!({ - "transaction_id": transaction_id.to_string() - }), - ), + SyncEvent::DerivationIndex { .. } => ("derivation", serde_json::json!({})), SyncEvent::TransactionFailed { transaction_id, error, @@ -311,13 +301,9 @@ impl Sage { "error": error }), ), - SyncEvent::OfferUpdated { offer_id, status } => ( - "offer_updated", - serde_json::json!({ - "offer_id": offer_id.to_string(), - "status": format!("{:?}", status) - }), - ), + SyncEvent::CoinsUpdated + | SyncEvent::TransactionUpdated { .. } + | SyncEvent::OfferUpdated { .. } => ("coin_state", serde_json::json!({})), SyncEvent::PuzzleBatchSynced => ("puzzle_batch_synced", serde_json::json!({})), SyncEvent::CatInfo => ("cat_info", serde_json::json!({})), SyncEvent::DidInfo => ("did_info", serde_json::json!({})), @@ -609,11 +595,12 @@ impl Sage { let entries = self.webhook_manager.get_webhook_entries().await; self.config.webhooks.webhooks = entries .into_iter() - .map(|(id, url, events, enabled)| WebhookEntry { + .map(|(id, url, events, enabled, secret)| WebhookEntry { id, url, events, enabled, + secret, last_delivered_at: None, last_delivery_attempt_at: None, }) @@ -624,12 +611,21 @@ impl Sage { } async fn setup_webhooks(&mut self) -> Result<()> { - let entries: Vec<(String, String, Option>, bool)> = self + type WebhookTuple = (String, String, Option>, bool, Option); + let entries: Vec = self .config .webhooks .webhooks .iter() - .map(|w| (w.id.clone(), w.url.clone(), w.events.clone(), w.enabled)) + .map(|w| { + ( + w.id.clone(), + w.url.clone(), + w.events.clone(), + w.enabled, + w.secret.clone(), + ) + }) .collect(); if !entries.is_empty() { diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index 527f168e1..b33b3301e 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -1,11 +1,18 @@ +use hmac::{Hmac, Mac}; use reqwest::Client; use serde::{Deserialize, Serialize}; +use sha2::Sha256; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use tracing::{debug, error}; use uuid::Uuid; +type HmacSha256 = Hmac; + +/// Webhook entry tuple: (id, url, events, enabled, secret) +type WebhookEntryTuple = (String, String, Option>, bool, Option); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebhookConfig { pub id: String, @@ -13,6 +20,8 @@ pub struct WebhookConfig { /// None means "all events, including future ones" pub events: Option>, pub active: bool, + /// Optional secret for HMAC-SHA256 signature verification + pub secret: Option, pub last_delivered_at: Option, pub last_delivery_attempt_at: Option, } @@ -63,13 +72,19 @@ impl WebhookManager { *self.network.write().await = network; } - pub async fn register_webhook(&self, url: String, events: Option>) -> String { + pub async fn register_webhook( + &self, + url: String, + events: Option>, + secret: Option, + ) -> String { let id = Uuid::new_v4().to_string(); let config = WebhookConfig { id: id.clone(), url, events, active: true, + secret, last_delivered_at: None, last_delivery_attempt_at: None, }; @@ -184,8 +199,29 @@ impl WebhookManager { webhook: &WebhookConfig, event: &WebhookEventPayload, ) -> Result<(), Box> { - let request = client.post(&webhook.url).json(event); - let response = request.send().await?; + // Serialize the event payload + let body = serde_json::to_vec(event)?; + + // Build the request + let mut request_builder = client + .post(&webhook.url) + .header("Content-Type", "application/json") + .body(body.clone()); + + // Compute HMAC signature if secret is provided + if let Some(secret) = &webhook.secret { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|e| format!("Invalid HMAC key: {e}"))?; + mac.update(&body); + let signature = mac.finalize(); + let signature_hex = hex::encode(signature.into_bytes()); + + // Add signature header in format: sha256= + request_builder = + request_builder.header("X-Webhook-Signature", format!("sha256={signature_hex}")); + } + + let response = request_builder.send().await?; if response.status().is_success() { Ok(()) @@ -199,14 +235,15 @@ impl WebhookManager { webhooks.values().cloned().collect() } - pub async fn load_webhooks(&self, entries: Vec<(String, String, Option>, bool)>) { + pub async fn load_webhooks(&self, entries: Vec) { let mut webhooks = self.webhooks.write().await; - for (id, url, events, enabled) in entries { + for (id, url, events, enabled, secret) in entries { let config = WebhookConfig { id: id.clone(), url, events, active: enabled, + secret, last_delivered_at: None, last_delivery_attempt_at: None, }; @@ -215,11 +252,19 @@ impl WebhookManager { } // Get webhooks in a format suitable for saving to config - pub async fn get_webhook_entries(&self) -> Vec<(String, String, Option>, bool)> { + pub async fn get_webhook_entries(&self) -> Vec { let webhooks = self.webhooks.read().await; webhooks .values() - .map(|w| (w.id.clone(), w.url.clone(), w.events.clone(), w.active)) + .map(|w| { + ( + w.id.clone(), + w.url.clone(), + w.events.clone(), + w.active, + w.secret.clone(), + ) + }) .collect() } } diff --git a/src/bindings.ts b/src/bindings.ts index c7dd03a56..9443fe93d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -555,7 +555,11 @@ export type PerformDatabaseMaintenance = { force_vacuum: boolean } export type PerformDatabaseMaintenanceResponse = { vacuum_duration_ms: number; analyze_duration_ms: number; wal_checkpoint_duration_ms: number; total_duration_ms: number; pages_vacuumed: number; wal_pages_checkpointed: number } export type RedownloadNft = { nft_id: string } export type RedownloadNftResponse = Record -export type RegisterWebhook = { url: string; event_types: string[] | null } +export type RegisterWebhook = { url: string; event_types: string[] | null; +/** + * Optional secret for HMAC-SHA256 signature verification + */ +secret: string | null } export type RegisterWebhookResponse = { webhook_id: string } export type RemovePeer = { ip: string; ban: boolean } export type RenameKey = { fingerprint: number; name: string } @@ -630,7 +634,11 @@ export type WebhookEntry = { id: string; url: string; /** * None means "all events, including future ones" */ -events: string[] | null; enabled: boolean; last_delivered_at: number | null; last_delivery_attempt_at: number | null } +events: string[] | null; enabled: boolean; +/** + * Optional secret for HMAC-SHA256 signature verification + */ +secret?: string | null; last_delivered_at: number | null; last_delivery_attempt_at: number | null } /** tauri-specta globals **/ diff --git a/webhook_testharness/app.js b/webhook_testharness/app.js index 5236f4da5..75590439a 100644 --- a/webhook_testharness/app.js +++ b/webhook_testharness/app.js @@ -1,4 +1,3 @@ -// Load environment variables from .env file require('dotenv').config(); var createError = require('http-errors'); @@ -9,43 +8,118 @@ var logger = require('morgan'); const bodyParser = require('body-parser'); const https = require('https'); const fs = require('fs'); +const crypto = require('crypto'); var indexRouter = require('./routes/index'); -// Store SSE connections for broadcasting webhook events const sseConnections = new Set(); +let webhookSecret = null; + var app = express(); -// view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'pug'); app.use(logger('dev')); -app.use(express.json()); app.use(express.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); -app.use(bodyParser.json()); -app.use('/', indexRouter); -app.post('/sage_hook', (req, res) => { - console.log(req.body); - - // Broadcast the webhook event to all SSE connections - const eventData = { - id: Date.now(), - event: 'webhook', - data: JSON.stringify({ - timestamp: new Date().toISOString(), - body: req.body, - }), - }; +// Webhook endpoint with HMAC verification (must be before bodyParser.json()) +app.post( + '/sage_hook', + bodyParser.raw({ type: 'application/json' }), + (req, res) => { + const signature = req.headers['x-webhook-signature']; + let verificationStatus = 'No signature required'; + let isValid = true; + + // Verify HMAC signature if secret is configured + if (webhookSecret) { + if (!signature) { + verificationStatus = '❌ FAILED: Missing signature header'; + isValid = false; + console.error('Webhook verification failed: Missing signature'); + } else { + try { + // Extract the signature from "sha256=" format + const signatureParts = signature.split('='); + if (signatureParts.length !== 2 || signatureParts[0] !== 'sha256') { + verificationStatus = '❌ FAILED: Invalid signature format'; + isValid = false; + console.error('Invalid signature format:', signature); + } else { + const receivedSignature = signatureParts[1]; + + const hmac = crypto.createHmac('sha256', webhookSecret); + hmac.update(req.body); + const expectedSignature = hmac.digest('hex'); + + if ( + crypto.timingSafeEqual( + Buffer.from(receivedSignature), + Buffer.from(expectedSignature), + ) + ) { + verificationStatus = '✅ VERIFIED'; + console.log('Webhook signature verified successfully'); + } else { + verificationStatus = '❌ FAILED: Signature mismatch'; + isValid = false; + console.error('Webhook verification failed: Signature mismatch'); + console.error('Expected:', expectedSignature); + console.error('Received:', receivedSignature); + } + } + } catch (error) { + verificationStatus = `❌ FAILED: ${error.message}`; + isValid = false; + console.error('Webhook verification error:', error); + } + } + } - broadcastSSEEvent(eventData); + const parsedBody = JSON.parse(req.body.toString()); + console.log('Webhook received:', parsedBody); + console.log('Verification status:', verificationStatus); - res.status(200).end(); + const eventData = { + id: Date.now(), + event: 'webhook', + data: JSON.stringify({ + timestamp: new Date().toISOString(), + body: parsedBody, + verification: verificationStatus, + signature: signature || 'none', + }), + }; + + broadcastSSEEvent(eventData); + + if (isValid) { + res.status(200).end(); + } else { + res.status(401).json({ error: 'Signature verification failed' }); + } + }, +); + +// Endpoint to sync secret from browser cookie to server memory +// don't do this in production - for deomnstration purposes on ly +app.post('/sync_secret', bodyParser.json(), (req, res) => { + const { secret } = req.body; + if (secret) { + webhookSecret = secret; + res.json({ status: 'ok', message: 'Secret synced' }); + } else { + webhookSecret = null; + res.json({ status: 'ok', message: 'Secret cleared' }); + } }); +app.use(bodyParser.json()); +app.use('/', indexRouter); + // SSE endpoint for webhook events app.get('/events', (req, res) => { // Set SSE headers @@ -66,7 +140,6 @@ app.get('/events', (req, res) => { })}\n\n`, ); - // Add this connection to our set sseConnections.add(res); // Handle client disconnect @@ -78,7 +151,6 @@ app.get('/events', (req, res) => { console.log('SSE client connected'); }); -// Function to broadcast events to all SSE connections function broadcastSSEEvent(eventData) { const message = `id: ${eventData.id}\nevent: ${eventData.event}\ndata: ${eventData.data}\n\n`; @@ -92,7 +164,6 @@ function broadcastSSEEvent(eventData) { }); } -// Helper function to create mTLS agent function createMTLSAgent() { const certPath = process.env.CLIENT_CERT_PATH; const keyPath = process.env.CLIENT_KEY_PATH; @@ -102,7 +173,6 @@ function createMTLSAgent() { let certData, keyData; if (certPath && keyPath) { - // Read from files try { certData = fs.readFileSync(certPath, 'utf8'); keyData = fs.readFileSync(keyPath, 'utf8'); @@ -130,6 +200,15 @@ function createMTLSAgent() { app.post('/proxy/register_webhook', (req, res) => { const agent = createMTLSAgent(); + // Store the secret if provided + if (req.body.secret) { + webhookSecret = req.body.secret; + console.log('Webhook secret stored for verification'); + } else { + webhookSecret = null; + console.log('No webhook secret provided'); + } + const postData = JSON.stringify(req.body); const options = { @@ -176,6 +255,9 @@ app.post('/proxy/register_webhook', (req, res) => { app.post('/proxy/unregister_webhook', (req, res) => { const agent = createMTLSAgent(); + webhookSecret = null; + console.log('Webhook secret cleared'); + const postData = JSON.stringify(req.body); const options = { diff --git a/webhook_testharness/views/index.pug b/webhook_testharness/views/index.pug index f5676459f..fbf902163 100644 --- a/webhook_testharness/views/index.pug +++ b/webhook_testharness/views/index.pug @@ -3,12 +3,20 @@ extends layout block content h2= title + div(style="margin-bottom: 20px;") + label(for="secret-input" style="display: block; margin-bottom: 5px; font-weight: bold;") Webhook Secret (optional): + input#secret-input(type="text" placeholder="Enter secret for HMAC verification" style="width: 100%; max-width: 500px; padding: 8px; border: 1px solid #ccc; border-radius: 4px;") + small(style="display: block; margin-top: 5px; color: #666;") If provided, webhooks will be signed with HMAC-SHA256 + button#register-btn(onclick="registerWebhook()") Register Webhook button#unregister-btn(onclick="unregisterWebhook()" style="display: none;") Unregister Webhook article.small h3 Registration: pre#response-display Hook not registered + div#verification-status(style="margin-top: 10px; padding: 10px; border-radius: 4px; background-color: #f0f0f0;") + strong HMAC Verification: + span#verification-enabled Disabled h3 Live Webhook Events: div.small @@ -32,18 +40,44 @@ block content } let webhookId = getCookie('webhookId'); + let webhookSecret = getCookie('webhookSecret'); function updateButtonStates() { if (webhookId) { $('#register-btn').hide(); $('#unregister-btn').show(); + $('#secret-input').prop('disabled', true); $('#response-display').text(`Webhook registered (ID: ${webhookId})`); } else { $('#register-btn').show(); $('#unregister-btn').hide(); + $('#secret-input').prop('disabled', false); + } + + if (webhookSecret) { + $('#verification-enabled').text('✅ Enabled').css('color', 'green'); + $('#verification-status').css('background-color', '#e8f5e9'); + } else { + $('#verification-enabled').text('❌ Disabled').css('color', '#666'); + $('#verification-status').css('background-color', '#f0f0f0'); } } + if (webhookSecret) { + $('#secret-input').val(webhookSecret); + fetch('/sync_secret', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ secret: webhookSecret }) + }).then(() => { + console.log('Secret synced to server'); + }).catch(err => { + console.error('Failed to sync secret:', err); + }); + } + function clearWebhookEvents() { $('#webhook-events').text('Webhook events cleared. Waiting for new events...'); webhookEventCount = 0; @@ -52,12 +86,30 @@ block content function registerWebhook() { clearWebhookEvents(); + const secret = $('#secret-input').val().trim(); + webhookSecret = secret || null; + + if (webhookSecret) { + setCookie('webhookSecret', webhookSecret); + } else { + deleteCookie('webhookSecret'); + } + + // Build request body + const requestBody = { + url: 'http://localhost:3000/sage_hook' + }; + + if (webhookSecret) { + requestBody.secret = webhookSecret; + } + fetch('/proxy/register_webhook', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url: 'http://localhost:3000/sage_hook' }), + body: JSON.stringify(requestBody), }) .then(response => response.json()) .then(data => { @@ -92,7 +144,10 @@ block content .then(response => response.json()) .then(data => { webhookId = null; + webhookSecret = null; deleteCookie('webhookId'); + deleteCookie('webhookSecret'); + $('#secret-input').val(''); updateButtonStates(); $('#response-display').text(JSON.stringify(data, null, 2)); }) @@ -158,7 +213,22 @@ block content webhookEventCount++; const timestamp = new Date().toLocaleTimeString(); - const eventHeader = `[${timestamp}] Event #${webhookEventCount} (${eventData.event}):`; + // Check if this is a webhook event with verification info + let eventHeader = `[${timestamp}] Event #${webhookEventCount} (${eventData.event})`; + + // Add verification status if present + if (eventData.data && typeof eventData.data === 'string') { + try { + const parsedData = JSON.parse(eventData.data); + if (parsedData.verification) { + eventHeader += ` - ${parsedData.verification}`; + } + } catch (e) { + // Not JSON or no verification field + } + } + + eventHeader += ':'; const eventJson = JSON.stringify(eventData, null, 2); const eventDisplay = eventHeader + '\n' + eventJson + '\n\n'; From c6b4d9e87ecf48beb58c86839402e07f71532d50 Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Tue, 7 Oct 2025 07:48:12 -0500 Subject: [PATCH 08/27] move settings to advanced tab --- crates/sage-api/src/requests/webhooks.rs | 1 - crates/sage/src/webhook_manager.rs | 5 - src/bindings.ts | 6 +- src/pages/Settings.tsx | 113 ++++++++++------------- webhook_testharness/app.js | 2 +- 5 files changed, 52 insertions(+), 75 deletions(-) diff --git a/crates/sage-api/src/requests/webhooks.rs b/crates/sage-api/src/requests/webhooks.rs index 94b65f2ae..8687669fd 100644 --- a/crates/sage-api/src/requests/webhooks.rs +++ b/crates/sage-api/src/requests/webhooks.rs @@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize}; pub struct RegisterWebhook { pub url: String, pub event_types: Option>, - /// Optional secret for HMAC-SHA256 signature verification pub secret: Option, } diff --git a/crates/sage/src/webhook_manager.rs b/crates/sage/src/webhook_manager.rs index b33b3301e..d90de0a37 100644 --- a/crates/sage/src/webhook_manager.rs +++ b/crates/sage/src/webhook_manager.rs @@ -20,7 +20,6 @@ pub struct WebhookConfig { /// None means "all events, including future ones" pub events: Option>, pub active: bool, - /// Optional secret for HMAC-SHA256 signature verification pub secret: Option, pub last_delivered_at: Option, pub last_delivery_attempt_at: Option, @@ -199,10 +198,8 @@ impl WebhookManager { webhook: &WebhookConfig, event: &WebhookEventPayload, ) -> Result<(), Box> { - // Serialize the event payload let body = serde_json::to_vec(event)?; - // Build the request let mut request_builder = client .post(&webhook.url) .header("Content-Type", "application/json") @@ -216,7 +213,6 @@ impl WebhookManager { let signature = mac.finalize(); let signature_hex = hex::encode(signature.into_bytes()); - // Add signature header in format: sha256= request_builder = request_builder.header("X-Webhook-Signature", format!("sha256={signature_hex}")); } @@ -251,7 +247,6 @@ impl WebhookManager { } } - // Get webhooks in a format suitable for saving to config pub async fn get_webhook_entries(&self) -> Vec { let webhooks = self.webhooks.read().await; webhooks diff --git a/src/bindings.ts b/src/bindings.ts index 9443fe93d..591046fc0 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -555,11 +555,7 @@ export type PerformDatabaseMaintenance = { force_vacuum: boolean } export type PerformDatabaseMaintenanceResponse = { vacuum_duration_ms: number; analyze_duration_ms: number; wal_checkpoint_duration_ms: number; total_duration_ms: number; pages_vacuumed: number; wal_pages_checkpointed: number } export type RedownloadNft = { nft_id: string } export type RedownloadNftResponse = Record -export type RegisterWebhook = { url: string; event_types: string[] | null; -/** - * Optional secret for HMAC-SHA256 signature verification - */ -secret: string | null } +export type RegisterWebhook = { url: string; event_types: string[] | null; secret: string | null } export type RegisterWebhookResponse = { webhook_id: string } export type RemovePeer = { ip: string; ban: boolean } export type RenameKey = { fingerprint: number; name: string } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7bc2b13e9..f0875a7f8 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -156,13 +156,6 @@ export default function Settings() { > Advanced - - - Webhooks -
@@ -207,13 +200,8 @@ export default function Settings() {
{!isMobile && } - -
-
- - -
+
@@ -1639,8 +1627,8 @@ function WebhooksSettings() { if (loading) { return ( -
- +
+
); @@ -1649,11 +1637,11 @@ function WebhooksSettings() { if (webhooks.length === 0) { return ( -
-
+
+
No webhooks registered
-
+
Register webhooks via the API to receive real-time notifications about wallet events @@ -1696,37 +1684,33 @@ function WebhooksSettings() { } return ( -
-
- {/* Header with ID and Status */} -
-
-
-
-
{statusLabel}
-
- {webhook.id} -
-
+
+
+ {/* Header with Status and ID on same line */} +
+
+
{statusLabel}
+
+ {webhook.id}
{/* URL */} -
-