Skip to main content

max / makenotwork

Transactional safety, account deletion, admin CLI, input validation, and tests - Refactor DB functions to accept generic executors for transaction support - Wrap multi-step operations (checkout, webhooks, version creation) in transactions - Fix Stripe Connect race condition with atomic try_set_stripe_account - Prevent users from purchasing their own items - Implement two-step account deletion with confirmation page - Atomic login token consumption replacing get+mark pattern - Block OAuth authorization for 2FA-enabled users - Add input validation for item/chapter/link update endpoints - Expand admin CLI: suspend/unsuspend, appeals, revenue stats, CSV export - Refactor users API from single file to module directory - Add integration tests for chapters, contacts, versions, waitlist - Add creator guide documentation and update roadmap - Remove unused functions, add ON DELETE CASCADE to contact revocations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-09 16:59 UTC
Commit: a58cc29235ee3f6b0b44d37c9fb02c5a4ab80fbd
Parent: adc7f41
56 files changed, +3378 insertions, -1005 deletions
@@ -72,10 +72,12 @@
72 72
73 73 - **Source-available codebase**: PolyForm Noncommercial 1.0.0
74 74 - **Creator waitlist**: Invite-only launch with lottery waves and hand-picked approvals
75 - - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, and stats -- connects directly to the database, no web UI needed
75 + - **Admin CLI** (`mnw-admin`): Command-line tool for waitlist management, creator approval, spam flagging, wave execution, stats, user suspension/unsuspension, appeal processing, revenue reports, transaction history, CSV data export, and S3 storage audits -- connects directly to the database, no web UI needed
76 76 - **Documentation**: Server-rendered from markdown, auto-linked cross-references
77 77 - **Health monitoring**: Real uptime tracking, database status, service connectivity checks
78 - - **386 automated tests**: Unit, integration, workflow, and health tests
78 + - **Malware scanning**: ClamAV + VirusTotal hash lookup on file uploads
79 + - **Creator guide**: 12-page documentation covering the full UX surface area
80 + - **619 automated tests**: Unit, integration, workflow, and health tests
79 81
80 82 ### Developer Infrastructure (SyncKit)
81 83
@@ -98,8 +100,7 @@
98 100 - **Free trial support** for subscription tiers
99 101 - **Sale and follower notifications** (email alerts for creators)
100 102 - **Contacts dashboard** (view fans who shared their email at purchase)
101 - - **Malware scanning** on upload (ClamAV + VirusTotal hash lookup)
102 - - **Admin CLI expansion**: User suspension/unsuspension, appeal processing, broadcast sending, revenue/transaction reports, data export triggers, storage usage audit
103 + - **Admin CLI expansion**: Broadcast sending (deferred until Postmark integration)
103 104
104 105 ---
105 106
@@ -1,6 +1,6 @@
1 1 CREATE TABLE contact_revocations (
2 - buyer_id UUID NOT NULL REFERENCES users(id),
3 - seller_id UUID NOT NULL REFERENCES users(id),
2 + buyer_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3 + seller_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4 4 revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
5 5 PRIMARY KEY (buyer_id, seller_id)
6 6 );
@@ -127,7 +127,10 @@
127 127 // - Stripe checkout is a vanilla form POST that redirects to Stripe's hosted page;
128 128 // SameSite=Strict cookies prevent cross-origin CSRF, AuthUser is required,
129 129 // and no state mutation occurs until Stripe's webhook confirms payment
130 - let exempt_paths = ["/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", "/login", "/join", "/api/sync", "/oauth", "/auth/passkey", "/postmark/webhook", "/unsubscribe"];
130 + // - /confirm-delete uses a signed HMAC link as its authorization; the user
131 + // arrives from an email and may not have an active session, so the
132 + // standard CSRF header cannot be attached to the vanilla form POST.
133 + let exempt_paths = ["/stripe/webhook", "/stripe/checkout", "/stripe/subscribe", "/login", "/join", "/api/sync", "/oauth", "/auth/passkey", "/postmark/webhook", "/unsubscribe", "/confirm-delete"];
131 134
132 135 if exempt_paths.iter().any(|p| path.starts_with(p)) {
133 136 return next.run(request).await;
@@ -35,6 +35,7 @@
35 35 /// Section directories in display order.
36 36 const SECTIONS: &[(&str, &str)] = &[
37 37 ("about", "About"),
38 + ("guide", "Guide"),
38 39 ("legal", "Legal"),
39 40 ("support", "Support"),
40 41 ];
@@ -24,6 +24,8 @@
24 24 pub const BLOG_POST_TITLE_MAX: usize = 200;
25 25 pub const BLOG_POST_SLUG_MAX: usize = 100;
26 26 pub const BLOG_POST_BODY_MAX: usize = 100_000;
27 + pub const CHAPTER_TITLE_MAX: usize = 200;
28 + pub const ITEM_TEXT_BODY_MAX: usize = 500_000;
27 29 pub const KEY_CODE_MAX: usize = 50;
28 30 pub const MACHINE_ID_MAX: usize = 255;
29 31 pub const ACTIVATION_LABEL_MAX: usize = 100;
@@ -130,6 +132,31 @@
130 132 Ok(())
131 133 }
132 134
135 + /// Validate a chapter title
136 + pub fn validate_chapter_title(title: &str) -> Result<(), AppError> {
137 + if title.is_empty() {
138 + return Err(AppError::Validation("Chapter title is required".to_string()));
139 + }
140 + if title.chars().count() > limits::CHAPTER_TITLE_MAX {
141 + return Err(AppError::Validation(format!(
142 + "Chapter title must be {} characters or less",
143 + limits::CHAPTER_TITLE_MAX
144 + )));
145 + }
146 + Ok(())
147 + }
148 +
149 + /// Validate an item text body
150 + pub fn validate_item_text_body(body: &str) -> Result<(), AppError> {
151 + if body.chars().count() > limits::ITEM_TEXT_BODY_MAX {
152 + return Err(AppError::Validation(format!(
153 + "Text body must be {} characters or less",
154 + limits::ITEM_TEXT_BODY_MAX
155 + )));
156 + }
157 + Ok(())
158 + }
159 +
133 160 /// Validate a tag name (for admin tag creation).
134 161 ///
135 162 /// Regular users select tags from the taxonomy via typeahead search,
@@ -471,6 +498,30 @@
471 498 Ok(())
472 499 }
473 500
501 + /// Validate a git repository name: 1-64 chars, ASCII alphanumeric + hyphens/underscores/dots, no leading dot.
502 + pub fn validate_git_repo_name(name: &str) -> Result<(), AppError> {
503 + if name.is_empty() || name.len() > 64 {
504 + return Err(AppError::Validation(
505 + "Git repo name must be 1-64 characters".to_string(),
506 + ));
507 + }
508 + if name.starts_with('.') {
509 + return Err(AppError::Validation(
510 + "Git repo name cannot start with a dot".to_string(),
511 + ));
512 + }
513 + if !name
514 + .chars()
515 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
516 + {
517 + return Err(AppError::Validation(
518 + "Git repo name can only contain letters, numbers, hyphens, underscores, and dots"
519 + .to_string(),
520 + ));
521 + }
522 + Ok(())
523 + }
524 +
474 525 /// Validate price in cents (must be non-negative)
475 526 pub fn validate_price_cents(price: i32) -> Result<(), AppError> {
476 527 if price < 0 {
@@ -760,4 +811,46 @@
760 811 assert!(validate_tier_price(-1).is_err()); // negative
761 812 assert!(validate_tier_price(1_000_001).is_err()); // over cap
762 813 }
814 +
815 + #[test]
816 + fn test_validate_chapter_title() {
817 + assert!(validate_chapter_title("Introduction").is_ok());
818 + assert!(validate_chapter_title("X").is_ok()); // single char
819 + assert!(validate_chapter_title("").is_err()); // empty
820 + assert!(validate_chapter_title(&"a".repeat(200)).is_ok()); // at limit
821 + assert!(validate_chapter_title(&"a".repeat(201)).is_err()); // over limit
822 + }
823 +
824 + #[test]
825 + fn test_validate_item_text_body() {
826 + assert!(validate_item_text_body("Some content").is_ok());
827 + assert!(validate_item_text_body("").is_ok()); // empty is valid
828 + assert!(validate_item_text_body(&"a".repeat(500_000)).is_ok()); // at limit
829 + assert!(validate_item_text_body(&"a".repeat(500_001)).is_err()); // over limit
830 + }
831 +
832 + #[test]
833 + fn test_validate_git_repo_name() {
834 + // Valid names
835 + assert!(validate_git_repo_name("my-repo").is_ok());
836 + assert!(validate_git_repo_name("my_repo").is_ok());
837 + assert!(validate_git_repo_name("MyRepo123").is_ok());
838 + assert!(validate_git_repo_name("repo.name").is_ok());
839 + assert!(validate_git_repo_name("a").is_ok()); // single char
840 + assert!(validate_git_repo_name(&"a".repeat(64)).is_ok()); // at limit
841 +
842 + // Invalid: empty
843 + assert!(validate_git_repo_name("").is_err());
844 + // Invalid: too long
845 + assert!(validate_git_repo_name(&"a".repeat(65)).is_err());
846 + // Invalid: leading dot
847 + assert!(validate_git_repo_name(".hidden").is_err());
848 + // Invalid: spaces
849 + assert!(validate_git_repo_name("my repo").is_err());
850 + // Invalid: slashes
851 + assert!(validate_git_repo_name("foo/bar").is_err());
852 + // Invalid: special chars
853 + assert!(validate_git_repo_name("repo@name").is_err());
854 + assert!(validate_git_repo_name("repo!").is_err());
855 + }
763 856 }