max / makenotwork
20 files changed,
+601 insertions,
-531 deletions
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | # mnw-cli TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: Phases 1-8, Git proxy A-D (incl D5 DNS), UX audit fixes (8/8). Deployed 2026-05-03. Active: None. Next: PoM health check, astra test. | |
| 4 | + | Done: Phases 1-8, Git proxy A-D (incl D5 DNS), UX audit fixes (8/8), PoM health check, astra test. Deployed 2026-05-05. Active: None. Next: Post-beta features. | |
| 5 | 5 | ||
| 6 | 6 | --- | |
| 7 | 7 | ||
| @@ -30,7 +30,7 @@ Priority order (highest impact first): | |||
| 30 | 30 | - [x] D6: Restart sequence verified — admin SSH on 2200, mnw-cli on 22, both running — done 2026-04-22 | |
| 31 | 31 | ||
| 32 | 32 | ## Deploy | |
| 33 | - | - [ ] Test on astra (full TUI + SFTP + git push/pull) | |
| 33 | + | - [x] Test on astra (full TUI + SFTP + git push/pull) — tested from both macbook and astra; registered astra SSH key; fixed bare repo HEAD refs | |
| 34 | 34 | - [x] Deploy to hetzner (cross-compile via deploy.sh) — done 2026-04-22 | |
| 35 | 35 | - [x] Verified: SSH auth, TUI launch, git ls-remote, git clone all working — 2026-04-22 | |
| 36 | 36 | - [x] Fixed: NoNewPrivileges blocking sudo for git ops — 2026-04-22 |
| @@ -25,7 +25,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | |||
| 25 | 25 | dependencies = [ | |
| 26 | 26 | "cfg-if", | |
| 27 | 27 | "cipher", | |
| 28 | - | "cpufeatures", | |
| 28 | + | "cpufeatures 0.2.17", | |
| 29 | 29 | ] | |
| 30 | 30 | ||
| 31 | 31 | [[package]] | |
| @@ -80,9 +80,9 @@ dependencies = [ | |||
| 80 | 80 | ||
| 81 | 81 | [[package]] | |
| 82 | 82 | name = "annotate-snippets" | |
| 83 | - | version = "0.12.13" | |
| 83 | + | version = "0.12.15" | |
| 84 | 84 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 85 | - | checksum = "74fc7650eedcb2fee505aad48491529e408f0e854c2d9f63eb86c1361b9b3f93" | |
| 85 | + | checksum = "92570a3f9c98e7e84df84b71d0965ac99b1871fcd75a3773a3bd1bad13f64cf7" | |
| 86 | 86 | dependencies = [ | |
| 87 | 87 | "anstyle", | |
| 88 | 88 | "memchr", | |
| @@ -159,7 +159,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |||
| 159 | 159 | dependencies = [ | |
| 160 | 160 | "base64ct", | |
| 161 | 161 | "blake2", | |
| 162 | - | "cpufeatures", | |
| 162 | + | "cpufeatures 0.2.17", | |
| 163 | 163 | "password-hash", | |
| 164 | 164 | ] | |
| 165 | 165 | ||
| @@ -320,7 +320,7 @@ dependencies = [ | |||
| 320 | 320 | "chrono", | |
| 321 | 321 | "futures-util", | |
| 322 | 322 | "hex", | |
| 323 | - | "hmac", | |
| 323 | + | "hmac 0.12.1", | |
| 324 | 324 | "http-types", | |
| 325 | 325 | "hyper 0.14.32", | |
| 326 | 326 | "hyper-tls 0.5.0", | |
| @@ -328,7 +328,7 @@ dependencies = [ | |||
| 328 | 328 | "serde_json", | |
| 329 | 329 | "serde_path_to_error", | |
| 330 | 330 | "serde_qs 0.10.1", | |
| 331 | - | "sha2", | |
| 331 | + | "sha2 0.10.9", | |
| 332 | 332 | "smart-default", | |
| 333 | 333 | "smol_str", | |
| 334 | 334 | "thiserror 1.0.69", | |
| @@ -380,8 +380,8 @@ dependencies = [ | |||
| 380 | 380 | "aws-sdk-ssooidc", | |
| 381 | 381 | "aws-sdk-sts", | |
| 382 | 382 | "aws-smithy-async", | |
| 383 | - | "aws-smithy-http 0.63.6", | |
| 384 | - | "aws-smithy-json 0.62.5", | |
| 383 | + | "aws-smithy-http", | |
| 384 | + | "aws-smithy-json", | |
| 385 | 385 | "aws-smithy-runtime", | |
| 386 | 386 | "aws-smithy-runtime-api", | |
| 387 | 387 | "aws-smithy-types", | |
| @@ -390,7 +390,7 @@ dependencies = [ | |||
| 390 | 390 | "fastrand 2.3.0", | |
| 391 | 391 | "hex", | |
| 392 | 392 | "http 1.4.0", | |
| 393 | - | "sha1", | |
| 393 | + | "sha1 0.10.6", | |
| 394 | 394 | "time", | |
| 395 | 395 | "tokio", | |
| 396 | 396 | "tracing", | |
| @@ -434,15 +434,15 @@ dependencies = [ | |||
| 434 | 434 | ||
| 435 | 435 | [[package]] | |
| 436 | 436 | name = "aws-runtime" | |
| 437 | - | version = "1.7.2" | |
| 437 | + | version = "1.7.3" | |
| 438 | 438 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 439 | - | checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17" | |
| 439 | + | checksum = "5dcd93c82209ac7413532388067dce79be5a8780c1786e5fae3df22e4dee2864" | |
| 440 | 440 | dependencies = [ | |
| 441 | 441 | "aws-credential-types", | |
| 442 | 442 | "aws-sigv4", | |
| 443 | 443 | "aws-smithy-async", | |
| 444 | 444 | "aws-smithy-eventstream", | |
| 445 | - | "aws-smithy-http 0.63.6", | |
| 445 | + | "aws-smithy-http", | |
| 446 | 446 | "aws-smithy-runtime", | |
| 447 | 447 | "aws-smithy-runtime-api", | |
| 448 | 448 | "aws-smithy-types", | |
| @@ -462,9 +462,9 @@ dependencies = [ | |||
| 462 | 462 | ||
| 463 | 463 | [[package]] | |
| 464 | 464 | name = "aws-sdk-s3" | |
| 465 | - | version = "1.119.0" | |
| 465 | + | version = "1.131.0" | |
| 466 | 466 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 467 | - | checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c" | |
| 467 | + | checksum = "fe1b8c5282bf859170836045296b3cd710b7573aceb909498366bb508a41058e" | |
| 468 | 468 | dependencies = [ | |
| 469 | 469 | "aws-credential-types", | |
| 470 | 470 | "aws-runtime", | |
| @@ -472,8 +472,9 @@ dependencies = [ | |||
| 472 | 472 | "aws-smithy-async", | |
| 473 | 473 | "aws-smithy-checksums", | |
| 474 | 474 | "aws-smithy-eventstream", | |
| 475 | - | "aws-smithy-http 0.62.6", | |
| 476 | - | "aws-smithy-json 0.61.9", | |
| 475 | + | "aws-smithy-http", | |
| 476 | + | "aws-smithy-json", | |
| 477 | + | "aws-smithy-observability", | |
| 477 | 478 | "aws-smithy-runtime", | |
| 478 | 479 | "aws-smithy-runtime-api", | |
| 479 | 480 | "aws-smithy-types", | |
| @@ -482,14 +483,14 @@ dependencies = [ | |||
| 482 | 483 | "bytes", | |
| 483 | 484 | "fastrand 2.3.0", | |
| 484 | 485 | "hex", | |
| 485 | - | "hmac", | |
| 486 | + | "hmac 0.13.0", | |
| 486 | 487 | "http 0.2.12", | |
| 487 | 488 | "http 1.4.0", | |
| 488 | - | "http-body 0.4.6", | |
| 489 | + | "http-body 1.0.1", | |
| 489 | 490 | "lru", | |
| 490 | 491 | "percent-encoding", | |
| 491 | 492 | "regex-lite", | |
| 492 | - | "sha2", | |
| 493 | + | "sha2 0.11.0", | |
| 493 | 494 | "tracing", | |
| 494 | 495 | "url", | |
| 495 | 496 | ] | |
| @@ -503,8 +504,8 @@ dependencies = [ | |||
| 503 | 504 | "aws-credential-types", | |
| 504 | 505 | "aws-runtime", | |
| 505 | 506 | "aws-smithy-async", | |
| 506 | - | "aws-smithy-http 0.63.6", | |
| 507 | - | "aws-smithy-json 0.62.5", | |
| 507 | + | "aws-smithy-http", | |
| 508 | + | "aws-smithy-json", | |
| 508 | 509 | "aws-smithy-observability", | |
| 509 | 510 | "aws-smithy-runtime", | |
| 510 | 511 | "aws-smithy-runtime-api", | |
| @@ -527,8 +528,8 @@ dependencies = [ | |||
| 527 | 528 | "aws-credential-types", | |
| 528 | 529 | "aws-runtime", | |
| 529 | 530 | "aws-smithy-async", | |
| 530 | - | "aws-smithy-http 0.63.6", | |
| 531 | - | "aws-smithy-json 0.62.5", | |
| 531 | + | "aws-smithy-http", | |
| 532 | + | "aws-smithy-json", | |
| 532 | 533 | "aws-smithy-observability", | |
| 533 | 534 | "aws-smithy-runtime", | |
| 534 | 535 | "aws-smithy-runtime-api", | |
| @@ -551,8 +552,8 @@ dependencies = [ | |||
| 551 | 552 | "aws-credential-types", | |
| 552 | 553 | "aws-runtime", | |
| 553 | 554 | "aws-smithy-async", | |
| 554 | - | "aws-smithy-http 0.63.6", | |
| 555 | - | "aws-smithy-json 0.62.5", | |
| 555 | + | "aws-smithy-http", | |
| 556 | + | "aws-smithy-json", | |
| 556 | 557 | "aws-smithy-observability", | |
| 557 | 558 | "aws-smithy-query", | |
| 558 | 559 | "aws-smithy-runtime", | |
| @@ -569,26 +570,26 @@ dependencies = [ | |||
| 569 | 570 | ||
| 570 | 571 | [[package]] | |
| 571 | 572 | name = "aws-sigv4" | |
| 572 | - | version = "1.4.2" | |
| 573 | + | version = "1.4.3" | |
| 573 | 574 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 574 | - | checksum = "b0b660013a6683ab23797778e21f1f854744fdf05f68204b4cca4c8c04b5d1f4" | |
| 575 | + | checksum = "68dc0b907359b120170613b5c09ccc61304eac3998ff6274b97d93ee6490115a" | |
| 575 | 576 | dependencies = [ | |
| 576 | 577 | "aws-credential-types", | |
| 577 | 578 | "aws-smithy-eventstream", | |
| 578 | - | "aws-smithy-http 0.63.6", | |
| 579 | + | "aws-smithy-http", | |
| 579 | 580 | "aws-smithy-runtime-api", | |
| 580 | 581 | "aws-smithy-types", | |
| 581 | 582 | "bytes", | |
| 582 | 583 | "crypto-bigint 0.5.5", | |
| 583 | 584 | "form_urlencoded", | |
| 584 | 585 | "hex", | |
| 585 | - | "hmac", | |
| 586 | + | "hmac 0.13.0", | |
| 586 | 587 | "http 0.2.12", | |
| 587 | 588 | "http 1.4.0", | |
| 588 | 589 | "p256 0.11.1", | |
| 589 | 590 | "percent-encoding", | |
| 590 | 591 | "ring", | |
| 591 | - | "sha2", | |
| 592 | + | "sha2 0.11.0", | |
| 592 | 593 | "subtle", | |
| 593 | 594 | "time", | |
| 594 | 595 | "tracing", | |
| @@ -608,21 +609,22 @@ dependencies = [ | |||
| 608 | 609 | ||
| 609 | 610 | [[package]] | |
| 610 | 611 | name = "aws-smithy-checksums" | |
| 611 | - | version = "0.63.12" | |
| 612 | + | version = "0.64.7" | |
| 612 | 613 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 613 | - | checksum = "87294a084b43d649d967efe58aa1f9e0adc260e13a6938eb904c0ae9b45824ae" | |
| 614 | + | checksum = "10efbbcec1e044b81600e2fc562a391951d291152d95b482d5b7e7132299d762" | |
| 614 | 615 | dependencies = [ | |
| 615 | - | "aws-smithy-http 0.62.6", | |
| 616 | + | "aws-smithy-http", | |
| 616 | 617 | "aws-smithy-types", | |
| 617 | 618 | "bytes", | |
| 618 | 619 | "crc-fast", | |
| 619 | 620 | "hex", | |
| 620 | - | "http 0.2.12", | |
| 621 | - | "http-body 0.4.6", | |
| 622 | - | "md-5", | |
| 621 | + | "http 1.4.0", | |
| 622 | + | "http-body 1.0.1", | |
| 623 | + | "http-body-util", | |
| 624 | + | "md-5 0.11.0", | |
| 623 | 625 | "pin-project-lite", | |
| 624 | - | "sha1", | |
| 625 | - | "sha2", | |
| 626 | + | "sha1 0.11.0", | |
| 627 | + | "sha2 0.11.0", | |
| 626 | 628 | "tracing", | |
| 627 | 629 | ] | |
| 628 | 630 | ||
| @@ -639,32 +641,11 @@ dependencies = [ | |||
| 639 | 641 | ||
| 640 | 642 | [[package]] | |
| 641 | 643 | name = "aws-smithy-http" | |
| 642 | - | version = "0.62.6" | |
| 643 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 644 | - | checksum = "826141069295752372f8203c17f28e30c464d22899a43a0c9fd9c458d469c88b" | |
| 645 | - | dependencies = [ | |
| 646 | - | "aws-smithy-eventstream", | |
| 647 | - | "aws-smithy-runtime-api", | |
| 648 | - | "aws-smithy-types", | |
| 649 | - | "bytes", | |
| 650 | - | "bytes-utils", | |
| 651 | - | "futures-core", | |
| 652 | - | "futures-util", | |
| 653 | - | "http 0.2.12", | |
| 654 | - | "http 1.4.0", | |
| 655 | - | "http-body 0.4.6", | |
| 656 | - | "percent-encoding", | |
| 657 | - | "pin-project-lite", | |
| 658 | - | "pin-utils", | |
| 659 | - | "tracing", | |
| 660 | - | ] | |
| 661 | - | ||
| 662 | - | [[package]] | |
| 663 | - | name = "aws-smithy-http" | |
| 664 | 644 | version = "0.63.6" | |
| 665 | 645 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 666 | 646 | checksum = "ba1ab2dc1c2c3749ead27180d333c42f11be8b0e934058fb4b2258ee8dbe5231" | |
| 667 | 647 | dependencies = [ | |
| 648 | + | "aws-smithy-eventstream", | |
| 668 | 649 | "aws-smithy-runtime-api", | |
| 669 | 650 | "aws-smithy-types", | |
| 670 | 651 | "bytes", | |
| @@ -712,15 +693,6 @@ dependencies = [ | |||
| 712 | 693 | ||
| 713 | 694 | [[package]] | |
| 714 | 695 | name = "aws-smithy-json" | |
| 715 | - | version = "0.61.9" | |
| 716 | - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 717 | - | checksum = "49fa1213db31ac95288d981476f78d05d9cbb0353d22cdf3472cc05bb02f6551" | |
| 718 | - | dependencies = [ | |
| 719 | - | "aws-smithy-types", | |
| 720 | - | ] | |
| 721 | - | ||
| 722 | - | [[package]] | |
| 723 | - | name = "aws-smithy-json" | |
| 724 | 696 | version = "0.62.5" | |
| 725 | 697 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 726 | 698 | checksum = "9648b0bb82a2eedd844052c6ad2a1a822d1f8e3adee5fbf668366717e428856a" | |
| @@ -749,12 +721,12 @@ dependencies = [ | |||
| 749 | 721 | ||
| 750 | 722 | [[package]] | |
| 751 | 723 | name = "aws-smithy-runtime" | |
| 752 | - | version = "1.10.3" | |
| 724 | + | version = "1.11.1" | |
| 753 | 725 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 754 | - | checksum = "028999056d2d2fd58a697232f9eec4a643cf73a71cf327690a7edad1d2af2110" | |
| 726 | + | checksum = "0504b1ab12debb5959e5165ee5fe97dd387e7aa7ea6a477bfd7635dfe769a4f5" | |
| 755 | 727 | dependencies = [ | |
| 756 | 728 | "aws-smithy-async", | |
| 757 | - | "aws-smithy-http 0.63.6", | |
| 729 | + | "aws-smithy-http", | |
| 758 | 730 | "aws-smithy-http-client", | |
| 759 | 731 | "aws-smithy-observability", | |
| 760 | 732 | "aws-smithy-runtime-api", | |
| @@ -774,11 +746,12 @@ dependencies = [ | |||
| 774 | 746 | ||
| 775 | 747 | [[package]] | |
| 776 | 748 | name = "aws-smithy-runtime-api" | |
| 777 | - | version = "1.11.6" | |
| 749 | + | version = "1.12.0" | |
| 778 | 750 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 779 | - | checksum = "876ab3c9c29791ba4ba02b780a3049e21ec63dabda09268b175272c3733a79e6" | |
| 751 | + | checksum = "b71a13df6ada0aafbf21a73bdfcdf9324cfa9df77d96b8446045be3cde61b42e" | |
| 780 | 752 | dependencies = [ | |
| 781 | 753 | "aws-smithy-async", | |
| 754 | + | "aws-smithy-runtime-api-macros", | |
| 782 | 755 | "aws-smithy-types", | |
| 783 | 756 | "bytes", | |
| 784 | 757 | "http 0.2.12", | |
| @@ -790,6 +763,17 @@ dependencies = [ | |||
| 790 | 763 | ] | |
| 791 | 764 | ||
| 792 | 765 | [[package]] | |
| 766 | + | name = "aws-smithy-runtime-api-macros" | |
| 767 | + | version = "1.0.0" | |
| 768 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 769 | + | checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" | |
| 770 | + | dependencies = [ | |
| 771 | + | "proc-macro2", | |
| 772 | + | "quote", | |
| 773 | + | "syn 2.0.117", | |
| 774 | + | ] | |
| 775 | + | ||
| 776 | + | [[package]] | |
| 793 | 777 | name = "aws-smithy-types" | |
| 794 | 778 | version = "1.4.7" | |
| 795 | 779 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -826,9 +810,9 @@ dependencies = [ | |||
| 826 | 810 | ||
| 827 | 811 | [[package]] | |
| 828 | 812 | name = "aws-types" | |
| 829 | - | version = "1.3.14" | |
| 813 | + | version = "1.3.15" | |
| 830 | 814 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 831 | - | checksum = "47c8323699dd9b3c8d5b3c13051ae9cdef58fd179957c882f8374dd8725962d9" | |
| 815 | + | checksum = "2f4bbcaa9304ea40902d3d5f42a0428d1bd895a2b0f6999436fb279ffddc58ac" | |
| 832 | 816 | dependencies = [ | |
| 833 | 817 | "aws-credential-types", | |
| 834 | 818 | "aws-smithy-async", | |
| @@ -1084,7 +1068,7 @@ version = "0.10.6" | |||
| 1084 | 1068 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1085 | 1069 | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 1086 | 1070 | dependencies = [ | |
| 1087 | - | "digest", | |
| 1071 | + | "digest 0.10.7", | |
| 1088 | 1072 | ] | |
| 1089 | 1073 | ||
| 1090 | 1074 | [[package]] | |
| @@ -1097,6 +1081,15 @@ dependencies = [ | |||
| 1097 | 1081 | ] | |
| 1098 | 1082 | ||
| 1099 | 1083 | [[package]] | |
| 1084 | + | name = "block-buffer" | |
| 1085 | + | version = "0.12.0" | |
| 1086 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1087 | + | checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" | |
| 1088 | + | dependencies = [ | |
| 1089 | + | "hybrid-array", | |
| 1090 | + | ] | |
| 1091 | + | ||
| 1092 | + | [[package]] | |
| 1100 | 1093 | name = "bstr" | |
| 1101 | 1094 | version = "1.12.1" | |
| 1102 | 1095 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1208,7 +1201,7 @@ version = "0.4.4" | |||
| 1208 | 1201 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1209 | 1202 | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" | |
| 1210 | 1203 | dependencies = [ | |
| 1211 | - | "crypto-common", | |
| 1204 | + | "crypto-common 0.1.6", | |
| 1212 | 1205 | "inout", | |
| 1213 | 1206 | ] | |
| 1214 | 1207 | ||
| @@ -1262,6 +1255,12 @@ dependencies = [ | |||
| 1262 | 1255 | ] | |
| 1263 | 1256 | ||
| 1264 | 1257 | [[package]] | |
| 1258 | + | name = "cmov" | |
| 1259 | + | version = "0.5.3" | |
| 1260 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1261 | + | checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" | |
| 1262 | + | ||
| 1263 | + | [[package]] | |
| 1265 | 1264 | name = "cobs" | |
| 1266 | 1265 | version = "0.3.0" | |
| 1267 | 1266 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1292,6 +1291,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1292 | 1291 | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | |
| 1293 | 1292 | ||
| 1294 | 1293 | [[package]] | |
| 1294 | + | name = "const-oid" | |
| 1295 | + | version = "0.10.2" | |
| 1296 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1297 | + | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 1298 | + | ||
| 1299 | + | [[package]] | |
| 1295 | 1300 | name = "constant_time_eq" | |
| 1296 | 1301 | version = "0.3.1" | |
| 1297 | 1302 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1374,6 +1379,15 @@ dependencies = [ | |||
| 1374 | 1379 | ] | |
| 1375 | 1380 | ||
| 1376 | 1381 | [[package]] | |
| 1382 | + | name = "cpufeatures" | |
| 1383 | + | version = "0.3.0" | |
| 1384 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1385 | + | checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" | |
| 1386 | + | dependencies = [ | |
| 1387 | + | "libc", | |
| 1388 | + | ] | |
| 1389 | + | ||
| 1390 | + | [[package]] | |
| 1377 | 1391 | name = "cranelift-assembler-x64" | |
| 1378 | 1392 | version = "0.127.4" | |
| 1379 | 1393 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1513,9 +1527,9 @@ checksum = "97400ad8fbd3a434092fc0b486fa7784150b53187941d818b1087f3ac0a547f0" | |||
| 1513 | 1527 | ||
| 1514 | 1528 | [[package]] | |
| 1515 | 1529 | name = "crc" | |
| 1516 | - | version = "3.4.0" | |
| 1530 | + | version = "3.3.0" | |
| 1517 | 1531 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1518 | - | checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" | |
| 1532 | + | checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" | |
| 1519 | 1533 | dependencies = [ | |
| 1520 | 1534 | "crc-catalog", | |
| 1521 | 1535 | ] | |
| @@ -1528,15 +1542,14 @@ checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" | |||
| 1528 | 1542 | ||
| 1529 | 1543 | [[package]] | |
| 1530 | 1544 | name = "crc-fast" | |
| 1531 | - | version = "1.6.0" | |
| 1545 | + | version = "1.9.0" | |
| 1532 | 1546 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1533 | - | checksum = "6ddc2d09feefeee8bd78101665bd8645637828fa9317f9f292496dbbd8c65ff3" | |
| 1547 | + | checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" | |
| 1534 | 1548 | dependencies = [ | |
| 1535 | 1549 | "crc", | |
| 1536 | - | "digest", | |
| 1537 | - | "rand 0.9.2", | |
| 1538 | - | "regex", | |
| 1550 | + | "digest 0.10.7", | |
| 1539 | 1551 | "rustversion", | |
| 1552 | + | "spin 0.10.0", | |
| 1540 | 1553 | ] | |
| 1541 | 1554 | ||
| 1542 | 1555 | [[package]] | |
| @@ -1623,6 +1636,15 @@ dependencies = [ | |||
| 1623 | 1636 | ] | |
| 1624 | 1637 | ||
| 1625 | 1638 | [[package]] | |
| 1639 | + | name = "crypto-common" | |
| 1640 | + | version = "0.2.1" | |
| 1641 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1642 | + | checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" | |
| 1643 | + | dependencies = [ | |
| 1644 | + | "hybrid-array", | |
| 1645 | + | ] | |
| 1646 | + | ||
| 1647 | + | [[package]] | |
| 1626 | 1648 | name = "cssparser" | |
| 1627 | 1649 | version = "0.35.0" | |
| 1628 | 1650 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1667,6 +1689,15 @@ dependencies = [ | |||
| 1667 | 1689 | ] | |
| 1668 | 1690 | ||
| 1669 | 1691 | [[package]] | |
| 1692 | + | name = "ctutils" | |
| 1693 | + | version = "0.4.2" | |
| 1694 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1695 | + | checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" | |
| 1696 | + | dependencies = [ | |
| 1697 | + | "cmov", | |
| 1698 | + | ] | |
| 1699 | + | ||
| 1700 | + | [[package]] | |
| 1670 | 1701 | name = "darling" | |
| 1671 | 1702 | version = "0.20.11" | |
| 1672 | 1703 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1733,7 +1764,7 @@ version = "0.6.1" | |||
| 1733 | 1764 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1734 | 1765 | checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" | |
| 1735 | 1766 | dependencies = [ | |
| 1736 | - | "const-oid", | |
| 1767 | + | "const-oid 0.9.6", | |
| 1737 | 1768 | "zeroize", | |
| 1738 | 1769 | ] | |
| 1739 | 1770 | ||
| @@ -1743,7 +1774,7 @@ version = "0.7.10" | |||
| 1743 | 1774 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1744 | 1775 | checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" | |
| 1745 | 1776 | dependencies = [ | |
| 1746 | - | "const-oid", | |
| 1777 | + | "const-oid 0.9.6", | |
| 1747 | 1778 | "pem-rfc7468", | |
| 1748 | 1779 | "zeroize", | |
| 1749 | 1780 | ] | |
| @@ -1792,13 +1823,25 @@ version = "0.10.7" | |||
| 1792 | 1823 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1793 | 1824 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" | |
| 1794 | 1825 | dependencies = [ | |
| 1795 | - | "block-buffer", | |
| 1796 | - | "const-oid", | |
| 1797 | - | "crypto-common", | |
| 1826 | + | "block-buffer 0.10.4", | |
| 1827 | + | "const-oid 0.9.6", | |
| 1828 | + | "crypto-common 0.1.6", | |
| 1798 | 1829 | "subtle", | |
| 1799 | 1830 | ] | |
| 1800 | 1831 | ||
| 1801 | 1832 | [[package]] | |
| 1833 | + | name = "digest" | |
| 1834 | + | version = "0.11.3" | |
| 1835 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1836 | + | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" | |
| 1837 | + | dependencies = [ | |
| 1838 | + | "block-buffer 0.12.0", | |
| 1839 | + | "const-oid 0.10.2", | |
| 1840 | + | "crypto-common 0.2.1", | |
| 1841 | + | "ctutils", | |
| 1842 | + | ] | |
| 1843 | + | ||
| 1844 | + | [[package]] | |
| 1802 | 1845 | name = "displaydoc" | |
| 1803 | 1846 | version = "0.2.5" | |
| 1804 | 1847 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1843,12 +1886,12 @@ version = "0.6.3" | |||
| 1843 | 1886 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1844 | 1887 | checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689" | |
| 1845 | 1888 | dependencies = [ | |
| 1846 | - | "digest", | |
| 1889 | + | "digest 0.10.7", | |
| 1847 | 1890 | "num-bigint-dig", | |
| 1848 | 1891 | "num-traits", | |
| 1849 | 1892 | "pkcs8 0.10.2", | |
| 1850 | 1893 | "rfc6979 0.4.0", | |
| 1851 | - | "sha2", | |
| 1894 | + | "sha2 0.10.9", | |
| 1852 | 1895 | "signature 2.2.0", |
Lines truncated
| @@ -75,7 +75,7 @@ base64 = "0.22.1" | |||
| 75 | 75 | infer = "0.19" | |
| 76 | 76 | goblin = "0.10" | |
| 77 | 77 | zip = "8.2" | |
| 78 | - | yara-x = "1.14" | |
| 78 | + | yara-x = "1.15" | |
| 79 | 79 | ||
| 80 | 80 | # CSV parsing (import system) | |
| 81 | 81 | csv = "1.3" |
| @@ -1,187 +1,164 @@ | |||
| 1 | 1 | # MakeNotWork -- Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-05-02 (Run 19, MNW server + doc fuzz) | |
| 4 | - | **Previous audit:** 2026-05-01 (Run 18, MNW server only) | |
| 3 | + | **Last audited:** 2026-05-04 (Run 20, MNW server full audit) | |
| 4 | + | **Previous audit:** 2026-05-02 (Run 19, MNW server + doc fuzz) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 19: 1,930 tests passing (1,220 unit + 679 integration + 28 doc + 3 load; 10 ignored), 0 failed. 0 cargo warnings. v0.4.8. ~81,384 LOC. 2 cold spots (0 bugs, 2 minor). Combined with doc fuzz for creator email readiness assessment. 10 test failures from Run 19 resolved 2026-05-04 (CSRF double-slash, SyncKit rate limiter key extraction, guest-free checkout CSRF exemption). | |
| 8 | + | Run 20: 678 integration tests passing (2 timing-sensitive sandbox rate-limit tests failing, non-critical), 0 cargo warnings. v0.4.10. ~83,232 LOC. 2 cold spots (0 bugs, 2 minor). Clean git status. `cargo check` passes cleanly. | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| 12 | 12 | | Dimension | Grade | Notes | | |
| 13 | 13 | |-----------|:-----:|-------| | |
| 14 | - | | Code Quality | A | unwrap() in git/raw.rs fixed. helpers.rs split into formatting/crypto/rate_limit (395 lines from 1,268) | | |
| 15 | - | | Architecture | A | Inline SQL in route handlers still present (4 locations) but minor | | |
| 16 | - | | Testing | A | 1,930 tests, 0 failures. proptest active. 10 test failures fixed 2026-05-04 (CSRF, SyncKit rate limiter, guest-free exempt) | | |
| 17 | - | | Security | A+ | Zero SQL injection vectors, constant-time compare everywhere, fail-closed scanning, CSRF on all forms | | |
| 18 | - | | Performance | A | analytics.rs deduplicated (623->468 LOC). hash_lookup uses static reqwest::Client | | |
| 19 | - | | Documentation | A- | Module-level //! on every file. No README.md (CONTRIBUTING.md partially fills role) | | |
| 20 | - | | Dependencies | A- | Most deps current. tokio (1.49->1.50), uuid (1.20->1.22), chrono (0.4.43->0.4.44) pins stale | | |
| 21 | - | | Frontend | A | Askama auto-escape, all `\|safe` uses verified safe, no raw innerHTML. No CSP header (inline onclick pattern) | | |
| 22 | - | | Type Safety | A | 50+ UUID newtypes, validated string types, Cents monetary newtype. moderation.rs still uses raw Uuid | | |
| 23 | - | | Observability | A | Comprehensive #[instrument] coverage. Structured logging throughout | | |
| 24 | - | | Concurrency | A | ON CONFLICT, FOR UPDATE, atomic WHERE guards, advisory locks, optimistic versioning | | |
| 25 | - | | Resilience | A | Graceful shutdown with hard deadline. One missing timeout: payments/connect.rs:198 raw reqwest call | | |
| 26 | - | | API Consistency | A | Documented response conventions, json_error_layer, versioned SyncKit routes | | |
| 27 | - | | Migration Safety | A | 87 additive migrations, IF EXISTS on drops, CHECK constraints | | |
| 28 | - | | Codebase Size | A | 81K LOC well-organized. helpers.rs split complete. analytics.rs deduplicated | | |
| 14 | + | | Code Quality | A | Zero .unwrap() in production paths (3 LazyLock regex are acceptable) | | |
| 15 | + | | Architecture | A | Clean layer separation. Inline SQL only in health.rs (stats + probe, acceptable) | | |
| 16 | + | | Testing | A | ~1,214 test annotations, 94 integration test files, proptest active, comprehensive harness | | |
| 17 | + | | Security | A+ | Constant-time compare, fail-closed scanning, CSRF everywhere, Argon2id, HMAC webhooks, path traversal prevention | | |
| 18 | + | | Performance | A | Batch queries, pagination, CDN fallback, session touch cache, presigned uploads | | |
| 19 | + | | Documentation | A- | Module-level //! on all major files. No README.md (CONTRIBUTING.md fills role) | | |
| 20 | + | | Dependencies | A- | 3 transitive advisories (none exploitable). All security-sensitive deps on latest stable | | |
| 21 | + | | Frontend | A | Askama auto-escape, HTMX patterns consistent, CSP headers | | |
| 22 | + | | Type Safety | A+ | 36 UUID newtypes, 25+ domain enums, validated string types, Cents/PriceCents monetary newtypes | | |
| 23 | + | | Observability | A- | Comprehensive #[instrument] on routes + DB layer. Gaps: embed/ handlers (0 instruments), payments/ module (0 instruments) | | |
| 24 | + | | Concurrency | A | ON CONFLICT, FOR UPDATE, advisory locks, DashMap caches, atomic state changes | | |
| 25 | + | | Resilience | A | Graceful shutdown with 10s deadline, timeouts on all outbound calls, fail-closed scanning | | |
| 26 | + | | API Consistency | A | ListResponse wrapper, json_error_layer, versioned SyncKit routes, documented conventions | | |
| 27 | + | | Migration Safety | A | 93 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout, defaults on all NOT NULL | | |
| 28 | + | | Codebase Size | A- | 83K LOC well-organized. 5 files over 500-line guideline (max 844). No egregious violations | | |
| 29 | 29 | ||
| 30 | 30 | ## Module Heatmap | |
| 31 | 31 | ||
| 32 | - | | Module | Code | Arch | Test | Security | Perf | Docs | TypeSafe | Observ | | |
| 33 | - | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:--------:|:------:| | |
| 34 | - | | main.rs | A | A | n/a | A | n/a | A | n/a | A | | |
| 35 | - | | lib.rs | A | A | n/a | A | A- | A | A | n/a | | |
| 36 | - | | config.rs | A | A | A | A+ | n/a | A | n/a | n/a | | |
| 37 | - | | error.rs | A+ | A | A+ | A+ | n/a | A | A | A | | |
| 38 | - | | auth.rs | A | A | A- | A+ | n/a | A | A | A | | |
| 39 | - | | csrf.rs | A | A- | A | A | n/a | A | n/a | n/a | | |
| 40 | - | | helpers.rs | A- | n/a | A+ | A | n/a | A | A | n/a | | |
| 41 | - | | constants.rs | A | n/a | A | n/a | n/a | A | n/a | n/a | | |
| 42 | - | | storage.rs | A | A | A | A | n/a | A | A | n/a | | |
| 43 | - | | monitor.rs | A- | A | A | n/a | n/a | A | n/a | A | | |
| 44 | - | | wam_client.rs | A | n/a | **B** | n/a | n/a | A | n/a | n/a | | |
| 45 | - | | synckit_auth.rs | A | n/a | A+ | A+ | n/a | A | A | n/a | | |
| 46 | - | | pricing.rs | A | A+ | A+ | n/a | n/a | A | A | n/a | | |
| 47 | - | | wordlist.rs | A | n/a | n/a | n/a | n/a | A | n/a | n/a | | |
| 48 | - | | license_templates.rs | A | n/a | A | A- | n/a | A | A | n/a | | |
| 49 | - | | build_runner.rs | A | n/a | A- | A | n/a | A | n/a | A | | |
| 50 | - | | git_ssh.rs | A | A | A- | A | n/a | A | n/a | n/a | | |
| 51 | - | | rss.rs | A | n/a | A | A | A | A | n/a | n/a | | |
| 52 | - | | db/mod.rs | A | A | n/a | A | n/a | A | n/a | n/a | | |
| 53 | - | | db/id_types.rs | A | n/a | A | n/a | n/a | A | A+ | n/a | | |
| 54 | - | | db/enums.rs | A | n/a | A+ | n/a | n/a | A | A | n/a | | |
| 55 | - | | db/validated_types.rs | A | n/a | A+ | n/a | A | A | A | n/a | | |
| 56 | - | | db/users.rs | A | A | C | A | A | A | A- | n/a | | |
| 57 | - | | db/items.rs | A | A | C | A | A | A | **B+** | n/a | | |
| 58 | - | | db/synckit.rs | A | A | C | A | A- | A | A- | n/a | | |
| 59 | - | | db/creator_tiers.rs | A | A | A | A | A | A | A | n/a | | |
| 60 | - | | db/transactions.rs | A | A | C | A | A | A | A- | n/a | | |
| 61 | - | | db/analytics.rs | **B+** | **B+** | A | A | A | A | A- | n/a | | |
| 62 | - | | db/discover.rs | A- | **B+** | C | A | A | A | A | n/a | | |
| 63 | - | | db/subscriptions.rs | A | A | C | A | A | A | A | n/a | | |
| 64 | - | | db/promo_codes.rs | A | A | A+ | A | A | A | A | n/a | | |
| 65 | - | | db/models/* | A | A | A- | n/a | n/a | A | A | n/a | | |
| 66 | - | | db/moderation.rs | A | A | C | A | n/a | A- | **B** | n/a | | |
| 67 | - | | scanning/ | A | A | A+ | A+ | A | A | A | n/a | | |
| 68 | - | | payments/checkout.rs | A- | A | A | A | A- | A | A | n/a | | |
| 69 | - | | payments/webhooks.rs | A | A | A+ | A+ | n/a | A | n/a | n/a | | |
| 70 | - | | payments/connect.rs | A- | A | C | A | n/a | A | n/a | n/a | | |
| 71 | - | | email/tokens.rs | A | A | A+ | A+ | n/a | A | n/a | n/a | | |
| 72 | - | | email/notifications.rs | A | A | C | n/a | n/a | A | n/a | n/a | | |
| 73 | - | | validation/ | A | A | A+ | A+ | n/a | A | A | n/a | | |
| 74 | - | | types/mod.rs | A | A | A+ | A+ | n/a | A | A- | n/a | | |
| 75 | - | | types/conversions.rs | A | A | A | n/a | n/a | A | A- | n/a | | |
| 76 | - | | templates/ | A | A | n/a | A | n/a | A | n/a | n/a | | |
| 77 | - | | scheduler/mod.rs | A | A | A+ | n/a | n/a | A | n/a | n/a | | |
| 78 | - | | scheduler/other | A | A | C | n/a | n/a | A | n/a | A | | |
| 79 | - | | import/csv_converter.rs | A | A | A+ | A | A- | A | n/a | n/a | | |
| 80 | - | | git/mod.rs | A | A | A | A | A | A | A | n/a | | |
| 81 | - | | routes/auth.rs | A | A | C | A+ | A | A | A | A | | |
| 82 | - | | routes/admin/ | A | A | C | A | **B** | A | A | A | | |
| 83 | - | | routes/storage/ | A | A | C | A | A | A | A | A | | |
| 84 | - | | routes/stripe/ | A | **B** | C | A | A | A | A | A | | |
| 85 | - | | routes/synckit/ | A | A | C | A | A | A | A | A | | |
| 86 | - | | routes/postmark/ | A | A | A | A | A | A | n/a | A | | |
| 87 | - | | routes/git/ | **B** | A | C | A | **B** | A | A | A | | |
| 88 | - | | routes/pages/ | A | **B** | C | A | A | A | A | A | | |
| 89 | - | | routes/api/ | A | A | C | A | A | A | A | **B** | | |
| 90 | - | | routes/builds.rs | A | A | C | A | A | A | A | A | | |
| 91 | - | | routes/ota.rs | A | A | C | A | **B** | A | A | A | | |
| 92 | - | | tests/ | A | A | n/a | A | n/a | A | n/a | n/a | | |
| 32 | + | | Module | Code | Arch | Test | Security | Perf | Docs | TypeSafe | Observ | Size | | |
| 33 | + | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:--------:|:------:|:----:| | |
| 34 | + | | lib.rs + main.rs | A | A | B+ | A | A | A | A | A | A | | |
| 35 | + | | config.rs | A | A | A | A | n/a | A | A | B+ | A | | |
| 36 | + | | error.rs | A | A | A | A | n/a | A | A | A | A | | |
| 37 | + | | auth.rs | A | A | A- | A+ | A | A | A | A | A | | |
| 38 | + | | csrf.rs | A | A | A | A | A- | A | A | A- | A | | |
| 39 | + | | helpers.rs | A | A | A | A | A | A | A- | B+ | A | | |
| 40 | + | | constants.rs | A | n/a | A | A | n/a | A- | A | n/a | A | | |
| 41 | + | | rate_limit.rs | A | A | A | A | A | A | A | B+ | A | | |
| 42 | + | | storage.rs | A | A | A | A | A | A | A | B+ | A | | |
| 43 | + | | pricing.rs | A | A+ | A+ | A | A | A | A | n/a | A | | |
| 44 | + | | crypto.rs | A | A | A | A | A | A | A | n/a | A | | |
| 45 | + | | formatting.rs | A | A | A+ | A | A | A | A | n/a | A | | |
| 46 | + | | rss.rs | A | A | A | A | A | A | A- | n/a | A | | |
| 47 | + | | synckit_auth.rs | A | A | A | A | A | A | A | B+ | A | | |
| 48 | + | | db/enums.rs | A | A | A | A | n/a | A | A+ | n/a | A | | |
| 49 | + | | db/id_types.rs | A | A | A | n/a | n/a | A | A+ | n/a | A | | |
| 50 | + | | db/validated_types.rs | A | A | A+ | A | A | A | A+ | n/a | A | | |
| 51 | + | | db/users.rs | A | A | B | A | A- | A | A | A | A | | |
| 52 | + | | db/items.rs | A | A | B | A | A- | A | A | A | A | | |
| 53 | + | | db/synckit.rs | A | A | B | A | A | A | A | A | A | | |
| 54 | + | | db/transactions.rs | A | A | B | A | A | A | A | A | A | | |
| 55 | + | | db/discover.rs | A | A | B | A | A | A | A | n/a | A | | |
| 56 | + | | db/creator_tiers.rs | A | A | A | A | A | A | A | n/a | A | | |
| 57 | + | | db/models/* | A | A | B+ | A | n/a | A- | A | n/a | A | | |
| 58 | + | | types/ | A | A | B | A | n/a | A | A | n/a | A | | |
| 59 | + | | scanning/ | A | A+ | A+ | A+ | A- | A | A | B | A | | |
| 60 | + | | payments/ | A | A | A- | A | A- | A | A | **B+** | B+ | | |
| 61 | + | | email/ | A | A | A | A | A- | A | A | B+ | A- | | |
| 62 | + | | scheduler/ | A | A | B+ | A | A | A | A | A- | A | | |
| 63 | + | | validation/ | A | A | A+ | A+ | A | B+ | A | n/a | A | | |
| 64 | + | | import/ | A | A | A | A | B+ | A- | A | B+ | A | | |
| 65 | + | | git/ | A | A | A | A+ | B+ | A- | A | B | A | | |
| 66 | + | | git_ssh.rs | A | A | A | A | A | A- | A | B+ | A | | |
| 67 | + | | build_runner.rs | A | A- | B+ | A+ | A | A- | A | A | A | | |
| 68 | + | | monitor.rs | A | A | A- | A | A | A | A | A | A | | |
| 69 | + | | templates/ | A | A | n/a | A | A | A- | A | B | B+ | | |
| 70 | + | | routes/auth.rs | A | A | n/a | A+ | A | A | A | A | A | | |
| 71 | + | | routes/oauth.rs | A | A | n/a | A+ | A | A | A | A | A- | | |
| 72 | + | | routes/admin/ | A | A | n/a | A | A | A | A | A | A | | |
| 73 | + | | routes/api/ | A | A | n/a | A | A | A | A | A | **B+** | | |
| 74 | + | | routes/stripe/ | A | A | n/a | A | A | A | A | **B+** | **B+** | | |
| 75 | + | | routes/synckit/ | A | A | n/a | A | A | A | A | A | A | | |
| 76 | + | | routes/pages/ | A | A | n/a | A | A | A | A | A- | **B+** | | |
| 77 | + | | routes/embed/ | A | A | n/a | A | A | A- | A | **B** | A | | |
| 78 | + | | routes/git/ | A | A | n/a | A | A | A | A | A | A | | |
| 79 | + | | routes/storage/ | A | A | n/a | A | A | A | A | A | A | | |
| 80 | + | | routes/postmark/ | A | A | n/a | A | A | A | n/a | A- | B+ | | |
| 93 | 81 | ||
| 94 | 82 | **Bold** = cold spot (B or below). | |
| 95 | 83 | ||
| 96 | 84 | ### Cold Spots | |
| 97 | 85 | ||
| 98 | - | 1. **db/moderation.rs type safety (B):** Uses raw `Uuid` for action IDs and `String` for action_type instead of typed newtypes. Only file in db/ without typed IDs. (Carried from Run 18) | |
| 99 | - | 2. **payments/connect.rs resilience (B):** Missing timeout on raw reqwest call to Stripe resume-subscription API at line 198. Only unprotected outbound HTTP call in the codebase. | |
| 86 | + | 1. **routes/embed/ observability (B):** Zero `#[tracing::instrument]` on any embed handler (item.rs, project.rs, user.rs). Embeds serve third-party traffic — blind spot for latency monitoring. | |
| 87 | + | 2. **routes/stripe/webhook/checkout.rs size (B+) + observability (B+):** 792 LOC (above 500-line guideline), internal `handle_*` functions lack `#[instrument]`. | |
| 100 | 88 | ||
| 101 | - | ### Resolved Cold Spots (from Run 18) | |
| 102 | - | - ~~routes/git/raw.rs unwrap()~~ -- Fixed | |
| 103 | - | - ~~db/analytics.rs duplication~~ -- Fixed (Scope enum extraction) | |
| 104 | - | - ~~wam_client.rs testing~~ -- Fixed (5 tests added) | |
| 89 | + | ### Resolved Cold Spots (from Run 19) | |
| 90 | + | ||
| 91 | + | - ~~db/moderation.rs type safety (B)~~ -- Fixed (typed IDs added) | |
| 92 | + | - ~~payments/connect.rs resilience (B)~~ -- Fixed (raw reqwest call removed/restructured) | |
| 105 | 93 | ||
| 106 | 94 | ## Mandatory Surprise | |
| 107 | 95 | ||
| 108 | - | **Hand-rolled Stripe v2 webhook signature verification.** `payments/webhooks.rs:184-228` implements v2 webhook signature verification from scratch because the `stripe` crate only supports v1. The implementation correctly parses `t=,v1=` headers, rejects timestamps >300s in either direction (replay + clock skew), and uses `hmac::Hmac::verify_slice()` which is constant-time via the `subtle` crate. Both past and future timestamp bounds are checked. Well-tested with 26 unit tests including edge cases (stale, future, wrong secret, within tolerance). | |
| 96 | + | **Unexpectedly good:** The `scanning` module (2,110 LOC) implements production-grade 6-layer anti-malware infrastructure. The archive layer doesn't trust ZIP central directory size claims -- it actually decompresses entries counting bytes with an abort threshold (archive.rs:83-107). The structural analysis layer detects VMProtect-packed and UPX-packed binaries by exact section name matching to avoid false positives. ZIP bomb detection uses both compression ratio AND actual decompression byte counting. This is far above what most content platforms implement and would be impressive in a dedicated security product. | |
| 109 | 97 | ||
| 110 | 98 | ### Previous Surprises | |
| 111 | 99 | ||
| 100 | + | **Run 19:** Hand-rolled Stripe v2 webhook signature verification with replay protection. | |
| 101 | + | ||
| 112 | 102 | **Run 18:** Sandbox tier mismatch bug (SmallFiles vs small_files). Fixed. | |
| 113 | 103 | ||
| 114 | 104 | **Run 17:** TOCTOU-safe slug generation with retry loop + advisory lock pattern for sandbox IP cap. | |
| 115 | 105 | ||
| 116 | - | **Run 15:** Session touch cache — DashMap with 30s TTL avoids N+1 session queries. | |
| 117 | - | ||
| 118 | - | **Run 13:** Mailing list delivery migration has zero duplication with the follows-based delivery it replaces. | |
| 106 | + | **Run 15:** Session touch cache -- DashMap with 30s TTL avoids N+1 session queries. | |
| 119 | 107 | ||
| 120 | 108 | ## Strengths | |
| 121 | 109 | ||
| 122 | 110 | ### 1. Security-in-depth | |
| 123 | - | Zero SQL injection vectors across 200+ queries. Argon2id with explicit params, CSRF synchronizer tokens with constant-time comparison, session fixation prevention, account lockout, rate limiting, HMAC-signed URLs, 6-layer malware scanning pipeline with fail-closed design. JWT tokens validated against live DB state (not just expiry). Comprehensive CSP headers. | |
| 111 | + | Zero SQL injection vectors across 200+ queries. Argon2id with explicit params (46MiB/2 iterations), CSRF synchronizer tokens with constant-time comparison, session fixation prevention, account lockout, rate limiting, HMAC-signed URLs, 6-layer malware scanning pipeline with fail-closed design. ZIP bomb detection, path traversal prevention in archives, shell command validation in build runner. | |
| 124 | 112 | ||
| 125 | - | ### 2. Test quality and coverage | |
| 126 | - | 1,933 tests with per-test database isolation (CREATE DATABASE TEMPLATE clone). 53 adversarial exploit-attempt tests. Property-based testing with proptest. Behavior-focused integration tests via in-process tower::ServiceExt::oneshot. Zero TODO/FIXME/HACK in the codebase. | |
| 113 | + | ### 2. Type safety discipline | |
| 114 | + | 36 UUID newtypes via `define_pg_uuid_id!`, 25+ domain enums via `impl_str_enum!`, validated string types (Username, Slug, KeyCode), Cents/PriceCents monetary newtypes with proptest coverage. Compile-time template verification via Askama. Proof-carrying types: once constructed, guaranteed valid. | |
| 127 | 115 | ||
| 128 | - | ### 3. Type safety discipline | |
| 129 | - | 50+ UUID newtypes via `define_pg_uuid_id!` macro, 23 domain enums via `impl_str_enum!`, validated string types (Username, Slug, KeyCode), Cents monetary newtype with SUM(BIGINT)->NUMERIC decode handling. Compile-time template verification via Askama. | |
| 116 | + | ### 3. Test quality | |
| 117 | + | 1,214+ test annotations with per-test database isolation. Property-based testing with proptest (pricing, formatting, validated types). Adversarial tests cover SQL injection, XSS, path traversal, formula injection, ZIP bombs. Integration harness mocks all external dependencies (Stripe, S3, Postmark, ClamAV). | |
| 130 | 118 | ||
| 131 | 119 | ## Weaknesses | |
| 132 | 120 | ||
| 133 | - | ### 1. Inline SQL in route handlers | |
| 134 | - | 4 locations where route handlers contain raw `sqlx::query` calls instead of delegating to `db/`: | |
| 135 | - | - `routes/stripe/checkout/project.rs:90` — INSERT for free project claim | |
| 136 | - | - `routes/pages/dashboard/forms.rs:58,71` — SUM queries for storage display | |
| 137 | - | - `routes/pages/public/landing.rs:46` — COUNT for landing page stats | |
| 121 | + | ### 1. Observability gaps in embed/ and payments/ | |
| 122 | + | The embed module (serving third-party iframe traffic) and payments module (handling money) have zero `#[instrument]` annotations. These are high-value modules where tracing would provide the most benefit. | |
| 138 | 123 | ||
| 139 | - | ### 2. Sandbox tier bug (confirmed) | |
| 140 | - | `'SmallFiles'` vs `'small_files'` mismatch silently breaks sandbox user tier detection. | |
| 124 | + | ### 2. Five files above 500-line size guideline | |
| 125 | + | health.rs (844), webhook/checkout.rs (792), exports.rs (737), license_keys.rs (741), tabs/user.rs (707). None are egregious but represent opportunities for extraction. | |
| 141 | 126 | ||
| 142 | - | ### 3. CSV import amount heuristic | |
| 143 | - | `import/csv_converter.rs` `parse_amount_cents` uses a 10,000 threshold heuristic to guess whether amounts are in cents or dollars. Values in the 100-10,000 range (e.g., $99 as `9900` cents) are silently misinterpreted as dollar amounts ($9,900). No explicit cents/dollars column indicator. | |
| 127 | + | ### 3. async-trait still in use | |
| 128 | + | 3 trait definitions still use `async-trait` crate instead of Rust 2024 native async traits (StorageBackend, EmailTransport, PaymentProvider). Chronic -- carried from Run 18. | |
| 144 | 129 | ||
| 145 | 130 | ## Action Items | |
| 146 | 131 | ||
| 147 | - | ### Run 19 (2026-05-02) | |
| 132 | + | ### Run 20 (2026-05-04) | |
| 148 | 133 | ||
| 149 | - | 51. **[MEDIUM]** Add timeout to `payments/connect.rs:198` raw reqwest call to Stripe resume-subscription API | |
| 150 | - | 52. **[MEDIUM]** Add `ModerationActionId` newtype and `ModerationActionType` enum to `db/moderation.rs` (carried from Run 18 #41) | |
| 151 | - | 53. **[LOW]** Add README.md to server/ with setup instructions and architecture overview link | |
| 152 | - | 54. **[LOW]** Bump dependency pins: tokio 1.49->1.50, uuid 1.20->1.22, chrono 0.4.43->0.4.44, yara-x 1.13->1.14, anyhow 1.0.101->1.0.102 | |
| 153 | - | 55. **[DEFERRED]** Extract inline SQL from route handlers to db/ layer (4 locations, carried from Run 18 #40) | |
| 154 | - | 56. **[DEFERRED]** Remove `async-trait` in favor of Rust 2024 native async traits (carried from Run 18 #50) | |
| 155 | - | 57. **[DEFERRED]** Migrate inline `onclick` handlers to `addEventListener` to enable strict CSP | |
| 134 | + | 58. **[MEDIUM]** Add `#[tracing::instrument(skip_all)]` to all handlers in routes/embed/ (item.rs, project.rs, user.rs) | |
| 135 | + | 59. **[MEDIUM]** Add `#[tracing::instrument(skip_all)]` to functions in payments/ (checkout.rs, connect.rs, webhooks.rs) | |
| 136 | + | 60. **[LOW]** Split routes/stripe/webhook/checkout.rs (792 LOC) -- extract handle_* functions to submodule | |
| 137 | + | 61. **[LOW]** Bump transitive deps: yara-x (for intaglio fix), AWS SDK chain (for rustls-webpki fix) | |
| 138 | + | 62. **[DEFERRED]** Remove `async-trait` in favor of Rust 2024 native async traits (chronic, carried from Run 18 #56) | |
| 139 | + | 63. **[DEFERRED]** Add README.md to server/ (carried from Run 19 #53) | |
| 140 | + | 64. **[DEFERRED]** Split oversized route files: exports.rs, license_keys.rs, health.rs, tabs/user.rs | |
| 156 | 141 | ||
| 157 | 142 | ### Open (blocked on upstream) | |
| 143 | + | ||
| 158 | 144 | 23. Monitor aws-sdk-s3 for lru fix (RUSTSEC-2026-0002) | |
| 159 | 145 | 24. Monitor async-stripe for instant fix (RUSTSEC-2024-0384) | |
| 160 | 146 | 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049) | |
| 161 | - | 33. bincode unmaintained (RUSTSEC-2025-0141) — upstream via syntect/yara-x, warning only | |
| 162 | - | ||
| 163 | - | ### Previously resolved | |
| 164 | - | All items 1-22, 31-38, and most Run 18 items verified intact. | |
| 147 | + | 33. bincode unmaintained (RUSTSEC-2025-0141) -- upstream via syntect/yara-x, warning only | |
| 165 | 148 | ||
| 166 | - | ## Previous Action Item Verification (Run 18) | |
| 149 | + | ## Previous Action Item Verification (Run 19) | |
| 167 | 150 | ||
| 168 | 151 | | # | Item | Status | | |
| 169 | 152 | |---|------|--------| | |
| 170 | - | | 39 | Sandbox tier bug (`'SmallFiles'`->`'small_files'`) | Fixed | | |
| 171 | - | | 40 | Extract inline SQL from route handlers | Unfixed (carried as #55) | | |
| 172 | - | | 41 | Moderation typed IDs | Unfixed (carried as #52) | | |
| 173 | - | | 42 | `.unwrap()` in git/raw.rs | Fixed | | |
| 174 | - | | 43 | Missing `#[instrument]` on public_projects | Fixed | | |
| 175 | - | | 44 | `.unwrap()` in helpers.rs | Fixed (helpers.rs split) | | |
| 176 | - | | 45 | `.unwrap()` in monitor.rs | Fixed | | |
| 177 | - | | 46 | wam_client.rs unit tests | Fixed (5 tests added) | | |
| 178 | - | | 47 | CSV import cents/dollars format | Fixed (heuristic removed) | | |
| 179 | - | | 48 | Split helpers.rs | Fixed (1,268->395 lines: formatting.rs, crypto.rs, rate_limit.rs) | | |
| 180 | - | | 49 | Analytics.rs deduplication | Fixed (623->468 lines via Scope enum) | | |
| 181 | - | | 50 | Remove async-trait | Unfixed (carried as #56) | | |
| 182 | - | | 23-25, 33 | Upstream dep advisories | Unfixed (chronic, upstream-blocked) | | |
| 183 | - | ||
| 184 | - | 10 of 12 Run 18 items fixed. 2 carried forward. No regressions. | |
| 153 | + | | 51 | Add timeout to payments/connect.rs raw reqwest call | Fixed (call restructured) | | |
| 154 | + | | 52 | Add ModerationActionId newtype to db/moderation.rs | Fixed | | |
| 155 | + | | 53 | Add README.md to server/ | Unfixed (carried as #63) | | |
| 156 | + | | 54 | Bump dependency pins (tokio, uuid, chrono, yara-x, anyhow) | Fixed (all at latest) | | |
| 157 | + | | 55 | Extract inline SQL from route handlers (4 locations) | Fixed (only health.rs COUNT stats remain -- acceptable) | | |
| 158 | + | | 56 | Remove async-trait | Unfixed (chronic, carried as #62) | | |
| 159 | + | | 57 | Migrate onclick to addEventListener for strict CSP | Fixed (via dashboard usability rework) | | |
| 160 | + | ||
| 161 | + | 5 of 7 Run 19 items fixed. 2 carried forward (1 chronic). No regressions. | |
| 185 | 162 | ||
| 186 | 163 | ## Metrics Over Time | |
| 187 | 164 | ||
| @@ -205,6 +182,7 @@ All items 1-22, 31-38, and most Run 18 items verified intact. | |||
| 205 | 182 | | 2026-04-30 (Run 17) | ~79,334 | -- | 1,861 | ~15.0 | 0 | 0 | A | | |
| 206 | 183 | | 2026-05-01 (Run 18) | ~80,470 | -- | 1,933 (34 int. fail) | ~15.1 | 0 | 5 | A | | |
| 207 | 184 | | 2026-05-02 (Run 19) | ~81,384 | -- | 1,923 (0 fail) | ~23.6 | 0 | 2 | A | | |
| 185 | + | | 2026-05-04 (Run 20) | ~83,232 | 238 | 1,214+ annotations | ~14.6 | 0 | 2 | A | | |
| 208 | 186 | ||
| 209 | 187 | --- | |
| 210 | 188 |
| @@ -512,7 +512,7 @@ mod tests { | |||
| 512 | 512 | host: "127.0.0.1".parse().unwrap(), | |
| 513 | 513 | port: 3000, | |
| 514 | 514 | database_url: "postgres://test".to_string(), | |
| 515 | - | host_url: "http://localhost:3000".to_string(), | |
| 515 | + | host_url: std::sync::Arc::from("http://localhost:3000"), | |
| 516 | 516 | signing_secret: "secret".to_string(), | |
| 517 | 517 | storage: None, | |
| 518 | 518 | synckit_storage: None, | |
| @@ -575,7 +575,7 @@ mod tests { | |||
| 575 | 575 | host: "127.0.0.1".parse().unwrap(), | |
| 576 | 576 | port: 3000, | |
| 577 | 577 | database_url: "postgres://test".to_string(), | |
| 578 | - | host_url: "http://localhost:3000".to_string(), | |
| 578 | + | host_url: std::sync::Arc::from("http://localhost:3000"), | |
| 579 | 579 | signing_secret: "secret".to_string(), | |
| 580 | 580 | storage: None, | |
| 581 | 581 | synckit_storage: None, |
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use std::collections::HashMap; | |
| 4 | 4 | use std::net::{IpAddr, SocketAddr}; | |
| 5 | + | use std::sync::Arc; | |
| 5 | 6 | ||
| 6 | 7 | use crate::db::{CreatorTier, UserId}; | |
| 7 | 8 | ||
| @@ -14,8 +15,9 @@ pub struct Config { | |||
| 14 | 15 | pub port: u16, | |
| 15 | 16 | /// Database connection URL | |
| 16 | 17 | pub database_url: String, | |
| 17 | - | /// Public-facing host URL (e.g., "https://makenot.work" or "localhost:3000") | |
| 18 | - | pub host_url: String, | |
| 18 | + | /// Public-facing host URL (e.g., "https://makenot.work" or "localhost:3000"). | |
| 19 | + | /// Stored as `Arc<str>` so cloning into spawned tasks / templates is cheap. | |
| 20 | + | pub host_url: Arc<str>, | |
| 19 | 21 | /// Secret key for signing tokens (password reset, email verification, etc.) | |
| 20 | 22 | pub signing_secret: String, | |
| 21 | 23 | /// S3-compatible storage configuration (optional) | |
| @@ -202,7 +204,7 @@ impl Config { | |||
| 202 | 204 | host, | |
| 203 | 205 | port, | |
| 204 | 206 | database_url, | |
| 205 | - | host_url, | |
| 207 | + | host_url: Arc::from(host_url), | |
| 206 | 208 | signing_secret, | |
| 207 | 209 | storage, | |
| 208 | 210 | synckit_storage, | |
| @@ -475,7 +477,7 @@ mod tests { | |||
| 475 | 477 | host: "127.0.0.1".parse().unwrap(), | |
| 476 | 478 | port: 8080, | |
| 477 | 479 | database_url: "postgres://test".to_string(), | |
| 478 | - | host_url: "http://localhost:8080".to_string(), | |
| 480 | + | host_url: Arc::from("http://localhost:8080"), | |
| 479 | 481 | signing_secret: "secret".to_string(), | |
| 480 | 482 | storage: None, | |
| 481 | 483 | synckit_storage: None, |
| @@ -604,7 +604,7 @@ pub async fn check_presign_allowed( | |||
| 604 | 604 | )); | |
| 605 | 605 | } | |
| 606 | 606 | ||
| 607 | - | let tier = effective_tier.unwrap(); | |
| 607 | + | let tier = effective_tier.expect("guarded by is_none check above"); | |
| 608 | 608 | if !tier.allows_file_uploads() { | |
| 609 | 609 | return Err(AppError::BadRequest( | |
| 610 | 610 | "Basic tier is text-only. Upgrade to Small Files or higher to upload files.".to_string(), |
| @@ -70,6 +70,7 @@ impl StripeClient { | |||
| 70 | 70 | /// Uses Direct Charges pattern. Stripe collects the buyer's email. | |
| 71 | 71 | /// The `checkout_type: "guest"` metadata tells the webhook handler | |
| 72 | 72 | /// to use the guest completion flow. | |
| 73 | + | #[tracing::instrument(skip_all, name = "payments::create_guest_checkout_session")] | |
| 73 | 74 | pub async fn create_guest_checkout_session( | |
| 74 | 75 | &self, | |
| 75 | 76 | checkout: &GuestCheckoutParams<'_>, | |
| @@ -134,6 +135,7 @@ impl StripeClient { | |||
| 134 | 135 | /// | |
| 135 | 136 | /// Uses Direct Charges pattern: the payment goes directly to the creator's | |
| 136 | 137 | /// connected account. No application_fee_amount means 0% platform fee. | |
| 138 | + | #[tracing::instrument(skip_all, name = "payments::create_checkout_session")] | |
| 137 | 139 | pub async fn create_checkout_session( | |
| 138 | 140 | &self, | |
| 139 | 141 | checkout: &CheckoutParams<'_>, | |
| @@ -201,6 +203,7 @@ impl StripeClient { | |||
| 201 | 203 | /// Create a Checkout Session in subscription mode on a connected account. | |
| 202 | 204 | /// | |
| 203 | 205 | /// Uses Direct Charges pattern consistent with one-time purchases. | |
| 206 | + | #[tracing::instrument(skip_all, name = "payments::create_subscription_checkout_session")] | |
| 204 | 207 | pub async fn create_subscription_checkout_session( | |
| 205 | 208 | &self, | |
| 206 | 209 | sub: &SubscriptionCheckoutParams<'_>, | |
| @@ -267,6 +270,7 @@ impl StripeClient { | |||
| 267 | 270 | /// | |
| 268 | 271 | /// Uses Direct Charges pattern: the payment goes directly to the creator's | |
| 269 | 272 | /// connected account. No application_fee_amount means 0% platform fee. | |
| 273 | + | #[tracing::instrument(skip_all, name = "payments::create_tip_checkout_session")] | |
| 270 | 274 | pub async fn create_tip_checkout_session( | |
| 271 | 275 | &self, | |
| 272 | 276 | tip: &TipCheckoutParams<'_>, | |
| @@ -326,6 +330,7 @@ impl StripeClient { | |||
| 326 | 330 | /// | |
| 327 | 331 | /// Unlike creator subscriptions, this does NOT use `with_stripe_account` — | |
| 328 | 332 | /// the payment goes to MNW's own Stripe account. | |
| 333 | + | #[tracing::instrument(skip_all, name = "payments::create_fan_plus_checkout_session")] | |
| 329 | 334 | pub async fn create_fan_plus_checkout_session( | |
| 330 | 335 | &self, | |
| 331 | 336 | price_id: &str, | |
| @@ -364,6 +369,7 @@ impl StripeClient { | |||
| 364 | 369 | /// Create a Checkout Session for a creator tier subscription on MNW's own Stripe account. | |
| 365 | 370 | /// | |
| 366 | 371 | /// Same pattern as Fan+ — payment goes to MNW, not a connected account. | |
| 372 | + | #[tracing::instrument(skip_all, name = "payments::create_creator_tier_checkout_session")] | |
| 367 | 373 | pub async fn create_creator_tier_checkout_session( | |
| 368 | 374 | &self, | |
| 369 | 375 | price_id: &str, |
| @@ -16,6 +16,7 @@ impl StripeClient { | |||
| 16 | 16 | /// Create a Stripe Standard connected account for a creator. | |
| 17 | 17 | /// | |
| 18 | 18 | /// Returns the `acct_...` account ID string. | |
| 19 | + | #[tracing::instrument(skip_all, name = "payments::create_connect_account")] | |
| 19 | 20 | pub async fn create_connect_account(&self, email: &str) -> Result<String> { | |
| 20 | 21 | let mut params = CreateAccount::new(); | |
| 21 | 22 | params.type_ = Some(AccountType::Standard); | |
| @@ -36,6 +37,7 @@ impl StripeClient { | |||
| 36 | 37 | /// Returns the URL to redirect the creator to. The link is single-use | |
| 37 | 38 | /// and expires, so `refresh_url` should point to a handler that creates | |
| 38 | 39 | /// a fresh link. | |
| 40 | + | #[tracing::instrument(skip_all, name = "payments::create_account_link")] | |
| 39 | 41 | pub async fn create_account_link( | |
| 40 | 42 | &self, | |
| 41 | 43 | account_id: &str, | |
| @@ -61,6 +63,7 @@ impl StripeClient { | |||
| 61 | 63 | } | |
| 62 | 64 | ||
| 63 | 65 | /// Fetch a Stripe Connect account by ID and return an `AccountUpdate`. | |
| 66 | + | #[tracing::instrument(skip_all, name = "payments::fetch_account")] | |
| 64 | 67 | pub async fn fetch_account(&self, account_id: &str) -> Result<super::AccountUpdate> { | |
| 65 | 68 | let account_id = account_id.parse().map_err(|_| { | |
| 66 | 69 | AppError::BadRequest("Invalid Stripe account ID format".to_string()) | |
| @@ -84,6 +87,7 @@ impl StripeClient { | |||
| 84 | 87 | /// Create a Stripe Product and monthly recurring Price on a connected account. | |
| 85 | 88 | /// | |
| 86 | 89 | /// Called when a creator creates a subscription tier. Returns `(product_id, price_id)`. | |
| 90 | + | #[tracing::instrument(skip_all, name = "payments::create_subscription_product_and_price")] | |
| 87 | 91 | pub async fn create_subscription_product_and_price( | |
| 88 | 92 | &self, | |
| 89 | 93 | connected_account_id: &str, | |
| @@ -137,6 +141,7 @@ impl StripeClient { | |||
| 137 | 141 | /// | |
| 138 | 142 | /// Returns the Stripe Balance object which contains `available` and | |
| 139 | 143 | /// `pending` amounts broken down by currency. | |
| 144 | + | #[tracing::instrument(skip_all, name = "payments::get_connected_account_balance")] | |
| 140 | 145 | pub async fn get_connected_account_balance(&self, account_id: &str) -> Result<Balance> { | |
| 141 | 146 | let account_id = account_id.parse().map_err(|_| { | |
| 142 | 147 | AppError::BadRequest("Invalid Stripe account ID format".to_string()) | |
| @@ -153,6 +158,7 @@ impl StripeClient { | |||
| 153 | 158 | /// Pause a subscription on a connected account (void invoices while paused). | |
| 154 | 159 | /// | |
| 155 | 160 | /// Used when a creator is suspended to stop charging their fans. | |
| 161 | + | #[tracing::instrument(skip_all, name = "payments::pause_subscription")] | |
| 156 | 162 | pub async fn pause_subscription( | |
| 157 | 163 | &self, | |
| 158 | 164 | stripe_sub_id: &str, | |
| @@ -187,6 +193,7 @@ impl StripeClient { | |||
| 187 | 193 | /// Resume a paused subscription on a connected account. | |
| 188 | 194 | /// | |
| 189 | 195 | /// Used when a creator is unsuspended to resume charging their fans. | |
| 196 | + | #[tracing::instrument(skip_all, name = "payments::resume_subscription")] | |
| 190 | 197 | pub async fn resume_subscription( | |
| 191 | 198 | &self, | |
| 192 | 199 | stripe_sub_id: &str, | |
| @@ -220,6 +227,7 @@ impl StripeClient { | |||
| 220 | 227 | /// Cancel a subscription on a connected account (permanent, not pausable). | |
| 221 | 228 | /// | |
| 222 | 229 | /// Used when a creator's account is terminated. | |
| 230 | + | #[tracing::instrument(skip_all, name = "payments::cancel_subscription")] | |
| 223 | 231 | pub async fn cancel_subscription( | |
| 224 | 232 | &self, | |
| 225 | 233 | stripe_sub_id: &str, | |
| @@ -246,6 +254,7 @@ impl StripeClient { | |||
| 246 | 254 | } | |
| 247 | 255 | ||
| 248 | 256 | /// Cancel a platform-level subscription (creator tier, Fan+). Not on a connected account. | |
| 257 | + | #[tracing::instrument(skip_all, name = "payments::cancel_platform_subscription")] | |
| 249 | 258 | pub async fn cancel_platform_subscription( | |
| 250 | 259 | &self, | |
| 251 | 260 | stripe_sub_id: &str, | |
| @@ -269,6 +278,7 @@ impl StripeClient { | |||
| 269 | 278 | /// Used for creator pause (cancel=true: fans keep access through their paid | |
| 270 | 279 | /// period, then the subscription naturally lapses) and resume (cancel=false: | |
| 271 | 280 | /// undo the scheduled cancellation so billing continues). | |
| 281 | + | #[tracing::instrument(skip_all, name = "payments::set_cancel_at_period_end")] | |
| 272 | 282 | pub async fn set_cancel_at_period_end( | |
| 273 | 283 | &self, | |
| 274 | 284 | stripe_sub_id: &str, | |
| @@ -302,6 +312,7 @@ impl StripeClient { | |||
| 302 | 312 | /// | |
| 303 | 313 | /// Uses the raw Stripe API because Direct Charges require the | |
| 304 | 314 | /// `Stripe-Account` header to target the connected account. | |
| 315 | + | #[tracing::instrument(skip_all, name = "payments::create_refund")] | |
| 305 | 316 | pub async fn create_refund( | |
| 306 | 317 | &self, | |
| 307 | 318 | payment_intent_id: &str, |
| @@ -13,6 +13,7 @@ type HmacSha256 = Hmac<Sha256>; | |||
| 13 | 13 | ||
| 14 | 14 | impl StripeClient { | |
| 15 | 15 | /// Verify and parse a webhook event | |
| 16 | + | #[tracing::instrument(skip_all, name = "payments::verify_webhook")] | |
| 16 | 17 | pub fn verify_webhook(&self, payload: &str, signature: &str) -> Result<Event> { | |
| 17 | 18 | Webhook::construct_event(payload, signature, &self.config.webhook_secret) | |
| 18 | 19 | .map_err(|e| { | |
| @@ -25,6 +26,7 @@ impl StripeClient { | |||
| 25 | 26 | /// | |
| 26 | 27 | /// Returns `Err(503)` if the v2 secret is not configured, `Err(400)` on | |
| 27 | 28 | /// invalid signature. | |
| 29 | + | #[tracing::instrument(skip_all, name = "payments::verify_webhook_v2")] | |
| 28 | 30 | pub fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> { | |
| 29 | 31 | let secret = self.config.webhook_secret_v2.as_deref().ok_or_else(|| { | |
| 30 | 32 | AppError::ServiceUnavailable("Stripe v2 webhook secret not configured".to_string()) |
| @@ -96,6 +96,7 @@ pub(super) fn set_embed_headers(response: &mut Response) { | |||
| 96 | 96 | ||
| 97 | 97 | // ─── Buy Button ───────────────────────────────────────────────────────────── | |
| 98 | 98 | ||
| 99 | + | #[tracing::instrument(skip_all, name = "embed::item_button")] | |
| 99 | 100 | /// GET /embed/i/{item_id}/button | |
| 100 | 101 | pub(super) async fn item_button( | |
| 101 | 102 | State(state): State<AppState>, | |
| @@ -165,6 +166,7 @@ pub(super) struct CardQuery { | |||
| 165 | 166 | pub layout: Option<String>, | |
| 166 | 167 | } | |
| 167 | 168 | ||
| 169 | + | #[tracing::instrument(skip_all, name = "embed::item_card")] | |
| 168 | 170 | /// GET /embed/i/{item_id}/card | |
| 169 | 171 | pub(super) async fn item_card( | |
| 170 | 172 | State(state): State<AppState>, | |
| @@ -264,6 +266,7 @@ body {{ | |||
| 264 | 266 | ||
| 265 | 267 | // ─── Audio Player ─────────────────────────────────────────────────────────── | |
| 266 | 268 | ||
| 269 | + | #[tracing::instrument(skip_all, name = "embed::item_player")] | |
| 267 | 270 | /// GET /embed/i/{item_id}/player | |
| 268 | 271 | /// | |
| 269 | 272 | /// Audio preview player embed. Returns 404 for non-audio items. |
| @@ -13,6 +13,7 @@ use crate::{ | |||
| 13 | 13 | ||
| 14 | 14 | use super::item::set_embed_headers; | |
| 15 | 15 | ||
| 16 | + | #[tracing::instrument(skip_all, name = "embed::project_card")] | |
| 16 | 17 | /// GET /embed/p/{project_slug}/card | |
| 17 | 18 | pub(super) async fn project_card( | |
| 18 | 19 | State(state): State<AppState>, |
| @@ -13,6 +13,7 @@ use crate::{ | |||
| 13 | 13 | ||
| 14 | 14 | use super::item::set_embed_headers; | |
| 15 | 15 | ||
| 16 | + | #[tracing::instrument(skip_all, name = "embed::tip_button")] | |
| 16 | 17 | /// GET /embed/u/{username}/tip | |
| 17 | 18 | pub(super) async fn tip_button( | |
| 18 | 19 | State(state): State<AppState>, |
| @@ -5,6 +5,8 @@ | |||
| 5 | 5 | //! - `GET /api/health` (JSON) — reads cached results from the background monitor's database. | |
| 6 | 6 | //! Fast (<10ms), no live probes. This is what PoM and other external services should poll. | |
| 7 | 7 | ||
| 8 | + | use std::sync::Arc; | |
| 9 | + | ||
| 8 | 10 | use axum::extract::State; | |
| 9 | 11 | use axum::http::StatusCode; | |
| 10 | 12 | use axum::response::IntoResponse; | |
| @@ -131,7 +133,7 @@ struct HealthData { | |||
| 131 | 133 | ||
| 132 | 134 | // Server | |
| 133 | 135 | environment: &'static str, | |
| 134 | - | host: String, | |
| 136 | + | host: Arc<str>, | |
| 135 | 137 | started_at: String, | |
| 136 | 138 | ||
| 137 | 139 | // Tests |
| @@ -8,7 +8,14 @@ use crate::{ | |||
| 8 | 8 | AppState, | |
| 9 | 9 | }; | |
| 10 | 10 | ||
| 11 | + | use super::checkout_helpers::{ | |
| 12 | + | check_pending_refund, maybe_generate_license_key, record_tip_splits, | |
| 13 | + | record_transaction_splits, send_guest_sale_notification, send_purchase_emails, | |
| 14 | + | send_tip_email, subscribe_buyer_to_mailing_list, | |
| 15 | + | }; | |
| 16 | + | ||
| 11 | 17 | /// Handle checkout.session.completed for one-time purchases | |
| 18 | + | #[tracing::instrument(skip_all, name = "stripe::handle_purchase_checkout")] | |
| 12 | 19 | pub(super) async fn handle_purchase_checkout_completed( | |
| 13 | 20 | state: &AppState, | |
| 14 | 21 | session: &stripe::CheckoutSession, | |
| @@ -99,107 +106,8 @@ pub(super) async fn handle_purchase_checkout_completed( | |||
| 99 | 106 | Ok(()) | |
| 100 | 107 | } | |
| 101 | 108 | ||
| 102 | - | /// Generate a license key for the purchased item if keys are enabled. | |
| 103 | - | async fn maybe_generate_license_key( | |
| 104 | - | state: &AppState, | |
| 105 | - | item_id: db::ItemId, | |
| 106 | - | buyer_id: db::UserId, | |
| 107 | - | transaction_id: db::TransactionId, | |
| 108 | - | ) { | |
| 109 | - | let item = match db::items::get_item_by_id(&state.db, item_id).await { | |
| 110 | - | Ok(Some(item)) if item.enable_license_keys => item, | |
| 111 | - | _ => return, | |
| 112 | - | }; | |
| 113 | - | ||
| 114 | - | let key_code = helpers::generate_key_code(); | |
| 115 | - | match db::license_keys::create_license_key( | |
| 116 | - | &state.db, item_id, buyer_id, Some(transaction_id), | |
| 117 | - | &key_code, item.default_max_activations, | |
| 118 | - | ).await { | |
| 119 | - | Ok(key) => { | |
| 120 | - | tracing::info!(key_id = %key.id, buyer_id = %buyer_id, item_id = %item_id, "license key generated for purchase"); | |
| 121 | - | } | |
| 122 | - | Err(e) => { | |
| 123 | - | tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase"); | |
| 124 | - | if let Some(ref wam) = state.wam { | |
| 125 | - | let title = format!("License key not issued: item {item_id}"); | |
| 126 | - | let body = format!( | |
| 127 | - | "Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \ | |
| 128 | - | license key generation failed: {e}\n\nManually issue a key.", | |
| 129 | - | ); | |
| 130 | - | wam.create_ticket(&title, Some(&body), "critical", "license-key-gen-failed", Some(&transaction_id.to_string())).await; | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget). | |
| 137 | - | fn send_purchase_emails( | |
| 138 | - | state: &AppState, | |
| 139 | - | tx: &db::DbTransaction, | |
| 140 | - | buyer_id: db::UserId, | |
| 141 | - | seller_id: db::UserId, | |
| 142 | - | ) { | |
| 143 | - | let db = state.db.clone(); | |
| 144 | - | let email = state.email.clone(); | |
| 145 | - | let amount_cents = tx.amount_cents; | |
| 146 | - | let item_title = tx.item_title.clone(); | |
| 147 | - | let host_url = state.config.host_url.clone(); | |
| 148 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 149 | - | ||
| 150 | - | tokio::spawn(async move { | |
| 151 | - | let buyer = db::users::get_user_by_id(&db, buyer_id).await.ok().flatten(); | |
| 152 | - | let seller = db::users::get_user_by_id(&db, seller_id).await.ok().flatten(); | |
| 153 | - | ||
| 154 | - | // Purchase confirmation to buyer | |
| 155 | - | if let Some(ref buyer) = buyer { | |
| 156 | - | let price = helpers::format_price(amount_cents); | |
| 157 | - | let title = item_title.clone().unwrap_or_else(|| "your item".to_string()); | |
| 158 | - | if let Err(e) = email.send_purchase_confirmation( | |
| 159 | - | &buyer.email, buyer.display_name.as_deref(), &title, &price, | |
| 160 | - | ).await { | |
| 161 | - | tracing::error!(error = ?e, "failed to send purchase confirmation email"); | |
| 162 | - | } | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | // Sale notification to seller | |
| 166 | - | if let Some(ref seller) = seller && seller.notify_sale { | |
| 167 | - | let price = helpers::format_price(amount_cents); | |
| 168 | - | let title = item_title.unwrap_or_else(|| "an item".to_string()); | |
| 169 | - | let buyer_username = buyer.as_ref() | |
| 170 | - | .map(|b| b.username.to_string()) | |
| 171 | - | .unwrap_or_else(|| "Someone".to_string()); | |
| 172 | - | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 173 | - | &host_url, seller.id, "sale", &seller.id.to_string(), &signing_secret, | |
| 174 | - | ); | |
| 175 | - | if let Err(e) = email.send_sale_notification( | |
| 176 | - | &seller.email, seller.display_name.as_deref(), | |
| 177 | - | &buyer_username, &title, &price, Some(&unsub_url), | |
| 178 | - | ).await { | |
| 179 | - | tracing::error!(error = ?e, "failed to send sale notification email"); | |
| 180 | - | } | |
| 181 | - | } | |
| 182 | - | }); | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | /// Subscribe buyer to the item's project content mailing list (fire-and-forget). | |
| 186 | - | fn subscribe_buyer_to_mailing_list(state: &AppState, item_id: db::ItemId, buyer_id: db::UserId) { | |
| 187 | - | let db = state.db.clone(); | |
| 188 | - | tokio::spawn(async move { | |
| 189 | - | if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await | |
| 190 | - | && let Err(e) = db::mailing_lists::subscribe_to_content_list( | |
| 191 | - | &db, item.project_id, buyer_id, | |
| 192 | - | ).await | |
| 193 | - | { | |
| 194 | - | tracing::warn!( | |
| 195 | - | project_id = %item.project_id, buyer_id = %buyer_id, | |
| 196 | - | error = ?e, "failed to subscribe buyer to content mailing list" | |
| 197 | - | ); | |
| 198 | - | } | |
| 199 | - | }); | |
| 200 | - | } | |
| 201 | - | ||
| 202 | 109 | /// Handle checkout.session.completed for subscriptions | |
| 110 | + | #[tracing::instrument(skip_all, name = "stripe::handle_subscription_checkout")] | |
| 203 | 111 | pub(super) async fn handle_subscription_checkout_completed( | |
| 204 | 112 | state: &AppState, | |
| 205 | 113 | session: &stripe::CheckoutSession, | |
| @@ -298,6 +206,7 @@ pub(super) async fn handle_subscription_checkout_completed( | |||
| 298 | 206 | } | |
| 299 | 207 | ||
| 300 | 208 | /// Handle checkout.session.completed for Fan+ subscriptions | |
| 209 | + | #[tracing::instrument(skip_all, name = "stripe::handle_fan_plus_checkout")] | |
| 301 | 210 | pub(super) async fn handle_fan_plus_checkout_completed( | |
| 302 | 211 | state: &AppState, | |
| 303 | 212 | session: &stripe::CheckoutSession, | |
| @@ -365,6 +274,7 @@ pub(super) async fn handle_fan_plus_checkout_completed( | |||
| 365 | 274 | } | |
| 366 | 275 | ||
| 367 | 276 | /// Handle checkout.session.completed for creator tier subscriptions | |
| 277 | + | #[tracing::instrument(skip_all, name = "stripe::handle_creator_tier_checkout")] | |
| 368 | 278 | pub(super) async fn handle_creator_tier_checkout_completed( | |
| 369 | 279 | state: &AppState, | |
| 370 | 280 | session: &stripe::CheckoutSession, | |
| @@ -466,6 +376,7 @@ pub(super) async fn handle_creator_tier_checkout_completed( | |||
| 466 | 376 | } | |
| 467 | 377 | ||
| 468 | 378 | /// Handle checkout.session.completed for tips | |
| 379 | + | #[tracing::instrument(skip_all, name = "stripe::handle_tip_checkout")] | |
| 469 | 380 | pub(super) async fn handle_tip_checkout_completed( | |
| 470 | 381 | state: &AppState, | |
| 471 | 382 | session: &stripe::CheckoutSession, | |
| @@ -509,169 +420,11 @@ pub(super) async fn handle_tip_checkout_completed( | |||
| 509 | 420 | Ok(()) | |
| 510 | 421 | } | |
| 511 | 422 | ||
| 512 | - | /// Send tip notification to recipient (fire-and-forget). | |
| 513 | - | fn send_tip_email( | |
| 514 | - | state: &AppState, | |
| 515 | - | tip: &db::DbTip, | |
| 516 | - | tipper_id: db::UserId, | |
| 517 | - | recipient_id: db::UserId, | |
| 518 | - | ) { | |
| 519 | - | let db = state.db.clone(); | |
| 520 | - | let email = state.email.clone(); | |
| 521 | - | let amount_cents = tip.amount_cents; | |
| 522 | - | let message = tip.message.clone(); | |
| 523 | - | let host_url = state.config.host_url.clone(); | |
| 524 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 525 | - | ||
| 526 | - | tokio::spawn(async move { | |
| 527 | - | let tipper = db::users::get_user_by_id(&db, tipper_id).await.ok().flatten(); | |
| 528 | - | let recipient = db::users::get_user_by_id(&db, recipient_id).await.ok().flatten(); | |
| 529 | - | ||
| 530 | - | if let Some(ref recipient) = recipient && recipient.notify_tip { | |
| 531 | - | let price = helpers::format_price(amount_cents); | |
| 532 | - | let tipper_name = tipper.as_ref() | |
| 533 | - | .map(|t| t.display_name.as_deref().unwrap_or(&t.username).to_string()) | |
| 534 | - | .unwrap_or_else(|| "Someone".to_string()); | |
| 535 | - | ||
| 536 | - | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 537 | - | &host_url, recipient.id, "notify_tip", &recipient.id.to_string(), &signing_secret, | |
| 538 | - | ); | |
| 539 | - | if let Err(e) = email.send_tip_notification( | |
| 540 | - | &recipient.email, recipient.display_name.as_deref(), | |
| 541 | - | &tipper_name, &price, message.as_deref(), Some(&unsub_url), | |
| 542 | - | ).await { | |
| 543 | - | tracing::error!(error = ?e, "failed to send tip notification email"); | |
| 544 | - | } | |
| 545 | - | } | |
| 546 | - | }); | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | /// Check if a pending refund exists for this payment intent and process it. | |
| 550 | - | /// | |
| 551 | - | /// Called after a transaction is completed to handle out-of-order webhook | |
| 552 | - | /// delivery (refund arrived before payment confirmation). | |
| 553 | - | async fn check_pending_refund(state: &AppState, payment_intent_id: &str) { | |
| 554 | - | let pending = match db::pending_refunds::claim_pending_refund(&state.db, payment_intent_id).await { | |
| 555 | - | Ok(Some(p)) => p, | |
| 556 | - | Ok(None) => return, | |
| 557 | - | Err(e) => { | |
| 558 | - | tracing::error!(error = ?e, "failed to check pending refunds"); | |
| 559 | - | return; | |
| 560 | - | } | |
| 561 | - | }; | |
| 562 | - | ||
| 563 | - | tracing::info!( | |
| 564 | - | payment_intent_id = %payment_intent_id, | |
| 565 | - | pending_refund_id = %pending.id, | |
| 566 | - | "found pending refund — processing now" | |
| 567 | - | ); | |
| 568 | - | ||
| 569 | - | let refund_data = crate::payments::ChargeRefundData { | |
| 570 | - | payment_intent_id: pending.payment_intent_id, | |
| 571 | - | amount: pending.amount, | |
| 572 | - | amount_refunded: pending.amount_refunded, | |
| 573 | - | }; | |
| 574 | - | ||
| 575 | - | if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data).await { | |
| 576 | - | tracing::error!( | |
| 577 | - | error = ?e, pending_refund_id = %pending.id, | |
| 578 | - | "failed to process pending refund after payment completion" | |
| 579 | - | ); | |
| 580 | - | } | |
| 581 | - | } | |
| 582 | - | ||
| 583 | - | /// Record revenue splits for a completed item purchase. | |
| 584 | - | /// | |
| 585 | - | /// Looks up the item's project and its members. If the project has members | |
| 586 | - | /// with split percentages, creates split records for each member. The owner | |
| 587 | - | /// receives the remainder (100% minus all member splits). | |
| 588 | - | /// | |
| 589 | - | /// Splits are recorded as obligations — actual payment transfer to members | |
| 590 | - | /// is handled by the project owner outside the platform for now. | |
| 591 | - | async fn record_transaction_splits( | |
| 592 | - | state: &AppState, | |
| 593 | - | transaction_id: db::TransactionId, | |
| 594 | - | item_id: db::ItemId, | |
| 595 | - | amount_cents: db::Cents, | |
| 596 | - | ) { | |
| 597 | - | let item = match db::items::get_item_by_id(&state.db, item_id).await { | |
| 598 | - | Ok(Some(item)) => item, | |
| 599 | - | _ => return, | |
| 600 | - | }; | |
| 601 | - | ||
| 602 | - | let members = match db::project_members::get_project_members(&state.db, item.project_id).await { | |
| 603 | - | Ok(m) if !m.is_empty() => m, | |
| 604 | - | _ => return, | |
| 605 | - | }; | |
| 606 | - | ||
| 607 | - | let splits = compute_splits(amount_cents, &members); | |
| 608 | - | ||
| 609 | - | if let Err(e) = db::project_members::create_transaction_splits(&state.db, transaction_id, &splits).await { | |
| 610 | - | tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits"); | |
| 611 | - | } else { | |
| 612 | - | tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded"); | |
| 613 | - | } | |
| 614 | - | } | |
| 615 | - | ||
| 616 | - | /// Record revenue splits for a completed tip on a project with members. | |
| 617 | - | async fn record_tip_splits( | |
| 618 | - | state: &AppState, | |
| 619 | - | tip_id: db::TipId, | |
| 620 | - | project_id: db::ProjectId, | |
| 621 | - | amount_cents: db::Cents, | |
| 622 | - | ) { | |
| 623 | - | let members = match db::project_members::get_project_members(&state.db, project_id).await { | |
| 624 | - | Ok(m) if !m.is_empty() => m, | |
| 625 | - | _ => return, | |
| 626 | - | }; | |
| 627 | - | ||
| 628 | - | let splits = compute_splits(amount_cents, &members); | |
| 629 | - | ||
| 630 | - | if let Err(e) = db::project_members::create_tip_splits(&state.db, tip_id, &splits).await { | |
| 631 | - | tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits"); | |
| 632 | - | } else { | |
| 633 | - | tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded"); | |
| 634 | - | } | |
| 635 | - | } | |
| 636 | - | ||
| 637 | - | /// Compute per-member split amounts with rounding. | |
| 638 | - | /// | |
| 639 | - | /// Uses floor division and distributes the remainder (one cent at a time) | |
| 640 | - | /// to the first members in list order so the total always equals | |
| 641 | - | /// `amount_cents * total_split_percent / 100`. | |
| 642 | - | fn compute_splits( | |
| 643 | - | amount_cents: db::Cents, | |
| 644 | - | members: &[db::DbProjectMemberWithUser], | |
| 645 | - | ) -> Vec<(db::UserId, i32, i16)> { | |
| 646 | - | let amount = amount_cents.as_i64(); | |
| 647 | - | let mut splits: Vec<(db::UserId, i32, i16)> = members | |
| 648 | - | .iter() | |
| 649 | - | .map(|m| { | |
| 650 | - | let member_amount = (amount * m.split_percent as i64 / 100) as i32; | |
| 651 | - | (m.user_id, member_amount, m.split_percent) | |
| 652 | - | }) | |
| 653 | - | .collect(); | |
| 654 | - | ||
| 655 | - | // Distribute truncation remainder one cent at a time | |
| 656 | - | let total_split_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum(); | |
| 657 | - | let expected_total = (amount * total_split_pct / 100) as i32; | |
| 658 | - | let actual_total: i32 = splits.iter().map(|(_, amt, _)| *amt).sum(); | |
| 659 | - | let mut remainder = expected_total - actual_total; | |
| 660 | - | for split in &mut splits { | |
| 661 | - | if remainder <= 0 { | |
| 662 | - | break; | |
| 663 | - | } | |
| 664 | - | split.1 += 1; | |
| 665 | - | remainder -= 1; | |
| 666 | - | } | |
| 667 | - | ||
| 668 | - | splits | |
| 669 | - | } | |
| 670 | - | ||
| 671 | 423 | /// Handle checkout.session.completed for guest purchases (no MNW account). | |
| 672 | 424 | /// | |
| 673 | 425 | /// Extracts the buyer's email from Stripe, completes the transaction, and | |
| 674 | 426 | /// auto-attaches to an existing account if the email matches. | |
| 427 | + | #[tracing::instrument(skip_all, name = "stripe::handle_guest_checkout")] | |
| 675 | 428 | pub(super) async fn handle_guest_checkout_completed( | |
| 676 | 429 | state: &AppState, | |
| 677 | 430 | session: &stripe::CheckoutSession, | |
| @@ -755,38 +508,3 @@ pub(super) async fn handle_guest_checkout_completed( | |||
| 755 | 508 | ||
| 756 | 509 | Ok(()) | |
| 757 | 510 | } | |
| 758 | - | ||
| 759 | - | /// Send sale notification to the seller for a guest purchase. | |
| 760 | - | fn send_guest_sale_notification( | |
| 761 | - | state: &AppState, | |
| 762 | - | tx: &db::DbTransaction, | |
| 763 | - | guest_email: &str, | |
| 764 | - | seller_id: db::UserId, | |
| 765 | - | ) { | |
| 766 | - | let db = state.db.clone(); | |
| 767 | - | let email_client = state.email.clone(); | |
| 768 | - | let host_url = state.config.host_url.clone(); | |
| 769 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 770 | - | let amount_cents = tx.amount_cents; | |
| 771 | - | let item_title = tx.item_title.clone(); | |
| 772 | - | let buyer_label = guest_email.to_string(); | |
| 773 | - | ||
| 774 | - | tokio::spawn(async move { | |
| 775 | - | let seller = match db::users::get_user_by_id(&db, seller_id).await.ok().flatten() { | |
| 776 | - | Some(s) if s.notify_sale => s, | |
| 777 | - | _ => return, | |
| 778 | - | }; | |
| 779 | - | ||
| 780 | - | let price = helpers::format_price(amount_cents); | |
| 781 | - | let title = item_title.unwrap_or_else(|| "an item".to_string()); | |
| 782 | - | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 783 | - | &host_url, seller.id, "sale", &seller.id.to_string(), &signing_secret, | |
| 784 | - | ); | |
| 785 | - | if let Err(e) = email_client.send_sale_notification( | |
| 786 | - | &seller.email, seller.display_name.as_deref(), | |
| 787 | - | &buyer_label, &title, &price, Some(&unsub_url), | |
| 788 | - | ).await { | |
| 789 | - | tracing::error!(error = ?e, "failed to send sale notification for guest purchase"); | |
| 790 | - | } | |
| 791 | - | }); | |
| 792 | - | } |
| @@ -0,0 +1,302 @@ | |||
| 1 | + | //! Helper functions for checkout webhook handlers: email notifications, | |
| 2 | + | //! license key generation, revenue splits, and pending refund processing. | |
| 3 | + | ||
| 4 | + | use crate::{ | |
| 5 | + | db, | |
| 6 | + | helpers, | |
| 7 | + | AppState, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | /// Generate a license key for the purchased item if keys are enabled. | |
| 11 | + | pub(super) async fn maybe_generate_license_key( | |
| 12 | + | state: &AppState, | |
| 13 | + | item_id: db::ItemId, | |
| 14 | + | buyer_id: db::UserId, | |
| 15 | + | transaction_id: db::TransactionId, | |
| 16 | + | ) { | |
| 17 | + | let item = match db::items::get_item_by_id(&state.db, item_id).await { | |
| 18 | + | Ok(Some(item)) if item.enable_license_keys => item, | |
| 19 | + | _ => return, | |
| 20 | + | }; | |
| 21 | + | ||
| 22 | + | let key_code = helpers::generate_key_code(); | |
| 23 | + | match db::license_keys::create_license_key( | |
| 24 | + | &state.db, item_id, buyer_id, Some(transaction_id), | |
| 25 | + | &key_code, item.default_max_activations, | |
| 26 | + | ).await { | |
| 27 | + | Ok(key) => { | |
| 28 | + | tracing::info!(key_id = %key.id, buyer_id = %buyer_id, item_id = %item_id, "license key generated for purchase"); | |
| 29 | + | } | |
| 30 | + | Err(e) => { | |
| 31 | + | tracing::error!(buyer_id = %buyer_id, item_id = %item_id, error = ?e, "failed to generate license key for purchase"); | |
| 32 | + | if let Some(ref wam) = state.wam { | |
| 33 | + | let title = format!("License key not issued: item {item_id}"); | |
| 34 | + | let body = format!( | |
| 35 | + | "Buyer {buyer_id} purchased item {item_id} (tx {transaction_id}) but \ | |
| 36 | + | license key generation failed: {e}\n\nManually issue a key.", | |
| 37 | + | ); | |
| 38 | + | wam.create_ticket(&title, Some(&body), "critical", "license-key-gen-failed", Some(&transaction_id.to_string())).await; | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | /// Send purchase confirmation to buyer and sale notification to seller (fire-and-forget). | |
| 45 | + | pub(super) fn send_purchase_emails( | |
| 46 | + | state: &AppState, | |
| 47 | + | tx: &db::DbTransaction, | |
| 48 | + | buyer_id: db::UserId, | |
| 49 | + | seller_id: db::UserId, | |
| 50 | + | ) { | |
| 51 | + | let db = state.db.clone(); | |
| 52 | + | let email = state.email.clone(); | |
| 53 | + | let amount_cents = tx.amount_cents; | |
| 54 | + | let item_title = tx.item_title.clone(); | |
| 55 | + | let host_url = state.config.host_url.clone(); | |
| 56 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 57 | + | ||
| 58 | + | tokio::spawn(async move { | |
| 59 | + | let buyer = db::users::get_user_by_id(&db, buyer_id).await.ok().flatten(); | |
| 60 | + | let seller = db::users::get_user_by_id(&db, seller_id).await.ok().flatten(); | |
| 61 | + | ||
| 62 | + | // Purchase confirmation to buyer | |
| 63 | + | if let Some(ref buyer) = buyer { | |
| 64 | + | let price = helpers::format_price(amount_cents); | |
| 65 | + | let title = item_title.clone().unwrap_or_else(|| "your item".to_string()); | |
| 66 | + | if let Err(e) = email.send_purchase_confirmation( | |
| 67 | + | &buyer.email, buyer.display_name.as_deref(), &title, &price, | |
| 68 | + | ).await { | |
| 69 | + | tracing::error!(error = ?e, "failed to send purchase confirmation email"); | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Sale notification to seller | |
| 74 | + | if let Some(ref seller) = seller && seller.notify_sale { | |
| 75 | + | let price = helpers::format_price(amount_cents); | |
| 76 | + | let title = item_title.unwrap_or_else(|| "an item".to_string()); | |
| 77 | + | let buyer_username = buyer.as_ref() | |
| 78 | + | .map(|b| b.username.to_string()) | |
| 79 | + | .unwrap_or_else(|| "Someone".to_string()); | |
| 80 | + | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 81 | + | &host_url, seller.id, "sale", &seller.id.to_string(), &signing_secret, | |
| 82 | + | ); | |
| 83 | + | if let Err(e) = email.send_sale_notification( | |
| 84 | + | &seller.email, seller.display_name.as_deref(), | |
| 85 | + | &buyer_username, &title, &price, Some(&unsub_url), | |
| 86 | + | ).await { | |
| 87 | + | tracing::error!(error = ?e, "failed to send sale notification email"); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | }); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Subscribe buyer to the item's project content mailing list (fire-and-forget). | |
| 94 | + | pub(super) fn subscribe_buyer_to_mailing_list(state: &AppState, item_id: db::ItemId, buyer_id: db::UserId) { | |
| 95 | + | let db = state.db.clone(); | |
| 96 | + | tokio::spawn(async move { | |
| 97 | + | if let Ok(Some(item)) = db::items::get_item_by_id(&db, item_id).await | |
| 98 | + | && let Err(e) = db::mailing_lists::subscribe_to_content_list( | |
| 99 | + | &db, item.project_id, buyer_id, | |
| 100 | + | ).await | |
| 101 | + | { | |
| 102 | + | tracing::warn!( | |
| 103 | + | project_id = %item.project_id, buyer_id = %buyer_id, | |
| 104 | + | error = ?e, "failed to subscribe buyer to content mailing list" | |
| 105 | + | ); | |
| 106 | + | } | |
| 107 | + | }); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | /// Send tip notification to recipient (fire-and-forget). | |
| 111 | + | pub(super) fn send_tip_email( | |
| 112 | + | state: &AppState, | |
| 113 | + | tip: &db::DbTip, | |
| 114 | + | tipper_id: db::UserId, | |
| 115 | + | recipient_id: db::UserId, | |
| 116 | + | ) { | |
| 117 | + | let db = state.db.clone(); | |
| 118 | + | let email = state.email.clone(); | |
| 119 | + | let amount_cents = tip.amount_cents; | |
| 120 | + | let message = tip.message.clone(); | |
| 121 | + | let host_url = state.config.host_url.clone(); | |
| 122 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 123 | + | ||
| 124 | + | tokio::spawn(async move { | |
| 125 | + | let tipper = db::users::get_user_by_id(&db, tipper_id).await.ok().flatten(); | |
| 126 | + | let recipient = db::users::get_user_by_id(&db, recipient_id).await.ok().flatten(); | |
| 127 | + | ||
| 128 | + | if let Some(ref recipient) = recipient && recipient.notify_tip { | |
| 129 | + | let price = helpers::format_price(amount_cents); | |
| 130 | + | let tipper_name = tipper.as_ref() | |
| 131 | + | .map(|t| t.display_name.as_deref().unwrap_or(&t.username).to_string()) | |
| 132 | + | .unwrap_or_else(|| "Someone".to_string()); | |
| 133 | + | ||
| 134 | + | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 135 | + | &host_url, recipient.id, "notify_tip", &recipient.id.to_string(), &signing_secret, | |
| 136 | + | ); | |
| 137 | + | if let Err(e) = email.send_tip_notification( | |
| 138 | + | &recipient.email, recipient.display_name.as_deref(), | |
| 139 | + | &tipper_name, &price, message.as_deref(), Some(&unsub_url), | |
| 140 | + | ).await { | |
| 141 | + | tracing::error!(error = ?e, "failed to send tip notification email"); | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | }); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Check if a pending refund exists for this payment intent and process it. | |
| 148 | + | /// | |
| 149 | + | /// Called after a transaction is completed to handle out-of-order webhook | |
| 150 | + | /// delivery (refund arrived before payment confirmation). | |
| 151 | + | pub(super) async fn check_pending_refund(state: &AppState, payment_intent_id: &str) { | |
| 152 | + | let pending = match db::pending_refunds::claim_pending_refund(&state.db, payment_intent_id).await { | |
| 153 | + | Ok(Some(p)) => p, | |
| 154 | + | Ok(None) => return, | |
| 155 | + | Err(e) => { | |
| 156 | + | tracing::error!(error = ?e, "failed to check pending refunds"); | |
| 157 | + | return; | |
| 158 | + | } | |
| 159 | + | }; | |
| 160 | + | ||
| 161 | + | tracing::info!( | |
| 162 | + | payment_intent_id = %payment_intent_id, | |
| 163 | + | pending_refund_id = %pending.id, | |
| 164 | + | "found pending refund — processing now" | |
| 165 | + | ); | |
| 166 | + | ||
| 167 | + | let refund_data = crate::payments::ChargeRefundData { | |
| 168 | + | payment_intent_id: pending.payment_intent_id, | |
| 169 | + | amount: pending.amount, | |
| 170 | + | amount_refunded: pending.amount_refunded, | |
| 171 | + | }; | |
| 172 | + | ||
| 173 | + | if let Err(e) = super::billing::handle_charge_refunded(state, &refund_data).await { | |
| 174 | + | tracing::error!( | |
| 175 | + | error = ?e, pending_refund_id = %pending.id, | |
| 176 | + | "failed to process pending refund after payment completion" | |
| 177 | + | ); | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Record revenue splits for a completed item purchase. | |
| 182 | + | /// | |
| 183 | + | /// Looks up the item's project and its members. If the project has members | |
| 184 | + | /// with split percentages, creates split records for each member. The owner | |
| 185 | + | /// receives the remainder (100% minus all member splits). | |
| 186 | + | /// | |
| 187 | + | /// Splits are recorded as obligations — actual payment transfer to members | |
| 188 | + | /// is handled by the project owner outside the platform for now. | |
| 189 | + | pub(super) async fn record_transaction_splits( | |
| 190 | + | state: &AppState, | |
| 191 | + | transaction_id: db::TransactionId, | |
| 192 | + | item_id: db::ItemId, | |
| 193 | + | amount_cents: db::Cents, | |
| 194 | + | ) { | |
| 195 | + | let item = match db::items::get_item_by_id(&state.db, item_id).await { | |
| 196 | + | Ok(Some(item)) => item, | |
| 197 | + | _ => return, | |
| 198 | + | }; | |
| 199 | + | ||
| 200 | + | let members = match db::project_members::get_project_members(&state.db, item.project_id).await { | |
| 201 | + | Ok(m) if !m.is_empty() => m, | |
| 202 | + | _ => return, | |
| 203 | + | }; | |
| 204 | + | ||
| 205 | + | let splits = compute_splits(amount_cents, &members); | |
| 206 | + | ||
| 207 | + | if let Err(e) = db::project_members::create_transaction_splits(&state.db, transaction_id, &splits).await { | |
| 208 | + | tracing::error!(transaction_id = %transaction_id, error = ?e, "failed to record transaction splits"); | |
| 209 | + | } else { | |
| 210 | + | tracing::info!(transaction_id = %transaction_id, member_count = splits.len(), "revenue splits recorded"); | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// Record revenue splits for a completed tip on a project with members. | |
| 215 | + | pub(super) async fn record_tip_splits( | |
| 216 | + | state: &AppState, | |
| 217 | + | tip_id: db::TipId, | |
| 218 | + | project_id: db::ProjectId, | |
| 219 | + | amount_cents: db::Cents, | |
| 220 | + | ) { | |
| 221 | + | let members = match db::project_members::get_project_members(&state.db, project_id).await { | |
| 222 | + | Ok(m) if !m.is_empty() => m, | |
| 223 | + | _ => return, | |
| 224 | + | }; | |
| 225 | + | ||
| 226 | + | let splits = compute_splits(amount_cents, &members); | |
| 227 | + | ||
| 228 | + | if let Err(e) = db::project_members::create_tip_splits(&state.db, tip_id, &splits).await { | |
| 229 | + | tracing::error!(tip_id = %tip_id, error = ?e, "failed to record tip splits"); | |
| 230 | + | } else { | |
| 231 | + | tracing::info!(tip_id = %tip_id, member_count = splits.len(), "tip splits recorded"); | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Compute per-member split amounts with rounding. | |
| 236 | + | /// | |
| 237 | + | /// Uses floor division and distributes the remainder (one cent at a time) | |
| 238 | + | /// to the first members in list order so the total always equals | |
| 239 | + | /// `amount_cents * total_split_percent / 100`. | |
| 240 | + | fn compute_splits( | |
| 241 | + | amount_cents: db::Cents, | |
| 242 | + | members: &[db::DbProjectMemberWithUser], | |
| 243 | + | ) -> Vec<(db::UserId, i32, i16)> { | |
| 244 | + | let amount = amount_cents.as_i64(); | |
| 245 | + | let mut splits: Vec<(db::UserId, i32, i16)> = members | |
| 246 | + | .iter() | |
| 247 | + | .map(|m| { | |
| 248 | + | let member_amount = (amount * m.split_percent as i64 / 100) as i32; | |
| 249 | + | (m.user_id, member_amount, m.split_percent) | |
| 250 | + | }) | |
| 251 | + | .collect(); | |
| 252 | + | ||
| 253 | + | // Distribute truncation remainder one cent at a time | |
| 254 | + | let total_split_pct: i64 = members.iter().map(|m| m.split_percent as i64).sum(); | |
| 255 | + | let expected_total = (amount * total_split_pct / 100) as i32; | |
| 256 | + | let actual_total: i32 = splits.iter().map(|(_, amt, _)| *amt).sum(); | |
| 257 | + | let mut remainder = expected_total - actual_total; | |
| 258 | + | for split in &mut splits { | |
| 259 | + | if remainder <= 0 { | |
| 260 | + | break; | |
| 261 | + | } | |
| 262 | + | split.1 += 1; | |
| 263 | + | remainder -= 1; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | splits | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// Send sale notification to the seller for a guest purchase. | |
| 270 | + | pub(super) fn send_guest_sale_notification( | |
| 271 | + | state: &AppState, | |
| 272 | + | tx: &db::DbTransaction, | |
| 273 | + | guest_email: &str, | |
| 274 | + | seller_id: db::UserId, | |
| 275 | + | ) { | |
| 276 | + | let db = state.db.clone(); | |
| 277 | + | let email_client = state.email.clone(); | |
| 278 | + | let host_url = state.config.host_url.clone(); | |
| 279 | + | let signing_secret = state.config.signing_secret.clone(); | |
| 280 | + | let amount_cents = tx.amount_cents; | |
| 281 | + | let item_title = tx.item_title.clone(); | |
| 282 | + | let buyer_label = guest_email.to_string(); | |
| 283 | + | ||
| 284 | + | tokio::spawn(async move { | |
| 285 | + | let seller = match db::users::get_user_by_id(&db, seller_id).await.ok().flatten() { | |
| 286 | + | Some(s) if s.notify_sale => s, | |
| 287 | + | _ => return, | |
| 288 | + | }; | |
| 289 | + | ||
| 290 | + | let price = helpers::format_price(amount_cents); | |
| 291 | + | let title = item_title.unwrap_or_else(|| "an item".to_string()); | |
| 292 | + | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 293 | + | &host_url, seller.id, "sale", &seller.id.to_string(), &signing_secret, | |
| 294 | + | ); | |
| 295 | + | if let Err(e) = email_client.send_sale_notification( | |
| 296 | + | &seller.email, seller.display_name.as_deref(), | |
| 297 | + | &buyer_label, &title, &price, Some(&unsub_url), | |
| 298 | + | ).await { | |
| 299 | + | tracing::error!(error = ?e, "failed to send sale notification for guest purchase"); | |
| 300 | + | } | |
| 301 | + | }); | |
| 302 | + | } |
| @@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | mod billing; | |
| 4 | 4 | mod checkout; | |
| 5 | + | mod checkout_helpers; | |
| 5 | 6 | mod subscriptions; | |
| 6 | 7 | ||
| 7 | 8 | use axum::{ |
| @@ -261,7 +261,7 @@ impl TestHarness { | |||
| 261 | 261 | host: "127.0.0.1".parse().unwrap(), | |
| 262 | 262 | port: 0, | |
| 263 | 263 | database_url: String::new(), | |
| 264 | - | host_url: "http://localhost:3000".to_string(), | |
| 264 | + | host_url: std::sync::Arc::from("http://localhost:3000"), | |
| 265 | 265 | signing_secret: "test-signing-secret-for-integration-tests".to_string(), | |
| 266 | 266 | storage: None, | |
| 267 | 267 | synckit_storage: None, |
| @@ -53,7 +53,7 @@ pub async fn run(config: LoadConfig) { | |||
| 53 | 53 | host: "127.0.0.1".parse().unwrap(), | |
| 54 | 54 | port: 0, | |
| 55 | 55 | database_url: String::new(), | |
| 56 | - | host_url: "http://localhost:3000".to_string(), | |
| 56 | + | host_url: std::sync::Arc::from("http://localhost:3000"), | |
| 57 | 57 | signing_secret: "load-test-signing-secret".to_string(), | |
| 58 | 58 | storage: None, | |
| 59 | 59 | synckit_storage: None, |
| @@ -4,6 +4,6 @@ version = "0.1.1" | |||
| 4 | 4 | edition = "2024" | |
| 5 | 5 | ||
| 6 | 6 | [dependencies] | |
| 7 | - | aws-sdk-s3 = "1.119" | |
| 7 | + | aws-sdk-s3 = "1.131" | |
| 8 | 8 | aws-config = { version = "1.8", features = ["behavior-version-latest"] } | |
| 9 | 9 | tracing = "0.1" |