Skip to main content

max / makenotwork

Audit Run 20 remediations and mnw-cli todo update - Bump yara-x 1.14→1.15, aws-sdk-s3 1.119→1.131 - Config.host_url String→Arc<str> (eliminates 30+ clones) - Add #[tracing::instrument] to all embed handlers and payments functions - Split checkout.rs: extract helpers to checkout_helpers.rs - Replace .unwrap() with .expect() in creator_tiers.rs - Update audit_review.md to Run 20 - Mark mnw-cli pre-beta items complete Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-05 20:52 UTC
Commit: ad832b895fa611edb61b22abecc5a7b8b26b6e79
Parent: b01eea8
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
M server/Cargo.lock +135 -92
@@ -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"