diff --git a/.github/scripts/compile_standup.py b/.github/scripts/compile_standup.py index cd023d8be..957263baa 100644 --- a/.github/scripts/compile_standup.py +++ b/.github/scripts/compile_standup.py @@ -1,153 +1,159 @@ #!/usr/bin/env python3 -"""Compile standup responses into a GitHub Discussion and close the issue.""" +"""Update the latest Weekly Check-in Discussion body with participation summary.""" 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"] -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", } +_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 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 - } - } - } - """ + +def graphql(query, variables=None): + """Run a GraphQL query and return the data.""" resp = requests.post( GRAPHQL, headers=HEADERS, - json={ - "query": mutation, - "variables": { - "repoId": repo_node_id, - "categoryId": CATEGORY_NODE_ID, - "title": title, - "body": body, - }, - }, + 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"]["createDiscussion"]["discussion"]["url"] + 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 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 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(): - issue = find_standup_issue() - if not issue: + dry_run = os.environ.get("DRY_RUN") + + discussion = find_latest_checkin() + if not discussion: + print("No Weekly Check-in discussion found.") 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") + print(f"Found: {discussion['url']}") - comments = fetch_comments(issue_number) + comments = get_discussion_comments(discussion["id"]) + participated = check_participation(comments) - # 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}") + if participated: + names = ", ".join(f"@{u}" for u in participated) + participation_line = f"**Participated:** {names}" + else: + participation_line = "**Participated:** _(none yet)_" - updates = "\n\n".join(sections) if sections else "_No responses._" + # Strip any previous participation section, then append + body = discussion["body"] + if PARTICIPATION_MARKER in body: + body = body[: body.index(PARTICIPATION_MARKER)].rstrip() - discussion_title = f"Weekly Check-in: {week_label}" - discussion_body = updates + new_body = f"{body}\n\n{PARTICIPATION_MARKER}\n{participation_line}" - repo_node_id = get_repo_node_id() - discussion_url = create_discussion(discussion_title, discussion_body, repo_node_id) - print(f"Created discussion: {discussion_url}") + if dry_run: + print(f"Would update body to:\n---\n{new_body}\n---") + return - close_issue(issue_number, discussion_url) - print(f"Closed issue #{issue_number}") + update_discussion_body(discussion["id"], new_body) + print(f"Updated discussion: {participation_line}") if __name__ == "__main__": diff --git a/.github/scripts/create_standup_discussion.py b/.github/scripts/create_standup_discussion.py new file mode 100644 index 000000000..c41849a29 --- /dev/null +++ b/.github/scripts/create_standup_discussion.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Create a weekly standup Discussion with auto-gathered activity per contributor.""" + +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"] +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", +} + +_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" + + +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_bottlenecks(user, since_date): + """Identify potential bottlenecks for a contributor.""" + since = since_date.strftime("%Y-%m-%d") + 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: + 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: + bottlenecks.append( + f"- PR has requested changes: [{pr['title']}]({pr['html_url']})" + ) + + return bottlenecks + + +def format_contributor_comment( + user, merged_prs, reviewed_prs, issues_opened, bottlenecks +): + """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._") + + # 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)") + lines.append("") + 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.")' + ) + lines.append("```") + if bottlenecks: + lines.append("") + lines.append("_Auto-detected signals:_") + lines.extend(bottlenecks) + + 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) + + 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)) + + 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 " + "with auto-gathered activity.\n\n" + "**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) + print(f"Created discussion: {discussion_url}") + + for user, comment_body in comments: + 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/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-compile.yml b/.github/workflows/standup-compile.yml index bdb0b4bed..90e0d5354 100644 --- a/.github/workflows/standup-compile.yml +++ b/.github/workflows/standup-compile.yml @@ -2,7 +2,7 @@ name: Standup Compile on: schedule: - # Tuesday 06:00 UTC = Tuesday 14:00 Taipei + # Tuesday 06:00 UTC = Tuesday 14:00 Taipei (before the meeting) - cron: "0 6 * * 2" workflow_dispatch: @@ -14,9 +14,8 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.12" - - run: pip install requests + - run: pip install requests pyyaml - 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 }} + STANDUP_TOKEN: ${{ secrets.STANDUP_BOT_TOKEN || secrets.STANDUP_TOKEN }} diff --git a/.github/workflows/standup-prompt.yml b/.github/workflows/standup-prompt.yml index 80aa84b38..466f4d583 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,15 +7,16 @@ on: workflow_dispatch: jobs: - create-issue: + create-discussion: 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/create_standup_issue.py + - run: pip install requests pyyaml + - 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 }} 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/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": { 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; + }; + }; + }; +} 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-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 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" } 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() # ********************** 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) 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/hpke.rs b/payjoin/src/core/hpke.rs index 0deb2f5d1..aa12756af 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)?; @@ -277,6 +276,7 @@ pub enum HpkeError { InvalidKeyLength, PayloadTooLarge { actual: usize, max: usize }, PayloadTooShort, + UnexpectedSecp256k1Error, } impl From for HpkeError { @@ -286,10 +286,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 +310,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 +324,7 @@ impl error::Error for HpkeError { PayloadTooLarge { .. } => None, InvalidKeyLength | PayloadTooShort => None, InvalidPublicKey => None, + UnexpectedSecp256k1Error => None, } } } @@ -330,6 +333,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(); @@ -345,7 +367,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 +385,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 +393,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 +440,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 +459,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 +470,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 +478,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 +489,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 +499,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/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()?; 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) =>