From adcbab0f66b19b21c9e5ee8d71731f1150b95bec Mon Sep 17 00:00:00 2001 From: spacebear Date: Thu, 26 Feb 2026 21:23:03 -0500 Subject: [PATCH 01/16] Add NixOS module for payjoin-mailroom Expose a nixosModules.payjoin-mailroom flake output that provides a systemd service. Operators declare settings as native Nix attrsets (serialized to TOML), keep secrets in an environment file, and get DynamicUser, state persistence, and auto-start on boot out of the box. --- flake.lock | 1 + flake.nix | 5 +- nix/modules/payjoin-mailroom.nix | 116 +++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 nix/modules/payjoin-mailroom.nix diff --git a/flake.lock b/flake.lock index 615fdf1f2..da559e792 100644 --- a/flake.lock +++ b/flake.lock @@ -12,6 +12,7 @@ "original": { "owner": "ipetkov", "repo": "crane", + "rev": "2510f2cbc3ccd237f700bb213756a8f35c32d8d7", "type": "github" } }, diff --git a/flake.nix b/flake.nix index 51e453921..a56acbbd5 100644 --- a/flake.nix +++ b/flake.nix @@ -30,7 +30,10 @@ treefmt-nix, nix2container, }: - flake-utils.lib.eachDefaultSystem ( + { + nixosModules.payjoin-mailroom = import ./nix/modules/payjoin-mailroom.nix self; + } + // flake-utils.lib.eachDefaultSystem ( system: let pkgs = import nixpkgs { diff --git a/nix/modules/payjoin-mailroom.nix b/nix/modules/payjoin-mailroom.nix new file mode 100644 index 000000000..ae0675c29 --- /dev/null +++ b/nix/modules/payjoin-mailroom.nix @@ -0,0 +1,116 @@ +flake: +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.payjoin-mailroom; + settingsFormat = pkgs.formats.toml { }; + configFile = settingsFormat.generate "payjoin-mailroom.toml" cfg.settings; +in +{ + options.services.payjoin-mailroom = { + enable = lib.mkEnableOption "payjoin-mailroom, a combined payjoin directory and OHTTP relay"; + + package = lib.mkOption { + type = lib.types.package; + default = flake.packages.${pkgs.system}.payjoin-mailroom; + defaultText = lib.literalExpression "flake.packages.\${pkgs.system}.payjoin-mailroom"; + description = "The payjoin-mailroom package to use."; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + default = { }; + description = '' + Configuration for payjoin-mailroom, serialized to TOML. + See config.example.toml for available options. + ''; + example = lib.literalExpression '' + { + listener = "[::]:443"; + timeout = 30; + acme = { + domains = [ "payjo.in" ]; + contact = [ "mailto:admin@payjo.in" ]; + }; + } + ''; + }; + + environment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + description = "Additional environment variables to pass to the service."; + example = { + RUST_LOG = "debug"; + }; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + File containing environment variables for the service. + Useful for secrets like PJ_TELEMETRY__AUTH_TOKEN. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + services.payjoin-mailroom.settings = { + storage_dir = lib.mkDefault "/var/lib/payjoin-mailroom"; + }; + + systemd.services.payjoin-mailroom = { + description = "Payjoin Mailroom"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment = { + RUST_LOG = lib.mkDefault "info"; + } + // cfg.environment; + + serviceConfig = { + ExecStart = "${cfg.package}/bin/payjoin-mailroom --config ${configFile}"; + DynamicUser = true; + StateDirectory = "payjoin-mailroom"; + WorkingDirectory = "/var/lib/payjoin-mailroom"; + Restart = "on-failure"; + RestartSec = 5; + + # Allow binding to privileged ports (e.g. 443 for ACME) + AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" ]; + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + SystemCallArchitectures = "native"; + } + // lib.optionalAttrs (cfg.environmentFile != null) { + EnvironmentFile = cfg.environmentFile; + }; + }; + }; +} From e3ea98781361714b55067d1bb8189be49a7c333d Mon Sep 17 00:00:00 2001 From: spacebear Date: Fri, 27 Feb 2026 15:42:58 -0500 Subject: [PATCH 02/16] Update uniffi-bindgen-react-native to 0.30.0-1 Switch from a git commit ref to the npm-published 0.30.0-1 release. uniffi-runtime-javascript is now published on crates.io with uniffi_core 0.30 support, so the local path patch in wasm-manifest-patch.toml is no longer needed. --- payjoin-ffi/javascript/package-lock.json | 7 ++++--- payjoin-ffi/javascript/package.json | 2 +- payjoin-ffi/javascript/wasm-manifest-patch.toml | 3 --- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/payjoin-ffi/javascript/package-lock.json b/payjoin-ffi/javascript/package-lock.json index 1ce585b0b..a706b7c18 100644 --- a/payjoin-ffi/javascript/package-lock.json +++ b/payjoin-ffi/javascript/package-lock.json @@ -11,7 +11,7 @@ "prettier": "^3.6.2", "tsx": "^4.20.6", "typescript": "^5.9.3", - "uniffi-bindgen-react-native": "github:jhugman/uniffi-bindgen-react-native#93bed377c6e0b5cd7afd3fb7ee0d7211d5f294c5" + "uniffi-bindgen-react-native": "0.30.0-1" } }, "node_modules/@esbuild/aix-ppc64": { @@ -587,8 +587,9 @@ } }, "node_modules/uniffi-bindgen-react-native": { - "version": "0.29.3-1", - "resolved": "git+ssh://git@github.com/jhugman/uniffi-bindgen-react-native.git#93bed377c6e0b5cd7afd3fb7ee0d7211d5f294c5", + "version": "0.30.0-1", + "resolved": "https://registry.npmjs.org/uniffi-bindgen-react-native/-/uniffi-bindgen-react-native-0.30.0-1.tgz", + "integrity": "sha512-rffyNPTQ2qFx7deaLXYVBhXcy4pDrjtEc28Pc1Rwt532IGEQquT7eNvvLKIgX6G5xODi6Yfni1zFs5u+O2QL1A==", "dev": true, "license": "MPL-2.0", "bin": { diff --git a/payjoin-ffi/javascript/package.json b/payjoin-ffi/javascript/package.json index 9c0d3469e..85600b612 100644 --- a/payjoin-ffi/javascript/package.json +++ b/payjoin-ffi/javascript/package.json @@ -29,7 +29,7 @@ "prettier": "^3.6.2", "tsx": "^4.20.6", "typescript": "^5.9.3", - "uniffi-bindgen-react-native": "github:jhugman/uniffi-bindgen-react-native#93bed377c6e0b5cd7afd3fb7ee0d7211d5f294c5" + "uniffi-bindgen-react-native": "0.30.0-1" }, "repository": { "type": "git", diff --git a/payjoin-ffi/javascript/wasm-manifest-patch.toml b/payjoin-ffi/javascript/wasm-manifest-patch.toml index cf63fc014..f601cac33 100644 --- a/payjoin-ffi/javascript/wasm-manifest-patch.toml +++ b/payjoin-ffi/javascript/wasm-manifest-patch.toml @@ -20,6 +20,3 @@ features = ["wasm-unstable-single-threaded"] payjoin = { path = "../../../../payjoin" } payjoin-directory = { path = "../../../../payjoin-directory" } payjoin-test-utils = { path = "../../../../payjoin-test-utils" } -# uniffi-runtime-javascript is not yet published with uniffi_core 0.30 support. -# This path patch lets cargo resolve it from the installed npm package. -uniffi-runtime-javascript = { path = "../../node_modules/uniffi-bindgen-react-native/crates/uniffi-runtime-javascript" } From 190dd18e7bd151fffecf54b3f99fcff4897334d1 Mon Sep 17 00:00:00 2001 From: spacebear Date: Fri, 27 Feb 2026 15:44:07 -0500 Subject: [PATCH 03/16] Update uniffi-dart to v0.1.0+v0.30.0 Switch from a pinned rev on chavic/uniffi-dart to the tagged v0.1.0+v0.30.0 release on Uniffi-Dart/uniffi-dart. --- Cargo-minimal.lock | 8 ++++---- Cargo-recent.lock | 8 ++++---- payjoin-ffi/Cargo.toml | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo-minimal.lock b/Cargo-minimal.lock index e19304b0f..7009fcb40 100644 --- a/Cargo-minimal.lock +++ b/Cargo-minimal.lock @@ -4752,8 +4752,8 @@ dependencies = [ [[package]] name = "uniffi-dart" -version = "0.1.0" -source = "git+https://github.com/chavic/uniffi-dart.git?rev=2078791#2078791fe1f93e7a71a8a25c9934adb54d97d52e" +version = "0.1.0+v0.30.0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" dependencies = [ "anyhow", "camino", @@ -4823,8 +4823,8 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" -version = "0.1.0" -source = "git+https://github.com/chavic/uniffi-dart.git?rev=2078791#2078791fe1f93e7a71a8a25c9934adb54d97d52e" +version = "0.1.0+v0.30.0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" dependencies = [ "futures", "proc-macro2", diff --git a/Cargo-recent.lock b/Cargo-recent.lock index e19304b0f..7009fcb40 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -4752,8 +4752,8 @@ dependencies = [ [[package]] name = "uniffi-dart" -version = "0.1.0" -source = "git+https://github.com/chavic/uniffi-dart.git?rev=2078791#2078791fe1f93e7a71a8a25c9934adb54d97d52e" +version = "0.1.0+v0.30.0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" dependencies = [ "anyhow", "camino", @@ -4823,8 +4823,8 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" -version = "0.1.0" -source = "git+https://github.com/chavic/uniffi-dart.git?rev=2078791#2078791fe1f93e7a71a8a25c9934adb54d97d52e" +version = "0.1.0+v0.30.0" +source = "git+https://github.com/Uniffi-Dart/uniffi-dart.git?tag=v0.1.0%2Bv0.30.0#e3ed67f780257a5a7fae23231e13d84f931208e0" dependencies = [ "futures", "proc-macro2", diff --git a/payjoin-ffi/Cargo.toml b/payjoin-ffi/Cargo.toml index 78ba7782a..942c45397 100644 --- a/payjoin-ffi/Cargo.toml +++ b/payjoin-ffi/Cargo.toml @@ -34,7 +34,7 @@ thiserror = "2.0.14" tokio = { version = "1.47.1", features = ["full"], optional = true } uniffi = { version = "0.30.0", features = ["cli"] } uniffi-bindgen-cs = { git = "https://github.com/chavic/uniffi-bindgen-cs.git", rev = "878a3d269eacce64beadcd336ade0b7c8da09824", optional = true } -uniffi-dart = { git = "https://github.com/chavic/uniffi-dart.git", rev = "2078791", optional = true } +uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart.git", tag = "v0.1.0+v0.30.0", optional = true } url = "2.5.4" # getrandom is ignored here because it's required by the wasm_js feature From ef8cc636355fe3fe03dbc8353ba48f4d9d7edee8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 2 Mar 2026 11:38:41 +0000 Subject: [PATCH 04/16] chore: update flake.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flake lock file updates: • Updated input 'nixpkgs': 'github:NixOS/nixpkgs/23d72da' (2026-02-07) → 'github:NixOS/nixpkgs/1267bb4' (2026-02-25) • Updated input 'rust-overlay': 'github:oxalica/rust-overlay/11a3965' (2026-02-09) → 'github:oxalica/rust-overlay/458eea8' (2026-03-02) --- flake.lock | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/flake.lock b/flake.lock index 615fdf1f2..25178b2d4 100644 --- a/flake.lock +++ b/flake.lock @@ -12,6 +12,7 @@ "original": { "owner": "ipetkov", "repo": "crane", + "rev": "2510f2cbc3ccd237f700bb213756a8f35c32d8d7", "type": "github" } }, @@ -68,11 +69,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1770464364, - "narHash": "sha256-z5NJPSBwsLf/OfD8WTmh79tlSU8XgIbwmk6qB1/TFzY=", + "lastModified": 1772047000, + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "23d72dabcb3b12469f57b37170fcbc1789bd7457", + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", "type": "github" }, "original": { @@ -99,11 +100,11 @@ ] }, "locked": { - "lastModified": 1770606655, - "narHash": "sha256-rpJf+kxvLWv32ivcgu8d+JeJooog3boJCT8J3joJvvM=", + "lastModified": 1772420823, + "narHash": "sha256-q3oVwz1Rx41D1D+F6vg41kpOkk3Zi3KwnkHEZp7DCGs=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "11a396520bf911e4ed01e78e11633d3fc63b350e", + "rev": "458eea8d905c609e9d889423e6b8a1c7bc2f792c", "type": "github" }, "original": { From 891711e855345b18a8c5c6d92e1f7f565324b865 Mon Sep 17 00:00:00 2001 From: DanGould Date: Tue, 3 Mar 2026 01:25:54 +0800 Subject: [PATCH 05/16] Take references in crate-internal HPKE API decrypt_message_a and decrypt_message_b only borrow their secret-key arguments, so accept &HpkeSecretKey instead of owned HpkeSecretKey. Removes .clone() at every call site. Also make encrypt_message_a take `mut body` in the signature to match encrypt_message_b, dropping the `let mut body = body` rebind. Note: the entire hpke module is pub(crate), so none of these are public API changes. --- payjoin/src/core/hpke.rs | 29 ++++++++++++++--------------- payjoin/src/core/receive/v2/mod.rs | 4 ++-- payjoin/src/core/send/v2/mod.rs | 4 ++-- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/payjoin/src/core/hpke.rs b/payjoin/src/core/hpke.rs index 0deb2f5d1..c1bcffc7b 100644 --- a/payjoin/src/core/hpke.rs +++ b/payjoin/src/core/hpke.rs @@ -171,7 +171,7 @@ impl<'de> serde::Deserialize<'de> for HpkePublicKey { /// Message A is sent from the sender to the receiver containing an Original PSBT payload pub fn encrypt_message_a( - body: Vec, + mut body: Vec, reply_pk: &HpkePublicKey, receiver_pk: &HpkePublicKey, ) -> Result, HpkeError> { @@ -182,7 +182,6 @@ pub fn encrypt_message_a( INFO_A, &mut OsRng, )?; - let mut body = body; pad_plaintext(&mut body, PADDED_PLAINTEXT_A_LENGTH)?; let mut plaintext = compressed_bytes_from_pubkey(reply_pk).to_vec(); plaintext.extend(body); @@ -194,7 +193,7 @@ pub fn encrypt_message_a( pub fn decrypt_message_a( message_a: &[u8], - receiver_sk: HpkeSecretKey, + receiver_sk: &HpkeSecretKey, ) -> Result<(Vec, HpkePublicKey), HpkeError> { use std::io::{Cursor, Read}; @@ -247,7 +246,7 @@ pub fn encrypt_message_b( pub fn decrypt_message_b( message_b: &[u8], receiver_pk: HpkePublicKey, - sender_sk: HpkeSecretKey, + sender_sk: &HpkeSecretKey, ) -> Result, HpkeError> { let enc = message_b.get(..ELLSWIFT_ENCODING_SIZE).ok_or(HpkeError::PayloadTooShort)?; let enc = encapped_key_from_ellswift_bytes(enc)?; @@ -345,7 +344,7 @@ mod test { .expect("encryption should work"); assert_eq!(message_a.len(), PADDED_MESSAGE_BYTES); - let decrypted = decrypt_message_a(&message_a, receiver_keypair.secret_key().clone()) + let decrypted = decrypt_message_a(&message_a, receiver_keypair.secret_key()) .expect("decryption should work"); assert_eq!(decrypted.0.len(), PADDED_PLAINTEXT_A_LENGTH); @@ -363,7 +362,7 @@ mod test { ) .expect("encryption should work"); - let decrypted = decrypt_message_a(&message_a, receiver_keypair.secret_key().clone()) + let decrypted = decrypt_message_a(&message_a, receiver_keypair.secret_key()) .expect("decryption should work"); assert_eq!(decrypted.0.len(), plaintext.len()); @@ -371,20 +370,20 @@ mod test { let unrelated_keypair = HpkeKeyPair::gen_keypair(); assert_eq!( - decrypt_message_a(&message_a, unrelated_keypair.secret_key().clone()), + decrypt_message_a(&message_a, unrelated_keypair.secret_key()), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); let mut corrupted_message_a = message_a.clone(); corrupted_message_a[3] ^= 1; // corrupt dhkem assert_eq!( - decrypt_message_a(&corrupted_message_a, receiver_keypair.secret_key().clone()), + decrypt_message_a(&corrupted_message_a, receiver_keypair.secret_key()), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); let mut corrupted_message_a = message_a.clone(); corrupted_message_a[PADDED_MESSAGE_BYTES - 3] ^= 1; // corrupt aead ciphertext assert_eq!( - decrypt_message_a(&corrupted_message_a, receiver_keypair.secret_key().clone()), + decrypt_message_a(&corrupted_message_a, receiver_keypair.secret_key()), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); @@ -418,7 +417,7 @@ mod test { let decrypted = decrypt_message_b( &message_b, receiver_keypair.public_key().clone(), - reply_keypair.secret_key().clone(), + reply_keypair.secret_key(), ) .expect("decryption should work"); @@ -437,7 +436,7 @@ mod test { let decrypted = decrypt_message_b( &message_b, receiver_keypair.public_key().clone(), - reply_keypair.secret_key().clone(), + reply_keypair.secret_key(), ) .expect("decryption should work"); assert_eq!(decrypted.len(), plaintext.len()); @@ -448,7 +447,7 @@ mod test { decrypt_message_b( &message_b, receiver_keypair.public_key().clone(), - unrelated_keypair.secret_key().clone() // wrong decryption key + unrelated_keypair.secret_key() // wrong decryption key ), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); @@ -456,7 +455,7 @@ mod test { decrypt_message_b( &message_b, unrelated_keypair.public_key().clone(), // wrong auth key - reply_keypair.secret_key().clone() + reply_keypair.secret_key() ), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); @@ -467,7 +466,7 @@ mod test { decrypt_message_b( &corrupted_message_b, receiver_keypair.public_key().clone(), - reply_keypair.secret_key().clone() + reply_keypair.secret_key() ), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); @@ -477,7 +476,7 @@ mod test { decrypt_message_b( &corrupted_message_b, receiver_keypair.public_key().clone(), - reply_keypair.secret_key().clone() + reply_keypair.secret_key() ), Err(HpkeError::Hpke(hpke::HpkeError::OpenError)) ); diff --git a/payjoin/src/core/receive/v2/mod.rs b/payjoin/src/core/receive/v2/mod.rs index ba49fe120..307cd6a50 100644 --- a/payjoin/src/core/receive/v2/mod.rs +++ b/payjoin/src/core/receive/v2/mod.rs @@ -473,7 +473,7 @@ impl Receiver { response: Vec, ) -> Result<(OriginalPayload, HpkePublicKey), ProtocolError> { let (payload_bytes, reply_key) = - decrypt_message_a(&response, self.session_context.receiver_key.secret_key().clone()) + decrypt_message_a(&response, self.session_context.receiver_key.secret_key()) .map_err(|e| ProtocolError::V2(InternalSessionError::Hpke(e).into()))?; let payload = std::str::from_utf8(&payload_bytes) .map_err(|e| ProtocolError::OriginalPayload(InternalPayloadError::Utf8(e).into()))?; @@ -1187,7 +1187,7 @@ impl Receiver { let body = { if let Some(reply_key) = &session_context.reply_key { encrypt_message_b( - self.error_reply.to_json().to_string().as_bytes().to_vec(), + self.error_reply.to_json().to_string().into_bytes(), &session_context.receiver_key, reply_key, ) diff --git a/payjoin/src/core/send/v2/mod.rs b/payjoin/src/core/send/v2/mod.rs index d2ea8f403..7a36553e3 100644 --- a/payjoin/src/core/send/v2/mod.rs +++ b/payjoin/src/core/send/v2/mod.rs @@ -377,7 +377,7 @@ pub(crate) fn extract_request( ) -> Result<(Request, ClientResponse), CreateRequestError> { let body = encrypt_message_a( body, - &HpkeKeyPair::from_secret_key(&session_context.reply_key).public_key().clone(), + HpkeKeyPair::from_secret_key(&session_context.reply_key).public_key(), session_context.pj_param.receiver_pubkey(), ) .map_err(InternalCreateRequestError::Hpke)?; @@ -495,7 +495,7 @@ impl Sender { let body = match decrypt_message_b( &body, self.session_context.pj_param.receiver_pubkey().clone(), - self.session_context.reply_key.clone(), + &self.session_context.reply_key, ) { Ok(body) => body, Err(e) => From 89572f7cfb89fc1b3da93721702c07bd908556c9 Mon Sep 17 00:00:00 2001 From: DanGould Date: Sun, 1 Mar 2026 14:55:01 +0800 Subject: [PATCH 06/16] Take cert_der by reference in fetch_ohttp_keys_with_cert The cert_der parameter is only passed to Certificate::from_der which borrows it, so accept &[u8] instead of Vec. Fix call sites in payjoin-cli and payjoin-test-utils accordingly. Other pub fns in io.rs and ohttp.rs keep ownership where consumed (ohttp_context, impl IntoUrl). --- payjoin-cli/src/app/v2/ohttp.rs | 2 +- payjoin-test-utils/src/lib.rs | 2 +- payjoin/src/core/io.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/payjoin-cli/src/app/v2/ohttp.rs b/payjoin-cli/src/app/v2/ohttp.rs index aa9b55d84..eecf85cf3 100644 --- a/payjoin-cli/src/app/v2/ohttp.rs +++ b/payjoin-cli/src/app/v2/ohttp.rs @@ -85,7 +85,7 @@ async fn fetch_ohttp_keys( payjoin::io::fetch_ohttp_keys_with_cert( selected_relay.as_str(), payjoin_directory.as_str(), - cert_der, + &cert_der, ) .await } else { diff --git a/payjoin-test-utils/src/lib.rs b/payjoin-test-utils/src/lib.rs index 9c467ca29..889ae3c8d 100644 --- a/payjoin-test-utils/src/lib.rs +++ b/payjoin-test-utils/src/lib.rs @@ -103,7 +103,7 @@ impl TestServices { fetch_ohttp_keys_with_cert( self.ohttp_relay_url().as_str(), self.directory_url().as_str(), - self.cert(), + &self.cert(), ) .await } diff --git a/payjoin/src/core/io.rs b/payjoin/src/core/io.rs index 5a6e8c365..d335a3fce 100644 --- a/payjoin/src/core/io.rs +++ b/payjoin/src/core/io.rs @@ -45,13 +45,13 @@ pub async fn fetch_ohttp_keys( pub async fn fetch_ohttp_keys_with_cert( ohttp_relay: impl IntoUrl, payjoin_directory: impl IntoUrl, - cert_der: Vec, + cert_der: &[u8], ) -> Result { let ohttp_keys_url = payjoin_directory.into_url()?.join("/.well-known/ohttp-gateway")?; let proxy = Proxy::all(ohttp_relay.into_url()?.as_str())?; let client = Client::builder() .use_rustls_tls() - .add_root_certificate(reqwest::tls::Certificate::from_der(&cert_der)?) + .add_root_certificate(reqwest::tls::Certificate::from_der(cert_der)?) .proxy(proxy) .http1_only() .build()?; From fd951a0921874c8533c89bf3cbe1a2583ff59933 Mon Sep 17 00:00:00 2001 From: spacebear Date: Wed, 4 Mar 2026 12:45:31 -0500 Subject: [PATCH 07/16] Add ACME section for mailroom example config --- payjoin-mailroom/config.example.toml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/payjoin-mailroom/config.example.toml b/payjoin-mailroom/config.example.toml index 6a9e995fe..11ffb52dc 100644 --- a/payjoin-mailroom/config.example.toml +++ b/payjoin-mailroom/config.example.toml @@ -13,7 +13,7 @@ # Request timeout in seconds # timeout = 30 -# --- Telemetry (requires `--telemetry` feature) --- +# --- Telemetry (requires `telemetry` feature) --- # [telemetry] # OpenTelemetry Protocol (OTLP) endpoint to export telemetry to @@ -40,6 +40,18 @@ # IP addresses or CIDR ranges whose requests should be blocked. # blocked_ips = ["192.0.2.0/24", "2001:db8::1"] +# --- ACME TLS (requires `acme` feature) --- +# [acme] + +# Domain names for the TLS certificate +# domains = ["your-domain.example.com"] + +# Contact addresses for the ACME account +# contact = ["mailto:contact@example.com"] + +# Custom ACME directory URL (defaults to Let's Encrypt production) +# directory_url = "https://acme-v02.api.letsencrypt.org/directory" + # --- V1 protocol --- # Uncomment the [v1] section to enable V1 fallback support. # (address screening requires `access-control` feature) From c10f5343520672fd338161f841d3682c45277755 Mon Sep 17 00:00:00 2001 From: Mshehu5 Date: Wed, 21 Jan 2026 23:21:16 +0100 Subject: [PATCH 08/16] Add safe catch-all for secp256k1::Error conversion in HpkeError Replace panic with Other(String) variant to handle unknown secp256k1 error variants. This prevents crashes for unknown varients and if new variants are added upstream. --- payjoin/src/core/hpke.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/payjoin/src/core/hpke.rs b/payjoin/src/core/hpke.rs index 0deb2f5d1..0616c82c6 100644 --- a/payjoin/src/core/hpke.rs +++ b/payjoin/src/core/hpke.rs @@ -277,6 +277,7 @@ pub enum HpkeError { InvalidKeyLength, PayloadTooLarge { actual: usize, max: usize }, PayloadTooShort, + UnexpectedSecp256k1Error, } impl From for HpkeError { @@ -286,10 +287,11 @@ impl From for HpkeError { impl From for HpkeError { fn from(value: secp256k1::Error) -> Self { match value { - // As of writing, this is the only relevant variant that could arise here. - // This may need to be updated if relevant variants are added to secp256k1 + // As of writing, this is the only relevant variant that could arise here. The other variant has + // been added due to new secp256k1::Error variants that may be added in the future. update this + // match statement if relevant error variants that are needed are added to secp256k1 secp256k1::Error::InvalidPublicKey => Self::InvalidPublicKey, - _ => panic!("Unsupported variant of secp256k1::Error"), + _other => Self::UnexpectedSecp256k1Error, } } } @@ -309,6 +311,7 @@ impl fmt::Display for HpkeError { } PayloadTooShort => write!(f, "Payload too small"), InvalidPublicKey => write!(f, "Invalid public key"), + UnexpectedSecp256k1Error => write!(f, "Unexpected secp256k1 error"), } } } @@ -322,6 +325,7 @@ impl error::Error for HpkeError { PayloadTooLarge { .. } => None, InvalidKeyLength | PayloadTooShort => None, InvalidPublicKey => None, + UnexpectedSecp256k1Error => None, } } } @@ -330,6 +334,25 @@ impl error::Error for HpkeError { mod test { use super::*; + #[test] + fn secp256k1_error_conversion_no_panic() { + // Test the known variant that maps to InvalidPublicKey(update if new variants are added) + let err = secp256k1::Error::InvalidPublicKey; + let hpke_err: HpkeError = err.into(); + assert_eq!(hpke_err, HpkeError::InvalidPublicKey); + // Test other variants that may arise + let other_variants = [ + secp256k1::Error::InvalidSecretKey, + secp256k1::Error::InvalidRecoveryId, + secp256k1::Error::InvalidTweak, + secp256k1::Error::NotEnoughMemory, + ]; + for err in other_variants { + let hpke_err: HpkeError = err.into(); + assert_eq!(hpke_err, HpkeError::UnexpectedSecp256k1Error); + } + } + #[test] fn message_a_round_trip() { let mut plaintext = "foo".as_bytes().to_vec(); From 3e5d171f5d63abf07fd2e282a316180f9dba176f Mon Sep 17 00:00:00 2001 From: Mshehu5 Date: Thu, 5 Mar 2026 22:44:05 +0100 Subject: [PATCH 09/16] Add async OHTTP key fetch helper for Python Introduce `fetch_ohttp_keys_async` in the Python bindings and update integration tests to use it instead of `TestServices.fetch_ohttp_keys()`. This keeps key retrieval inside Python HTTP code and aligns test flow with the async sender/receiver path. The helper fetches `/.well-known/ohttp-gateway` through the configured relay proxy, validates TLS with the provided DER certificate and decodes the response into `OhttpKeys`. --- payjoin-ffi/python/src/payjoin/http.py | 28 +++++++++++++++++++ .../test/test_payjoin_integration_test.py | 6 ++-- 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 payjoin-ffi/python/src/payjoin/http.py diff --git a/payjoin-ffi/python/src/payjoin/http.py b/payjoin-ffi/python/src/payjoin/http.py new file mode 100644 index 000000000..70c391700 --- /dev/null +++ b/payjoin-ffi/python/src/payjoin/http.py @@ -0,0 +1,28 @@ +import ssl +from urllib.parse import urljoin + +import httpx + +from .payjoin import OhttpKeys + + +async def fetch_ohttp_keys( + ohttp_relay_url: str, + directory_url: str, + certificate: bytes, +) -> OhttpKeys: + keys_url = urljoin(directory_url, "/.well-known/ohttp-gateway") + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ssl_context.check_hostname = True + ssl_context.verify_mode = ssl.CERT_REQUIRED + pem_certificate = ssl.DER_cert_to_PEM_cert(certificate) + ssl_context.load_verify_locations(cadata=pem_certificate) + async with httpx.AsyncClient( + proxy=ohttp_relay_url, verify=ssl_context, timeout=30.0 + ) as client: + response = await client.get( + keys_url, + headers={"Accept": "application/ohttp-keys"}, + ) + response.raise_for_status() + return OhttpKeys.decode(response.content) diff --git a/payjoin-ffi/python/test/test_payjoin_integration_test.py b/payjoin-ffi/python/test/test_payjoin_integration_test.py index 6ef45de28..4763a3050 100644 --- a/payjoin-ffi/python/test/test_payjoin_integration_test.py +++ b/payjoin-ffi/python/test/test_payjoin_integration_test.py @@ -4,6 +4,7 @@ import json from payjoin import * +from payjoin.http import fetch_ohttp_keys from typing import Optional import unittest @@ -98,7 +99,8 @@ async def test_ffi_validation(self): services = TestServices.initialize() services.wait_for_services_ready() directory = services.directory_url() - ohttp_keys = services.fetch_ohttp_keys() + ohttp_relay = services.ohttp_relay_url() + ohttp_keys = await fetch_ohttp_keys(ohttp_relay, directory, services.cert()) recv_persister = InMemoryReceiverSessionEventLog(999) pj_uri = self.create_receiver_context( receiver_address, directory, ohttp_keys, recv_persister @@ -261,8 +263,8 @@ async def test_integration_v2_to_v2(self): services.wait_for_services_ready() directory = services.directory_url() - ohttp_keys = services.fetch_ohttp_keys() ohttp_relay = services.ohttp_relay_url() + ohttp_keys = await fetch_ohttp_keys(ohttp_relay, directory, services.cert()) agent = httpx.AsyncClient() # ********************** From 32c12297c15ef8623f61f5d63a2cc970e846fa8d Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 12:50:31 +0800 Subject: [PATCH 10/16] Replace issue-based standup with Discussion system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-step Issue→Discussion standup flow with a single Discussion-based system that auto-gathers each contributor's weekly activity (merged PRs, reviews, issues) and creates pre-populated threaded replies. Changes: - Add create_standup_discussion.py with GitHub API activity gathering and GraphQL Discussion/comment creation - Update standup-prompt.yml to call new script and pass DISCUSSION_CATEGORY_NODE_ID - Remove compile_standup.py and standup-compile.yml since the Discussion IS the standup (no compile step needed) - Remove create_standup_issue.py (superseded) --- .github/scripts/compile_standup.py | 154 ------------ .github/scripts/create_standup_discussion.py | 241 +++++++++++++++++++ .github/scripts/create_standup_issue.py | 69 ------ .github/workflows/standup-compile.yml | 22 -- .github/workflows/standup-prompt.yml | 7 +- 5 files changed, 245 insertions(+), 248 deletions(-) delete mode 100644 .github/scripts/compile_standup.py create mode 100644 .github/scripts/create_standup_discussion.py delete mode 100644 .github/scripts/create_standup_issue.py delete mode 100644 .github/workflows/standup-compile.yml diff --git a/.github/scripts/compile_standup.py b/.github/scripts/compile_standup.py deleted file mode 100644 index cd023d8be..000000000 --- a/.github/scripts/compile_standup.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 -"""Compile standup responses into a GitHub Discussion and close the issue.""" - -import os -from datetime import datetime, timezone, timedelta - -import requests - -REPO = os.environ["GITHUB_REPOSITORY"] -TOKEN = os.environ["STANDUP_TOKEN"] -CATEGORY_NODE_ID = os.environ["DISCUSSION_CATEGORY_NODE_ID"] -API = "https://api.github.com" -GRAPHQL = "https://api.github.com/graphql" -HEADERS = { - "Authorization": f"token {TOKEN}", - "Accept": "application/vnd.github+json", -} - - -def find_standup_issue(): - """Find the most recent open standup-input issue from the last 7 days.""" - since = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat() - resp = requests.get( - f"{API}/repos/{REPO}/issues", - headers=HEADERS, - params={ - "labels": "standup-input", - "state": "open", - "since": since, - "sort": "created", - "direction": "desc", - "per_page": 1, - }, - ) - resp.raise_for_status() - issues = resp.json() - if not issues: - print("No standup-input issue found in the last 7 days.") - return None - return issues[0] - - -def fetch_comments(issue_number): - """Fetch all comments on an issue.""" - comments = [] - page = 1 - while True: - resp = requests.get( - f"{API}/repos/{REPO}/issues/{issue_number}/comments", - headers=HEADERS, - params={"per_page": 100, "page": page}, - ) - resp.raise_for_status() - batch = resp.json() - if not batch: - break - comments.extend(batch) - page += 1 - return comments - - -def get_repo_node_id(): - """Get the repository node ID for the GraphQL mutation.""" - resp = requests.get(f"{API}/repos/{REPO}", headers=HEADERS) - resp.raise_for_status() - return resp.json()["node_id"] - - -def create_discussion(title, body, repo_node_id): - """Create a GitHub Discussion via GraphQL.""" - mutation = """ - mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) { - createDiscussion(input: { - repositoryId: $repoId, - categoryId: $categoryId, - title: $title, - body: $body - }) { - discussion { - url - } - } - } - """ - resp = requests.post( - GRAPHQL, - headers=HEADERS, - json={ - "query": mutation, - "variables": { - "repoId": repo_node_id, - "categoryId": CATEGORY_NODE_ID, - "title": title, - "body": body, - }, - }, - ) - resp.raise_for_status() - data = resp.json() - if "errors" in data: - raise RuntimeError(f"GraphQL errors: {data['errors']}") - return data["data"]["createDiscussion"]["discussion"]["url"] - - -def close_issue(issue_number, discussion_url): - """Close the standup issue with a link to the compiled discussion.""" - requests.post( - f"{API}/repos/{REPO}/issues/{issue_number}/comments", - headers=HEADERS, - json={"body": f"Compiled into discussion: {discussion_url}"}, - ) - requests.patch( - f"{API}/repos/{REPO}/issues/{issue_number}", - headers=HEADERS, - json={"state": "closed"}, - ) - - -def main(): - issue = find_standup_issue() - if not issue: - return - - issue_number = issue["number"] - # Extract the week label from the issue title - title_suffix = issue["title"].removeprefix("Standup Input: ") - week_label = title_suffix or datetime.now(timezone.utc).strftime("Week of %Y-%m-%d") - - comments = fetch_comments(issue_number) - - # Build sections per contributor - sections = [] - for comment in comments: - user = comment["user"]["login"] - if comment["user"]["type"] == "Bot": - continue - body = comment["body"].strip() - sections.append(f"### @{user}\n{body}") - - updates = "\n\n".join(sections) if sections else "_No responses._" - - discussion_title = f"Weekly Check-in: {week_label}" - discussion_body = updates - - repo_node_id = get_repo_node_id() - discussion_url = create_discussion(discussion_title, discussion_body, repo_node_id) - print(f"Created discussion: {discussion_url}") - - close_issue(issue_number, discussion_url) - print(f"Closed issue #{issue_number}") - - -if __name__ == "__main__": - main() diff --git a/.github/scripts/create_standup_discussion.py b/.github/scripts/create_standup_discussion.py new file mode 100644 index 000000000..bfc15cd50 --- /dev/null +++ b/.github/scripts/create_standup_discussion.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +"""Create a weekly standup Discussion with auto-gathered activity per contributor.""" + +import os +from datetime import datetime, timezone, timedelta + +import requests + +REPO = os.environ["GITHUB_REPOSITORY"] +TOKEN = os.environ["STANDUP_TOKEN"] +CATEGORY_NODE_ID = os.environ["DISCUSSION_CATEGORY_NODE_ID"] +API = "https://api.github.com" +GRAPHQL = "https://api.github.com/graphql" +HEADERS = { + "Authorization": f"token {TOKEN}", + "Accept": "application/vnd.github+json", +} + +CONTRIBUTORS = [ + "DanGould", + "spacebear21", + "arminsabouri", + "benalleng", + "chavic", + "zealsham", + "Mshehu5", +] + +ORG = "payjoin" + + +def get_repo_node_id(): + """Get the repository node ID for GraphQL mutations.""" + resp = requests.get(f"{API}/repos/{REPO}", headers=HEADERS) + resp.raise_for_status() + return resp.json()["node_id"] + + +def create_discussion(title, body, repo_node_id): + """Create a GitHub Discussion via GraphQL and return its node ID and URL.""" + mutation = """ + mutation($repoId: ID!, $categoryId: ID!, $title: String!, $body: String!) { + createDiscussion(input: { + repositoryId: $repoId, + categoryId: $categoryId, + title: $title, + body: $body + }) { + discussion { + id + url + } + } + } + """ + resp = requests.post( + GRAPHQL, + headers=HEADERS, + json={ + "query": mutation, + "variables": { + "repoId": repo_node_id, + "categoryId": CATEGORY_NODE_ID, + "title": title, + "body": body, + }, + }, + ) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + raise RuntimeError(f"GraphQL errors: {data['errors']}") + discussion = data["data"]["createDiscussion"]["discussion"] + return discussion["id"], discussion["url"] + + +def add_discussion_comment(discussion_id, body): + """Add a threaded comment to a Discussion via GraphQL.""" + mutation = """ + mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: { + discussionId: $discussionId, + body: $body + }) { + comment { + id + } + } + } + """ + resp = requests.post( + GRAPHQL, + headers=HEADERS, + json={ + "query": mutation, + "variables": { + "discussionId": discussion_id, + "body": body, + }, + }, + ) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + raise RuntimeError(f"GraphQL errors: {data['errors']}") + + +def search_issues(query): + """Run a GitHub search/issues query and return the items.""" + resp = requests.get( + f"{API}/search/issues", + headers=HEADERS, + params={"q": query, "per_page": 30}, + ) + resp.raise_for_status() + return resp.json().get("items", []) + + +def gather_activity(user, since_date): + """Gather a contributor's past-week activity across the org.""" + since = since_date.strftime("%Y-%m-%d") + + # PRs merged (authored) + merged_prs = search_issues(f"author:{user} org:{ORG} type:pr merged:>{since}") + + # PRs reviewed + reviewed_prs = search_issues( + f"reviewed-by:{user} org:{ORG} type:pr updated:>{since}" + ) + # Exclude PRs the user authored (already counted above) + reviewed_prs = [pr for pr in reviewed_prs if pr["user"]["login"] != user] + + # Issues opened + issues_opened = search_issues( + f"author:{user} org:{ORG} type:issue created:>{since}" + ) + + return merged_prs, reviewed_prs, issues_opened + + +def gather_potential_blockers(user, since_date): + """Identify potential blockers for a contributor (stretch goal).""" + since = since_date.strftime("%Y-%m-%d") + blockers = [] + + # Open PRs with no reviews + open_prs = search_issues( + f"author:{user} org:{ORG} type:pr state:open review:none created:>{since}" + ) + for pr in open_prs: + blockers.append(f"- PR awaiting review: [{pr['title']}]({pr['html_url']})") + + # PRs with requested changes + changes_requested = search_issues( + f"author:{user} org:{ORG} type:pr state:open review:changes_requested" + ) + for pr in changes_requested: + blockers.append( + f"- PR has requested changes: [{pr['title']}]({pr['html_url']})" + ) + + return blockers + + +def format_contributor_comment(user, merged_prs, reviewed_prs, issues_opened, blockers): + """Format the threaded reply for a contributor.""" + lines = [f"## @{user}", ""] + + # SHIPPED section + lines.append("### Shipped") + if merged_prs or reviewed_prs or issues_opened: + if merged_prs: + lines.append("") + lines.append("**PRs merged:**") + for pr in merged_prs: + lines.append(f"- [{pr['title']}]({pr['html_url']})") + + if reviewed_prs: + lines.append("") + lines.append("**PRs reviewed:**") + for pr in reviewed_prs: + lines.append(f"- [{pr['title']}]({pr['html_url']})") + + if issues_opened: + lines.append("") + lines.append("**Issues opened:**") + for issue in issues_opened: + lines.append(f"- [{issue['title']}]({issue['html_url']})") + else: + lines.append("_No activity found — please edit to add yours._") + + # Focus section (for contributor to fill in) + lines.append("") + lines.append("### Focus") + lines.append("_What are you working on this week? (please edit)_") + + # Blockers section + lines.append("") + lines.append("### Blockers") + if blockers: + lines.append("_Auto-detected (please edit/confirm):_") + lines.extend(blockers) + else: + lines.append("_Any blockers? Name who can help. (please edit)_") + + return "\n".join(lines) + + +def main(): + today = datetime.now(timezone.utc) + week_label = today.strftime("Week of %Y-%m-%d") + since_date = today - timedelta(days=7) + + repo_node_id = get_repo_node_id() + + # Create the Discussion + title = f"Weekly Check-in: {week_label}" + body = ( + "Weekly standup — each contributor has a thread below " + "with auto-gathered activity.\n\n" + "**Please review your thread and edit to add Focus and Blockers " + "by end-of-day Monday (your timezone).**" + ) + discussion_id, discussion_url = create_discussion(title, body, repo_node_id) + print(f"Created discussion: {discussion_url}") + + # Create a threaded reply for each contributor + for user in CONTRIBUTORS: + merged_prs, reviewed_prs, issues_opened = gather_activity(user, since_date) + blockers = gather_potential_blockers(user, since_date) + comment_body = format_contributor_comment( + user, merged_prs, reviewed_prs, issues_opened, blockers + ) + add_discussion_comment(discussion_id, comment_body) + print(f" Added thread for @{user}") + + print("Done.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/create_standup_issue.py b/.github/scripts/create_standup_issue.py deleted file mode 100644 index 13773d5d1..000000000 --- a/.github/scripts/create_standup_issue.py +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env python3 -"""Create a weekly standup input issue and ping contributors.""" - -import os -from datetime import datetime, timezone - -import requests - -REPO = os.environ["GITHUB_REPOSITORY"] -TOKEN = os.environ["STANDUP_TOKEN"] -API = "https://api.github.com" -HEADERS = { - "Authorization": f"token {TOKEN}", - "Accept": "application/vnd.github+json", -} - -CONTRIBUTORS = [ - "DanGould", - "spacebear21", - "arminsabouri", - "benalleng", - "chavic", - "zealsham", - "Mshehu5", -] - - -def main(): - today = datetime.now(timezone.utc) - week_label = today.strftime("%Y-%m-%d") - title = f"Standup Input: Week of {week_label}" - - cc_line = " ".join(f"@{u}" for u in CONTRIBUTORS) - body = ( - "Please reply by **Monday end-of-day** (your timezone).\n\n" - "Format:\n" - "- **Shipped**: What you landed last week (PR/issue links)\n" - "- **Focus**: What you're working on this week\n" - "- **Blockers**: Anything stopping you — name who can help\n\n" - f"cc {cc_line}" - ) - - # Ensure the label exists - label_url = f"{API}/repos/{REPO}/labels/standup-input" - resp = requests.get(label_url, headers=HEADERS) - if resp.status_code == 404: - requests.post( - f"{API}/repos/{REPO}/labels", - headers=HEADERS, - json={ - "name": "standup-input", - "color": "0E8A16", - "description": "Weekly standup input issue", - }, - ) - - # Create the issue - resp = requests.post( - f"{API}/repos/{REPO}/issues", - headers=HEADERS, - json={"title": title, "body": body, "labels": ["standup-input"]}, - ) - resp.raise_for_status() - issue = resp.json() - print(f"Created issue #{issue['number']}: {issue['html_url']}") - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/standup-compile.yml b/.github/workflows/standup-compile.yml deleted file mode 100644 index bdb0b4bed..000000000 --- a/.github/workflows/standup-compile.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Standup Compile - -on: - schedule: - # Tuesday 06:00 UTC = Tuesday 14:00 Taipei - - cron: "0 6 * * 2" - workflow_dispatch: - -jobs: - compile: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: pip install requests - - run: python .github/scripts/compile_standup.py - env: - GITHUB_REPOSITORY: ${{ github.repository }} - STANDUP_TOKEN: ${{ secrets.STANDUP_TOKEN }} - DISCUSSION_CATEGORY_NODE_ID: ${{ secrets.DISCUSSION_CATEGORY_NODE_ID }} diff --git a/.github/workflows/standup-prompt.yml b/.github/workflows/standup-prompt.yml index 80aa84b38..059e6f591 100644 --- a/.github/workflows/standup-prompt.yml +++ b/.github/workflows/standup-prompt.yml @@ -1,4 +1,4 @@ -name: Standup Prompt +name: Weekly Standup on: schedule: @@ -7,7 +7,7 @@ on: workflow_dispatch: jobs: - create-issue: + create-discussion: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -15,7 +15,8 @@ jobs: with: python-version: "3.12" - run: pip install requests - - run: python .github/scripts/create_standup_issue.py + - run: python .github/scripts/create_standup_discussion.py env: GITHUB_REPOSITORY: ${{ github.repository }} STANDUP_TOKEN: ${{ secrets.STANDUP_TOKEN }} + DISCUSSION_CATEGORY_NODE_ID: ${{ secrets.DISCUSSION_CATEGORY_NODE_ID }} From 5f4e719bb237d3de49e6bb708a542cc38f267f74 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 12:50:52 +0800 Subject: [PATCH 11/16] Reframe Blockers as singular Bottleneck --- .github/scripts/create_standup_discussion.py | 49 +++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/scripts/create_standup_discussion.py b/.github/scripts/create_standup_discussion.py index bfc15cd50..338d5bbe6 100644 --- a/.github/scripts/create_standup_discussion.py +++ b/.github/scripts/create_standup_discussion.py @@ -138,31 +138,33 @@ def gather_activity(user, since_date): return merged_prs, reviewed_prs, issues_opened -def gather_potential_blockers(user, since_date): - """Identify potential blockers for a contributor (stretch goal).""" +def gather_potential_bottlenecks(user, since_date): + """Identify potential bottlenecks for a contributor.""" since = since_date.strftime("%Y-%m-%d") - blockers = [] + bottlenecks = [] # Open PRs with no reviews open_prs = search_issues( f"author:{user} org:{ORG} type:pr state:open review:none created:>{since}" ) for pr in open_prs: - blockers.append(f"- PR awaiting review: [{pr['title']}]({pr['html_url']})") + bottlenecks.append(f"- PR awaiting review: [{pr['title']}]({pr['html_url']})") # PRs with requested changes changes_requested = search_issues( f"author:{user} org:{ORG} type:pr state:open review:changes_requested" ) for pr in changes_requested: - blockers.append( + bottlenecks.append( f"- PR has requested changes: [{pr['title']}]({pr['html_url']})" ) - return blockers + return bottlenecks -def format_contributor_comment(user, merged_prs, reviewed_prs, issues_opened, blockers): +def format_contributor_comment( + user, merged_prs, reviewed_prs, issues_opened, bottlenecks +): """Format the threaded reply for a contributor.""" lines = [f"## @{user}", ""] @@ -194,14 +196,27 @@ def format_contributor_comment(user, merged_prs, reviewed_prs, issues_opened, bl lines.append("### Focus") lines.append("_What are you working on this week? (please edit)_") - # Blockers section + # Bottleneck section lines.append("") - lines.append("### Blockers") - if blockers: - lines.append("_Auto-detected (please edit/confirm):_") - lines.extend(blockers) - else: - lines.append("_Any blockers? Name who can help. (please edit)_") + lines.append("### Bottleneck") + lines.append("") + lines.append( + "What is the single biggest bottleneck in progress toward your greater goal?" + ) + lines.append( + "Name your goal. Name the constraint. Name who or what can unblock it." + ) + lines.append("") + lines.append( + '*(There\'s always one. Not just "waiting on review." Example: ' + '"Goal: ship mailroom to production. Bottleneck: I need 30 min ' + "with @X to align on the ohttp-relay migration plan before I can " + 'write the PR.")*' + ) + if bottlenecks: + lines.append("") + lines.append("_Auto-detected signals:_") + lines.extend(bottlenecks) return "\n".join(lines) @@ -218,7 +233,7 @@ def main(): body = ( "Weekly standup — each contributor has a thread below " "with auto-gathered activity.\n\n" - "**Please review your thread and edit to add Focus and Blockers " + "**Please review your thread and edit to add Focus and Bottleneck " "by end-of-day Monday (your timezone).**" ) discussion_id, discussion_url = create_discussion(title, body, repo_node_id) @@ -227,9 +242,9 @@ def main(): # Create a threaded reply for each contributor for user in CONTRIBUTORS: merged_prs, reviewed_prs, issues_opened = gather_activity(user, since_date) - blockers = gather_potential_blockers(user, since_date) + bottlenecks = gather_potential_bottlenecks(user, since_date) comment_body = format_contributor_comment( - user, merged_prs, reviewed_prs, issues_opened, blockers + user, merged_prs, reviewed_prs, issues_opened, bottlenecks ) add_discussion_comment(discussion_id, comment_body) print(f" Added thread for @{user}") From 0950c04d43f9fc7855176ac6a7b376154b04680c Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 12:51:06 +0800 Subject: [PATCH 12/16] Fence contributor template for copy-paste --- .github/scripts/create_standup_discussion.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/scripts/create_standup_discussion.py b/.github/scripts/create_standup_discussion.py index 338d5bbe6..bc2a0ebcb 100644 --- a/.github/scripts/create_standup_discussion.py +++ b/.github/scripts/create_standup_discussion.py @@ -191,12 +191,11 @@ def format_contributor_comment( else: lines.append("_No activity found — please edit to add yours._") - # Focus section (for contributor to fill in) + # Fenced template for contributor to copy-paste and fill in lines.append("") + lines.append("```") lines.append("### Focus") - lines.append("_What are you working on this week? (please edit)_") - - # Bottleneck section + lines.append("What are you working on this week? (please edit)") lines.append("") lines.append("### Bottleneck") lines.append("") @@ -208,11 +207,12 @@ def format_contributor_comment( ) lines.append("") lines.append( - '*(There\'s always one. Not just "waiting on review." Example: ' + '(There\'s always one. Not just "waiting on review." Example: ' '"Goal: ship mailroom to production. Bottleneck: I need 30 min ' "with @X to align on the ohttp-relay migration plan before I can " - 'write the PR.")*' + 'write the PR.")' ) + lines.append("```") if bottlenecks: lines.append("") lines.append("_Auto-detected signals:_") From 0de6f6307f9036abe047c22755ee347ae889c7c4 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 12:51:10 +0800 Subject: [PATCH 13/16] Extract contributor list to config file --- .github/scripts/create_standup_discussion.py | 14 +++++--------- .github/standup-contributors.yml | 9 +++++++++ .github/workflows/standup-prompt.yml | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) create mode 100644 .github/standup-contributors.yml diff --git a/.github/scripts/create_standup_discussion.py b/.github/scripts/create_standup_discussion.py index bc2a0ebcb..618173096 100644 --- a/.github/scripts/create_standup_discussion.py +++ b/.github/scripts/create_standup_discussion.py @@ -3,8 +3,10 @@ import os from datetime import datetime, timezone, timedelta +from pathlib import Path import requests +import yaml REPO = os.environ["GITHUB_REPOSITORY"] TOKEN = os.environ["STANDUP_TOKEN"] @@ -16,15 +18,9 @@ "Accept": "application/vnd.github+json", } -CONTRIBUTORS = [ - "DanGould", - "spacebear21", - "arminsabouri", - "benalleng", - "chavic", - "zealsham", - "Mshehu5", -] +_CONFIG_PATH = Path(__file__).resolve().parent.parent / "standup-contributors.yml" +with open(_CONFIG_PATH) as _f: + CONTRIBUTORS = [c["username"] for c in yaml.safe_load(_f)["contributors"]] ORG = "payjoin" diff --git a/.github/standup-contributors.yml b/.github/standup-contributors.yml new file mode 100644 index 000000000..6a5a0d1b9 --- /dev/null +++ b/.github/standup-contributors.yml @@ -0,0 +1,9 @@ +contributors: + - username: DanGould + - username: spacebear21 + - username: arminsabouri + - username: benalleng + - username: chavic + - username: zealsham + - username: Mshehu5 + - username: 0xZaddyy diff --git a/.github/workflows/standup-prompt.yml b/.github/workflows/standup-prompt.yml index 059e6f591..40a799b60 100644 --- a/.github/workflows/standup-prompt.yml +++ b/.github/workflows/standup-prompt.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install requests + - run: pip install requests pyyaml - run: python .github/scripts/create_standup_discussion.py env: GITHUB_REPOSITORY: ${{ github.repository }} From c98f9dd2da662c193ccbe6417d2ed57a45778e79 Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 12:51:24 +0800 Subject: [PATCH 14/16] Update workflow for bot token --- .github/workflows/standup-prompt.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/standup-prompt.yml b/.github/workflows/standup-prompt.yml index 40a799b60..466f4d583 100644 --- a/.github/workflows/standup-prompt.yml +++ b/.github/workflows/standup-prompt.yml @@ -18,5 +18,5 @@ jobs: - run: python .github/scripts/create_standup_discussion.py env: GITHUB_REPOSITORY: ${{ github.repository }} - STANDUP_TOKEN: ${{ secrets.STANDUP_TOKEN }} + STANDUP_TOKEN: ${{ secrets.STANDUP_BOT_TOKEN || secrets.STANDUP_TOKEN }} DISCUSSION_CATEGORY_NODE_ID: ${{ secrets.DISCUSSION_CATEGORY_NODE_ID }} From 2e335bebfde8195f16c092f8519946ee03a3e25a Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 12:57:58 +0800 Subject: [PATCH 15/16] Add DRY_RUN mode to standup script When DRY_RUN is set, gather activity and print what would be posted without creating a Discussion or sending notifications. --- .github/scripts/create_standup_discussion.py | 28 +++++++++++++------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/scripts/create_standup_discussion.py b/.github/scripts/create_standup_discussion.py index 618173096..c41849a29 100644 --- a/.github/scripts/create_standup_discussion.py +++ b/.github/scripts/create_standup_discussion.py @@ -222,9 +222,25 @@ def main(): week_label = today.strftime("Week of %Y-%m-%d") since_date = today - timedelta(days=7) - repo_node_id = get_repo_node_id() + dry_run = os.environ.get("DRY_RUN") + + # Gather all comments first + comments = [] + for user in CONTRIBUTORS: + merged_prs, reviewed_prs, issues_opened = gather_activity(user, since_date) + bottlenecks = gather_potential_bottlenecks(user, since_date) + comment_body = format_contributor_comment( + user, merged_prs, reviewed_prs, issues_opened, bottlenecks + ) + comments.append((user, comment_body)) - # Create the Discussion + if dry_run: + for user, comment_body in comments: + print(f"--- {user} ---\n{comment_body}\n") + print("Dry run complete — nothing was created.") + return + + repo_node_id = get_repo_node_id() title = f"Weekly Check-in: {week_label}" body = ( "Weekly standup — each contributor has a thread below " @@ -235,13 +251,7 @@ def main(): discussion_id, discussion_url = create_discussion(title, body, repo_node_id) print(f"Created discussion: {discussion_url}") - # Create a threaded reply for each contributor - for user in CONTRIBUTORS: - merged_prs, reviewed_prs, issues_opened = gather_activity(user, since_date) - bottlenecks = gather_potential_bottlenecks(user, since_date) - comment_body = format_contributor_comment( - user, merged_prs, reviewed_prs, issues_opened, bottlenecks - ) + for user, comment_body in comments: add_discussion_comment(discussion_id, comment_body) print(f" Added thread for @{user}") From 2f82c569c6caab2e65bb4fc3a0ed92d11849472e Mon Sep 17 00:00:00 2001 From: DanGould Date: Fri, 6 Mar 2026 13:03:21 +0800 Subject: [PATCH 16/16] Compile s'up participation before Tuesday meeting Runs Tuesday 06:00 UTC before the standup meeting. Edits the Discussion body to show which contributors replied to their thread. Uses an HTML comment marker so re-runs update in place rather than appending. --- .github/scripts/compile_standup.py | 160 ++++++++++++++++++++++++++ .github/workflows/standup-compile.yml | 21 ++++ 2 files changed, 181 insertions(+) create mode 100644 .github/scripts/compile_standup.py create mode 100644 .github/workflows/standup-compile.yml diff --git a/.github/scripts/compile_standup.py b/.github/scripts/compile_standup.py new file mode 100644 index 000000000..957263baa --- /dev/null +++ b/.github/scripts/compile_standup.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Update the latest Weekly Check-in Discussion body with participation summary.""" + +import os +from pathlib import Path + +import requests +import yaml + +REPO = os.environ["GITHUB_REPOSITORY"] +TOKEN = os.environ["STANDUP_TOKEN"] +GRAPHQL = "https://api.github.com/graphql" +HEADERS = { + "Authorization": f"token {TOKEN}", + "Accept": "application/vnd.github+json", +} + +_CONFIG_PATH = Path(__file__).resolve().parent.parent / "standup-contributors.yml" +with open(_CONFIG_PATH) as _f: + CONTRIBUTORS = [c["username"] for c in yaml.safe_load(_f)["contributors"]] + + +def graphql(query, variables=None): + """Run a GraphQL query and return the data.""" + resp = requests.post( + GRAPHQL, + headers=HEADERS, + json={"query": query, "variables": variables or {}}, + ) + resp.raise_for_status() + data = resp.json() + if "errors" in data: + raise RuntimeError(f"GraphQL errors: {data['errors']}") + return data["data"] + + +def find_latest_checkin(): + """Find the most recent Weekly Check-in Discussion.""" + owner, name = REPO.split("/") + data = graphql( + """ + query($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + discussions(first: 10, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + id + title + url + body + } + } + } + } + """, + {"owner": owner, "name": name}, + ) + for d in data["repository"]["discussions"]["nodes"]: + if d["title"].startswith("Weekly Check-in:"): + return d + return None + + +def get_discussion_comments(discussion_id): + """Fetch top-level comments and their replies for a Discussion.""" + data = graphql( + """ + query($id: ID!) { + node(id: $id) { + ... on Discussion { + comments(first: 50) { + nodes { + body + replies(first: 50) { + nodes { + author { login } + } + } + } + } + } + } + } + """, + {"id": discussion_id}, + ) + return data["node"]["comments"]["nodes"] + + +def check_participation(comments): + """Return list of contributors who replied to their thread.""" + participated = [] + for comment in comments: + body = comment["body"] + for user in CONTRIBUTORS: + if f"@{user}" not in body: + continue + reply_authors = { + r["author"]["login"] for r in comment["replies"]["nodes"] if r["author"] + } + if user in reply_authors: + participated.append(user) + return participated + + +def update_discussion_body(discussion_id, new_body): + """Edit the Discussion body via GraphQL.""" + graphql( + """ + mutation($discussionId: ID!, $body: String!) { + updateDiscussion(input: { + discussionId: $discussionId, + body: $body + }) { + discussion { id } + } + } + """, + {"discussionId": discussion_id, "body": new_body}, + ) + + +PARTICIPATION_MARKER = "" + + +def main(): + dry_run = os.environ.get("DRY_RUN") + + discussion = find_latest_checkin() + if not discussion: + print("No Weekly Check-in discussion found.") + return + + print(f"Found: {discussion['url']}") + + comments = get_discussion_comments(discussion["id"]) + participated = check_participation(comments) + + if participated: + names = ", ".join(f"@{u}" for u in participated) + participation_line = f"**Participated:** {names}" + else: + participation_line = "**Participated:** _(none yet)_" + + # Strip any previous participation section, then append + body = discussion["body"] + if PARTICIPATION_MARKER in body: + body = body[: body.index(PARTICIPATION_MARKER)].rstrip() + + new_body = f"{body}\n\n{PARTICIPATION_MARKER}\n{participation_line}" + + if dry_run: + print(f"Would update body to:\n---\n{new_body}\n---") + return + + update_discussion_body(discussion["id"], new_body) + print(f"Updated discussion: {participation_line}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/standup-compile.yml b/.github/workflows/standup-compile.yml new file mode 100644 index 000000000..90e0d5354 --- /dev/null +++ b/.github/workflows/standup-compile.yml @@ -0,0 +1,21 @@ +name: Standup Compile + +on: + schedule: + # Tuesday 06:00 UTC = Tuesday 14:00 Taipei (before the meeting) + - cron: "0 6 * * 2" + workflow_dispatch: + +jobs: + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install requests pyyaml + - run: python .github/scripts/compile_standup.py + env: + GITHUB_REPOSITORY: ${{ github.repository }} + STANDUP_TOKEN: ${{ secrets.STANDUP_BOT_TOKEN || secrets.STANDUP_TOKEN }}