max / makenotwork
51 files changed,
+3173 insertions,
-3160 deletions
| @@ -0,0 +1,26 @@ | |||
| 1 | + | # MNW Server | |
| 2 | + | ||
| 3 | + | Rust web server for [makenot.work](https://makenot.work), a fair creator platform with 0% platform fees. | |
| 4 | + | ||
| 5 | + | Built with Axum, SQLx (PostgreSQL), and Askama templates. | |
| 6 | + | ||
| 7 | + | ## Documentation | |
| 8 | + | ||
| 9 | + | - **Architecture:** `docs/architecture.md` | |
| 10 | + | - **Contributing:** `CONTRIBUTING.md` | |
| 11 | + | - **Deployment:** `deploy/deploy.sh` (run from this directory) | |
| 12 | + | - **Schema:** `docs/schema.md` | |
| 13 | + | - **Test plan:** `docs/test_plan.md` | |
| 14 | + | - **Troubleshooting:** `docs/troubleshooting.md` | |
| 15 | + | - **Rollback:** `docs/rollback.md` | |
| 16 | + | - **Audit:** `docs/audit_review.md` | |
| 17 | + | ||
| 18 | + | ## Quick start | |
| 19 | + | ||
| 20 | + | ```sh | |
| 21 | + | cargo run # development server | |
| 22 | + | cargo test # run test suite | |
| 23 | + | cargo clippy # lint | |
| 24 | + | ``` | |
| 25 | + | ||
| 26 | + | See `CONTRIBUTING.md` for coding patterns, macros, and conventions. |
| @@ -1,11 +1,11 @@ | |||
| 1 | 1 | # MakeNotWork -- Audit Review | |
| 2 | 2 | ||
| 3 | - | **Last audited:** 2026-05-09 (Run 22, Ultra Fuzz -- 5-axis deep audit) | |
| 4 | - | **Previous audit:** 2026-05-08 (Run 21, Ultra Fuzz -- 5-axis deep audit) | |
| 3 | + | **Last audited:** 2026-05-09 (Run 24, Ultra Fuzz -- 5-axis deep audit) | |
| 4 | + | **Previous audit:** 2026-05-09 (Run 23, Ultra Fuzz -- 5-axis deep audit) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 8 | - | Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9. ~87,853 LOC. 1,215 test annotations. 105 migrations. 1 SERIOUS finding (UX), 0 CRITICAL. 3 cold spots. All Run 21 action items fixed (12/12). Security posture A+. Observability gaps resolved (embed/, payments/ now instrumented). | |
| 8 | + | Run 24: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9. ~88,082 LOC. ~1,215 test annotations. 107 migrations. 2 SERIOUS + 3 MINOR findings identified, all fixed in same-day remediation (1 false positive). 0 cold spots (all 2 resolved). 5/5 axes at A. All Run 23 fixes verified intact. No regressions. | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| @@ -13,19 +13,19 @@ Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 13 | 13 | |-----------|:-----:|-------| | |
| 14 | 14 | | Code Quality | A | Zero .unwrap() in production paths. Clean macro patterns throughout | | |
| 15 | 15 | | Architecture | A | Clean layer separation. Trait-based backends for storage/email/payments | | |
| 16 | - | | Testing | A | 1,215 test annotations, proptest active, adversarial tests, comprehensive harness | | |
| 17 | - | | Security | A+ | SHA-256-based constant-time compare, fail-closed scanning, CSRF everywhere, Argon2id, HMAC webhooks, PKCE S256 | | |
| 18 | - | | Performance | A | Discover facet queries now parallelized via try_join!. Scheduler lock pinned. No remaining bottlenecks at launch scale | | |
| 19 | - | | Documentation | A- | Module-level //! on all major files. No README.md | | |
| 16 | + | | Testing | A | ~1,215 test annotations, proptest active, adversarial tests, comprehensive harness | | |
| 17 | + | | Security | A | Constant-time compare, fail-closed scanning, CSRF everywhere, Argon2id, HMAC webhooks, PKCE S256. DUMMY_HASH pattern on all login paths | | |
| 18 | + | | Performance | A | Cart and download queries consolidated. Export writes to ZIP one-by-one. Advisory lock pinned | | |
| 19 | + | | Documentation | A | Module-level //! on all major files. README.md present | | |
| 20 | 20 | | Dependencies | A- | 4 transitive advisories (none exploitable). async-trait retained (required for dyn dispatch) | | |
| 21 | - | | Frontend | A | Askama auto-escape, json_escape prevents JSON-LD XSS, HTMX patterns consistent | | |
| 21 | + | | Frontend | A | Askama auto-escape, json_escape prevents JSON-LD XSS, HTMX patterns consistent. CSRF field names consistent | | |
| 22 | 22 | | Type Safety | A+ | 36 UUID newtypes, 25+ domain enums, validated string types, Cents/PriceCents monetary newtypes | | |
| 23 | - | | Observability | A | All route modules now instrumented including embed/ and payments/ (chronic items resolved) | | |
| 24 | - | | Concurrency | A | ON CONFLICT, FOR UPDATE, DashMap caches. Scheduler advisory lock now pinned to connection | | |
| 23 | + | | Observability | A | All route modules instrumented including embed/ and payments/ | | |
| 24 | + | | Concurrency | A | ON CONFLICT, FOR UPDATE, DashMap caches. Scheduler advisory lock pinned. Promo code atomically reserved at checkout | | |
| 25 | 25 | | Resilience | A | Graceful shutdown with 10s deadline, timeouts on all outbound calls, fail-closed scanning | | |
| 26 | 26 | | API Consistency | A | ListResponse wrapper, json_error_layer, versioned SyncKit routes | | |
| 27 | - | | Migration Safety | A | 105 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout | | |
| 28 | - | | Codebase Size | A- | ~88K LOC. Oversized files remain (exports.rs, health.rs, tabs/user.rs) | | |
| 27 | + | | Migration Safety | A | 107 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout | | |
| 28 | + | | Codebase Size | A | All three oversized files split into modules. health/mod.rs at 749 (monolithic probe fn) | | |
| 29 | 29 | ||
| 30 | 30 | ## Module Heatmap | |
| 31 | 31 | ||
| @@ -35,7 +35,7 @@ Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 35 | 35 | | config.rs | A | A | A | A | n/a | A | A | A- | A | | |
| 36 | 36 | | error.rs | A | A | A | A | n/a | A | A | A | A | | |
| 37 | 37 | | auth.rs | A | A | A- | A+ | A | A | A | A | A | | |
| 38 | - | | csrf.rs | A | A | A | A | A- | A | A | A- | A | | |
| 38 | + | | csrf.rs | A+ | A | A | A+ | A- | A | A | A- | A | | |
| 39 | 39 | | constants.rs | A+ | n/a | A | A | n/a | A- | A | n/a | A | | |
| 40 | 40 | | helpers.rs | A | A | A | A | A | A | A- | B+ | A | | |
| 41 | 41 | | rate_limit.rs | A | A | A- | A | A | A | A | A | A | | |
| @@ -45,6 +45,7 @@ Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 45 | 45 | | formatting.rs | A | A | A+ | A | A | A- | A | n/a | A | | |
| 46 | 46 | | rss.rs | A | A | A | A | A | A | A- | n/a | A | | |
| 47 | 47 | | synckit_auth.rs | A | A | A | A | A | A | A | A | A | | |
| 48 | + | | metrics.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 48 | 49 | | db/enums.rs | A | A | A | A | n/a | A | A+ | n/a | A | | |
| 49 | 50 | | db/id_types.rs | A | A | A | n/a | n/a | A | A+ | n/a | A | | |
| 50 | 51 | | db/validated_types.rs | A | A | A+ | A | A | A | A+ | n/a | A | | |
| @@ -54,7 +55,7 @@ Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 54 | 55 | | db/transactions.rs | A | A | B+ | A | A | A | A | A | A | | |
| 55 | 56 | | db/discover.rs | A | A | B | A | A | A | A | n/a | A- | | |
| 56 | 57 | | db/cart.rs | A | A | B | A | A | A | A | n/a | A | | |
| 57 | - | | db/creator_tiers.rs | A | A | A | A | A | A | A | n/a | A | | |
| 58 | + | | db/creator_tiers.rs | A | A+ | A | A | A | A | A | n/a | A | | |
| 58 | 59 | | db/versions.rs | A | A | A | A | A | A | A | n/a | A | | |
| 59 | 60 | | db/builds.rs | A | A+ | A | A | A | A | A | n/a | A | | |
| 60 | 61 | | db/pending_refunds.rs | A | A | B | A | A | A | A | n/a | A | | |
| @@ -62,34 +63,41 @@ Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 62 | 63 | | db/promo_codes.rs | A | A | A | A | A | A | A | n/a | A | | |
| 63 | 64 | | db/tips.rs | A | A | B | A | A | A | A | n/a | A | | |
| 64 | 65 | | db/idempotency.rs | A | A | B- | A | A | A | A | n/a | A | | |
| 66 | + | | db/pending_uploads.rs | B+ | A | n/a | A | A | A | A | n/a | A | | |
| 65 | 67 | | db/models/* | A | A | B+ | A | n/a | A- | A | n/a | A | | |
| 66 | 68 | | types/ | A | A | B | A | n/a | A | A | n/a | A | | |
| 67 | 69 | | scanning/ | A | A+ | A- | A+ | A- | A | A- | A | A | | |
| 70 | + | | scanning/archive.rs | A | A | A- | A- | A- | A | A- | A | A | | |
| 68 | 71 | | payments/ | A | A | A- | A | A- | A | A | A | B+ | | |
| 69 | 72 | | email/ | A | A | A | A | A- | A | A | B+ | A- | | |
| 70 | - | | scheduler/ | A | A | B+ | A | A | A | A | A- | A | | |
| 73 | + | | scheduler/ | A | A+ | B+ | A | A | A | A | A- | A | | |
| 71 | 74 | | scheduler/cleanup.rs | A | A | n/a | A | A | A | A | A- | A | | |
| 72 | 75 | | validation/ | A | A | A+ | A+ | A | B+ | A | n/a | A | | |
| 73 | 76 | | import/ | A | A | A | A | B+ | A- | A | B+ | A | | |
| 74 | 77 | | git/ | A | A | A | A+ | B+ | A- | A | B | A | | |
| 75 | - | | git_ssh.rs | A | A | A | A- | A | A- | A | B+ | A | | |
| 78 | + | | git_ssh.rs | A | A | A | A | A | A- | A | B+ | A | | |
| 76 | 79 | | build_runner.rs | A | A- | B+ | A+ | A- | A- | A | A | A | | |
| 77 | - | | monitor.rs | A | A | A- | A | A | A | A | A | A | | |
| 78 | - | | templates/ | A | A- | n/a | **B+** | A | A- | A | B+ | B+ | | |
| 80 | + | | monitor.rs | A | A | A- | A | A | A | A | A+ | A | | |
| 81 | + | | templates/ | A | A- | n/a | A | A | A- | A | B+ | B+ | | |
| 79 | 82 | | routes/auth.rs | A | A | n/a | A+ | A | A | A | A | A | | |
| 80 | 83 | | routes/oauth.rs | A | A | n/a | A- | A | A | A | A- | A- | | |
| 81 | 84 | | routes/admin/ | A | A | n/a | A | A | A | A | A | A | | |
| 82 | - | | routes/api/ | A | A | n/a | A | A | A | A | A | **B+** | | |
| 83 | - | | routes/api/exports.rs | A- | A- | n/a | A | **B-** | A | A | A | **B** | | |
| 84 | - | | routes/stripe/ | A | A | n/a | A | A- | A | A | A | **B+** | | |
| 85 | + | | routes/api/ | A | A | n/a | A | A | A | A | A | B+ | | |
| 86 | + | | routes/api/guest_checkout.rs | **B** | A | n/a | **B** | A | A | A | A | A | | |
| 87 | + | | routes/api/cart.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 88 | + | | routes/api/exports/ | A- | A- | n/a | A | A- | A | A | A | A- | | |
| 89 | + | | routes/stripe/ | A | A | n/a | A | A- | A | A | A | B+ | | |
| 85 | 90 | | routes/stripe/checkout/ | A | A | n/a | A- | A | A | A | A | A | | |
| 91 | + | | routes/stripe/checkout/cart.rs | A | A | n/a | A- | A | A | A | A | A | | |
| 86 | 92 | | routes/synckit/ | A | A | n/a | A | A- | A | A | A | A | | |
| 87 | 93 | | routes/pages/discover.rs | A | A | n/a | A | A | B+ | A | A | A | | |
| 88 | - | | routes/pages/ (other) | A | A | n/a | A | A | A | A | A- | **B+** | | |
| 94 | + | | routes/pages/ (other) | A | A | n/a | A | A | A | A | A- | B+ | | |
| 89 | 95 | | routes/embed/ | A | A | n/a | A | A | A- | A | A | A | | |
| 90 | 96 | | routes/git/ | A | A | n/a | A | A | A | A | A | A | | |
| 91 | 97 | | routes/storage/ | A | A | n/a | A | A | A | A | A | A | | |
| 98 | + | | routes/storage/versions.rs | **B-** | A- | n/a | A | A | A | A | A | A | | |
| 92 | 99 | | routes/storage/uploads.rs | A | A | n/a | A | A | A | A | A | A | | |
| 100 | + | | routes/storage/downloads.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 93 | 101 | | routes/storage/images.rs | A | A | n/a | A | A | A | A | A | A | | |
| 94 | 102 | | routes/postmark/ | A | A | n/a | A | A | A | n/a | A- | B+ | | |
| 95 | 103 | ||
| @@ -97,197 +105,207 @@ Run 22: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 97 | 105 | ||
| 98 | 106 | ### Cold Spots | |
| 99 | 107 | ||
| 100 | - | 1. **templates/ security (B+):** Three templates (`fan_plus.html`, `project.html`, `sandbox.html`) use `name="csrf_token"` but CSRF middleware extracts `_csrf`. Forms with these fields will fail CSRF validation with 403. | |
| 101 | - | 2. **routes/api/exports.rs performance (B-):** Content export downloads all S3 files into RAM before zipping. 2 GB cap exists but entire payload is buffered in memory. At concurrent exports, memory exhaustion is possible. | |
| 102 | - | 3. **routes/api/exports.rs size (B):** 842 LOC, above 500-line guideline. Chronic from Run 21. | |
| 108 | + | None. All Run 24 cold spots resolved in remediation. | |
| 103 | 109 | ||
| 104 | - | ### Resolved Cold Spots (from Run 21) | |
| 110 | + | ### Resolved Cold Spots (from Run 24) | |
| 105 | 111 | ||
| 106 | - | - ~~routes/pages/discover.rs performance (B) + architecture (B-)~~ -- Facet queries now parallelized with `tokio::try_join!`. | |
| 107 | - | - ~~scheduler/ concurrency (B+)~~ -- Advisory lock now pinned to acquired connection. | |
| 108 | - | - ~~db/cart.rs correctness (B+)~~ -- PWYW minimum now enforced in `effective_price_cents()`. | |
| 109 | - | - ~~routes/embed/ observability (B)~~ -- All embed handlers now instrumented. | |
| 110 | - | - ~~payments/ observability (B+)~~ -- All payment functions now instrumented. | |
| 112 | + | - ~~routes/api/guest_checkout.rs (B)~~ -- Added `starts_at` promo code validation. Matches all authenticated checkout paths. | |
| 113 | + | - ~~routes/storage/versions.rs (B-)~~ -- Now uses atomic `try_replace_storage` for file replacements. No separate decrement/increment. | |
| 114 | + | ||
| 115 | + | ### Resolved Cold Spots (from Run 23) | |
| 116 | + | ||
| 117 | + | - ~~versions.rs transaction safety (B)~~ -- Old S3 key now enqueued for deletion via `pending_s3_deletions`. | |
| 118 | + | - ~~cart.rs performance (B-)~~ -- Consolidated into single-query `toggle_cart_preflight()`. | |
| 119 | + | - ~~downloads.rs performance (B-)~~ -- Consolidated into single-query `check_item_access()`. | |
| 120 | + | - ~~exports.rs performance (B-) + size (B)~~ -- Files written directly to ZIP one-by-one. Module split into `exports/mod.rs` + `exports/content.rs`. | |
| 121 | + | - ~~archive.rs security (B+)~~ -- Decompression fallback uses 10x conservative multiplier. | |
| 122 | + | - ~~checkout/cart.rs correctness (B+)~~ -- Was false positive; atomic `try_increment_use_count` already in place at checkout creation. | |
| 111 | 123 | ||
| 112 | 124 | ## Mandatory Surprises | |
| 113 | 125 | ||
| 114 | - | **Run 22 (5 surprises, one per axis):** | |
| 126 | + | **Run 24 (5 surprises, one per axis):** | |
| 115 | 127 | ||
| 116 | - | 1. **Payments -- Webhook signature verification (unexpectedly good):** `payments/webhooks.rs:186-231` implements constant-time HMAC verification with configurable timestamp tolerance, distinct error messages per failure mode, and comprehensive edge-case tests (stale timestamps, future timestamps, wrong secret). Gold standard implementation. | |
| 128 | + | 1. **Payments -- Claim token architecture for guest purchases (unexpectedly good):** `db/transactions.rs:74-173` creates unattached transactions with unique `claim_token`, sends secure download link, claims idempotently via `ON CONFLICT DO NOTHING`. Cryptographically sound, race-free, elegant. | |
| 117 | 129 | ||
| 118 | - | 2. **Storage -- Cleanup scheduler resource lifecycle (unexpectedly good):** `scheduler/cleanup.rs` enqueues S3 deletions as durable records BEFORE any destructive work (fail-closed), retries with exponential backoff and attempt counting, decouples DB cleanup from S3 cleanup with transaction boundaries. The `pending_s3_deletions` queue survives crashes. Production-grade pattern. | |
| 130 | + | 2. **Storage -- recalculate_all_storage_batch LATERAL join (unexpectedly good):** `db/creator_tiers.rs:458-508` recalculates storage for ALL creators in a single query using LATERAL join instead of N+1 loop. Shows architectural awareness. | |
| 119 | 131 | ||
| 120 | - | 3. **UX Wiring -- Form error recovery (unexpectedly good):** Login and form error patterns preserve user input via browser/HTMX state without re-rendering from server, avoiding the security footgun of echoing user input. Error messages are plain text, not reflected user input. Small UX detail that most platforms get wrong. | |
| 132 | + | 3. **UX Wiring -- CSRF constant-time token comparison (unexpectedly good):** `csrf.rs:61` uses `constant_time_compare()` for CSRF token validation. Most web frameworks skip this detail. Combined with dual-layer extraction (header + body), this is production-hardened CSRF. | |
| 121 | 133 | ||
| 122 | - | 4. **Security -- Defense-in-depth layering (unexpectedly good):** Email tokens use HMAC-SHA256 with constant-time comparison + expiry bounds. Password reset URLs bind to password_hash (changing password invalidates links). Login tokens use double-hash (token in email, hash in DB). Passkey counter prevents cloning. TOTP replay prevention via `last_used_step`. Six independent layers, each independently correct. | |
| 134 | + | 4. **Security -- TOTP replay prevention via explicit time step matching (unexpectedly good):** `routes/api/totp.rs:254-266` stores matched time step in DB, rejects monotonically non-increasing steps. Completely eliminates TOTP replay across skew window. Most platforms get this wrong. | |
| 123 | 135 | ||
| 124 | - | 5. **Performance -- Export buffering (unexpectedly bad):** `routes/api/exports.rs:656-680` downloads all S3 content files into `Vec<u8>` in RAM before building ZIP. With a 2 GB per-export cap, two concurrent exports could spike heap to 4+ GB. The rest of the codebase carefully uses presigned URLs for client-direct downloads, making this anomaly stand out. | |
| 136 | + | 5. **Performance -- Storage quota atomicity with idempotency checks (unexpectedly good):** `routes/storage/uploads.rs:206-219` checks idempotency before touching quotas, uses atomic `try_replace_storage` for file replacements. Sophisticated and rarely seen. | |
| 125 | 137 | ||
| 126 | 138 | ### Previous Surprises | |
| 127 | 139 | ||
| 128 | - | **Run 21:** Pending refund bidirectional matching, claim_pending_build FOR UPDATE SKIP LOCKED, json_escape, SHA-256-based constant_time_compare, scheduler advisory lock unpinned (bad, now fixed). | |
| 140 | + | **Run 23:** Webhook signature gold standard, atomic storage quota enforcement, CSRF dual-layer extraction, archive decompression fallback (bad, fixed), advisory lock protocol. | |
| 129 | 141 | ||
| 130 | - | **Run 20:** Scanning module -- 6-layer anti-malware with actual decompression, VMProtect/UPX detection, ZIP bomb byte counting. | |
| 131 | - | ||
| 132 | - | **Run 19:** Hand-rolled Stripe v2 webhook signature verification with replay protection. | |
| 142 | + | **Run 22:** Webhook signature gold standard, cleanup scheduler durable queue, form error recovery, defense-in-depth 6 layers, export RAM buffering. | |
| 133 | 143 | ||
| 134 | - | **Run 18:** Sandbox tier mismatch bug (SmallFiles vs small_files). Fixed. | |
| 144 | + | **Run 21:** Pending refund bidirectional matching, claim_pending_build FOR UPDATE SKIP LOCKED, json_escape, SHA-256 constant_time_compare, scheduler advisory lock unpinned (bad, fixed). | |
| 135 | 145 | ||
| 136 | - | **Run 17:** TOCTOU-safe slug generation with retry loop + advisory lock pattern for sandbox IP cap. | |
| 146 | + | **Run 20:** Scanning module -- 6-layer anti-malware with decompression, VMProtect/UPX detection, ZIP bomb byte counting. | |
| 137 | 147 | ||
| 138 | - | **Run 15:** Session touch cache -- DashMap with 30s TTL avoids N+1 session queries. | |
| 148 | + | **Run 19:** Hand-rolled Stripe v2 webhook signature verification with replay protection. | |
| 139 | 149 | ||
| 140 | 150 | ## Strengths | |
| 141 | 151 | ||
| 142 | 152 | ### 1. Security-in-depth | |
| 143 | - | Zero SQL injection vectors across 200+ queries. Argon2id (46MiB/2 iterations), SHA-256-based constant-time comparison (length-independent), CSRF synchronizer tokens, session fixation prevention, account lockout with anti-enumeration dummy hashes, PKCE S256 required, rate limiting on all endpoint classes, HMAC-signed URLs, 6-layer malware scanning with fail-closed, ZIP bomb detection, path traversal prevention, shell command validation, TOTP replay prevention via `last_used_step`, passkey counter updates preventing cloning attacks. | |
| 153 | + | Zero SQL injection vectors across 200+ queries. Argon2id (46MiB/2 iterations), SHA-256-based constant-time comparison, CSRF synchronizer tokens (constant-time validated), session fixation prevention, account lockout with anti-enumeration dummy hashes on ALL login paths (web, SyncKit, OAuth), PKCE S256 required, rate limiting on all endpoint classes, HMAC-signed URLs, 6-layer malware scanning with fail-closed, ZIP bomb detection with 10x conservative multiplier, path traversal prevention, shell command validation, TOTP replay prevention via time step tracking, passkey counter updates. | |
| 144 | 154 | ||
| 145 | 155 | ### 2. Type safety discipline | |
| 146 | - | 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. All money math in integer cents (i32/i64), zero floating point in money paths. `SUM(BIGINT)::BIGINT` cast used consistently across all aggregate queries. License keys use CSPRNG (5 random words from 2048-word list, ~55 bits entropy). | |
| 156 | + | 36 UUID newtypes via `define_pg_uuid_id!`, 25+ domain enums via `impl_str_enum!`, validated string types, Cents/PriceCents monetary newtypes with proptest coverage. All money math in integer cents (i32/i64), zero floating point in money paths. `SUM(BIGINT)::BIGINT` cast used consistently. License keys use CSPRNG (~55 bits entropy). FOR UPDATE row locking on license activation prevents TOCTOU. | |
| 147 | 157 | ||
| 148 | 158 | ### 3. Payment robustness | |
| 149 | - | Three-layer webhook idempotency (event dedup table, status-based WHERE clauses, ON CONFLICT). Bidirectional pending refund matching. Atomic promo code reservation with cleanup on abandonment. `FOR UPDATE` row locking on tier deletion, license activation, pending refund claims. Self-purchase blocked across all checkout paths. Cart PWYW minimum now enforced. Tip amounts capped ($1-$10,000). | |
| 159 | + | Three-layer webhook idempotency. Bidirectional pending refund matching. Atomic promo code enforcement at DB level. `FOR UPDATE` row locking on tier deletion, license activation, pending refund claims. Self-purchase blocked across all paths. Cart PWYW minimum enforced. Tip amounts capped. Guest purchase claim tokens are cryptographically sound and idempotent. | |
| 150 | 160 | ||
| 151 | - | ### 4. Operational maturity (NEW) | |
| 152 | - | All Run 21 action items fixed (12/12) in one day. Chronic observability gaps in embed/ and payments/ resolved after two runs. Scheduler advisory lock pinned. Soft-delete purge now handles version S3 keys and storage decrement. Confirm uploads wrapped in transactions. Discover page parallelized. Pending S3 deletions durable queue deployed (migration 105). | |
| 161 | + | ### 4. Operational maturity | |
| 162 | + | All Run 22-23 fixes verified intact. Idempotency key scope widened (migration 106). Pending S3 deletions UNIQUE constraint (migration 107). Scheduler advisory lock pinned. Soft-delete purge handles version S3 keys. Confirm uploads wrapped in transactions. Discover page parallelized. Content exports stream one-by-one. N+1 queries consolidated. | |
| 153 | 163 | ||
| 154 | 164 | ## Weaknesses | |
| 155 | 165 | ||
| 156 | - | ### 1. Export memory buffering (NEW) | |
| 157 | - | Content export downloads all S3 files into RAM (up to 2 GB) before zipping. First real memory exhaustion vector at concurrent usage. Should stream ZIP directly or export to S3 with presigned download URL. | |
| 166 | + | ### Active (Run 24) | |
| 158 | 167 | ||
| 159 | - | ### 2. CSRF token field name mismatch (NEW) | |
| 160 | - | Three templates use `csrf_token` field name but middleware extracts `_csrf`. Affected forms: fan_plus.html, project.html tip form, sandbox.html. These POST forms will fail with 403 if submitted without HTMX (which injects the token via header instead). | |
| 168 | + | - **Guest checkout promo code validation gap** -- Missing `starts_at` check allows future-dated codes to be used early by guest buyers. Easy fix (2 lines). | |
| 169 | + | - **Version replace storage counter corruption** -- Decrement-then-increment is not atomic. If increment fails, old storage is permanently lost from counter. Should use `try_replace_storage` or re-increment on failure. | |
| 161 | 170 | ||
| 162 | - | ### 3. Idempotency key scope too narrow (NEW) | |
| 163 | - | Unique constraint is `(key, user_id)` but should be `(key, user_id, method, path)`. Same key reused across different endpoints returns cached response from the first endpoint. Low probability in practice (clients generate per-request keys) but schema should match intent. | |
| 171 | + | ### Resolved (Run 23) | |
| 164 | 172 | ||
| 165 | - | ### 4. async-trait retained (RESOLVED -- not removable) | |
| 166 | - | 3 trait definitions use `async-trait` for dyn-compatible async dispatch. Rust 2024 native async fn in traits is not dyn-compatible. Closing chronic item. | |
| 173 | + | - ~~Version S3 key leak~~ -- Fixed. Old keys now enqueued via `pending_s3_deletions`. | |
| 174 | + | - ~~Archive decompression fallback~~ -- Fixed. 10x conservative multiplier on decompression error. | |
| 175 | + | - ~~Export memory buffering~~ -- Fixed. Files written to ZIP one-by-one. | |
| 176 | + | - ~~N+1 query patterns~~ -- Fixed. Cart and download access control consolidated. | |
| 167 | 177 | ||
| 168 | 178 | ## Bug Reports by Axis | |
| 169 | 179 | ||
| 170 | 180 | ### Payments | |
| 171 | - | 0 SERIOUS, 0 MINOR, 3 NOTE | |
| 181 | + | 0 CRITICAL, 1 SERIOUS, 0 MINOR, 2 NOTE | |
| 172 | 182 | ||
| 173 | 183 | | # | Sev | Location | Description | | |
| 174 | 184 | |---|-----|----------|-------------| | |
| 175 | - | | P1 | NOTE | `pricing.rs:134-148` | FixedPricing has no upper cap on amount. Not exploitable -- fixed-price path uses `item.price_cents`, not user input. | | |
| 176 | - | | P2 | NOTE | `pricing.rs:113-115` | FixedPricing `is_free()` hardcoded false. Unreachable -- `for_item()` routes price=0 to FreePricing. | | |
| 177 | - | | P3 | NOTE | `db/subscriptions.rs:399-407` | `has_active_subscription_to_project` doesn't check `cancel_at_period_end`. By design (access until period end). | | |
| 178 | - | ||
| 179 | - | Previous SERIOUS findings (P1 cart PWYW bypass from Run 21) -- **FIXED**. | |
| 185 | + | | P1 | **SERIOUS** | `routes/api/guest_checkout.rs:91-140` | Missing `starts_at` promo code validation. All 3 authenticated paths (cart.rs:150, item.rs:127, subscriptions.rs:192) check it. | | |
| 186 | + | | P2 | NOTE | `db/license_keys.rs:158-233` | Activation limit enforced with FOR UPDATE + active count + idempotent upsert. Excellent. | | |
| 187 | + | | P3 | NOTE | `payments/webhooks.rs:48-61` | Webhook deduplication correctly prevents double-credit. Solid. | | |
| 180 | 188 | ||
| 181 | 189 | ### Storage | |
| 182 | - | 0 SERIOUS, 1 MINOR, 1 NOTE | |
| 190 | + | 0 CRITICAL, 1 SERIOUS, 1 MINOR, 1 NOTE | |
| 183 | 191 | ||
| 184 | 192 | | # | Sev | Location | Description | | |
| 185 | 193 | |---|-----|----------|-------------| | |
| 186 | - | | S1 | MINOR | `db/pending_s3_deletions.rs` | No UNIQUE constraint on `(s3_key, bucket)` in pending_s3_deletions table. `ON CONFLICT DO NOTHING` is idempotent but schema should be explicit. | | |
| 187 | - | | S2 | NOTE | `storage.rs:446-465` | `extract_s3_key_from_url` includes bucket name for path-style URLs. Only used for project images which handle it. | | |
| 188 | - | ||
| 189 | - | Previous SERIOUS findings (S1 soft-delete purge, S2 non-atomic confirm from Run 21) -- **FIXED**. | |
| 194 | + | | S1 | **SERIOUS** | `routes/storage/versions.rs:175-184` | Storage counter corruption: decrement at 175, try_increment at 182. If increment fails, decrement is not rolled back. | | |
| 195 | + | | S2 | MINOR | `db/pending_uploads.rs:17` | ON CONFLICT DO UPDATE refreshes created_at, letting repeated presign calls prevent garbage collection. | | |
| 196 | + | | S3 | NOTE | `routes/storage/uploads.rs:262` | Storage decrement rollback error swallowed with `.ok()`. Acceptable given background reconciliation. | | |
| 190 | 197 | ||
| 191 | 198 | ### UX Wiring | |
| 192 | - | 1 SERIOUS, 0 MINOR, 3 NOTE | |
| 199 | + | 0 SERIOUS, 1 MINOR, 3 NOTE | |
| 193 | 200 | ||
| 194 | 201 | | # | Sev | Location | Description | | |
| 195 | 202 | |---|-----|----------|-------------| | |
| 196 | - | | U1 | **SERIOUS** | `templates/pages/fan_plus.html:97`, `templates/pages/project.html:209`, `templates/pages/sandbox.html:24` | CSRF field name mismatch: templates use `name="csrf_token"` but `csrf.rs:81` extracts `_csrf`. Forms submitted without HTMX JS will fail with 403. HTMX path works (header injection). | | |
| 197 | - | | U2 | NOTE | `templates/pages/login.html:13-16` | Login form lacks CSRF. Intentional (pre-auth exempt). | | |
| 198 | - | | U3 | NOTE | `templates/public.rs:471-482` | BuyPageTemplate lacks csrf_token field entirely. Intentional (guest checkout). | | |
| 199 | - | | U4 | NOTE | `formatting.rs:4-13` | `format_price` uses f64 division. Correct for all practical prices with `{:.2}`. | | |
| 203 | + | | U1 | MINOR | `routes/pages/dashboard/forms.rs` | Blog editor doesn't preserve user input on slug validation error. | | |
| 204 | + | | U2 | NOTE | `routes/pages/public/discover.rs:85` | Page clamped to `.max(1)` but no upper bound. DB returns empty; no impact. | | |
| 205 | + | | U3 | NOTE | `templates/wizards/wizard_join.html` | Join wizard CSRF exemption justified (pre-auth) and documented. | | |
| 206 | + | | U4 | NOTE | `templates/dashboards/dashboard-blog-editor.html:42` | hx-vals with UUID template variable. Safe (UUIDs contain no special chars). | | |
| 200 | 207 | ||
| 201 | 208 | ### Security | |
| 202 | - | 0 SERIOUS, 1 MINOR, 2 NOTE | |
| 209 | + | 0 CRITICAL, 0 SERIOUS, 0 MINOR, 3 NOTE | |
| 203 | 210 | ||
| 204 | 211 | | # | Sev | Location | Description | | |
| 205 | 212 | |---|-----|----------|-------------| | |
| 206 | - | | X1 | MINOR | `db/idempotency.rs:50-52` | Unique constraint on `(key, user_id)` but queries filter by `(key, user_id, method, path)`. Same key on different endpoints returns wrong cached response. | | |
| 207 | - | | X2 | NOTE | `scanning/mod.rs:279` | Pipeline integration test only exercises 3 of 7 FileType variants. | | |
| 208 | - | | X3 | NOTE | `scanning/hash_lookup.rs:84` | MalwareBazaar `no_results` treated as Error (held for review). Deliberate fail-closed. | | |
| 213 | + | | X1 | NOTE | `auth.rs:302-309` | verify_password is not independently constant-time on parse failure, but all callers use DUMMY_HASH. No exploitable timing channel. | | |
| 214 | + | | X2 | NOTE | `db/totp.rs:126-146` | Backup code DB lookup not constant-time. Standard practice; not exploitable over network. | | |
| 215 | + | | X3 | NOTE | `synckit_auth.rs:54-68` | JWT iat claim not validated. Standard; exp is validated. No impact. | | |
| 209 | 216 | ||
| 210 | 217 | ### Performance | |
| 211 | - | 0 SERIOUS, 2 MINOR, 2 NOTE | |
| 218 | + | 0 CRITICAL, 0 SERIOUS, 2 MINOR, 1 NOTE | |
| 212 | 219 | ||
| 213 | 220 | | # | Sev | Location | Description | | |
| 214 | 221 | |---|-----|----------|-------------| | |
| 215 | - | | F1 | MINOR | `routes/api/exports.rs:656-680` | Content export downloads all S3 files into RAM before zipping. 2 GB cap but full heap buffering. Two concurrent exports = 4 GB spike. | | |
| 216 | - | | F2 | MINOR | `metrics.rs:206` | Idempotency middleware buffers full response body (up to 1MB). | | |
| 217 | - | | F3 | NOTE | `routes/synckit/subscribe.rs:123` | SSE broadcast channel size 16. Lag handled by filtering. By design. | | |
| 218 | - | | F4 | NOTE | `routes/synckit/sync.rs:98-100` | Push/pull fetch all devices for validation. Bounded at 50 per app. | | |
| 222 | + | | F1 | MINOR | `metrics.rs:232` | Idempotency middleware: `.to_vec()` creates second allocation from Bytes. Minor efficiency loss. | | |
| 223 | + | | F2 | MINOR | `metrics.rs:228` | Response body >1MB returns Body::empty(). Only affects chunked responses without Content-Length (rare). | | |
| 224 | + | | F3 | NOTE | `routes/storage/uploads.rs:250-253` | Dynamic SQL column names via format!(). Hardcoded enum match arms -- not injection risk, but bypasses compile-time checking. | | |
| 219 | 225 | ||
| 220 | 226 | ## Cross-Cutting Concerns | |
| 221 | 227 | ||
| 222 | - | ### CSRF field name inconsistency (UX + Security) | |
| 223 | - | Three templates use `csrf_token` instead of `_csrf`. While HTMX header injection means this doesn't block normal usage, it represents a defense-in-depth gap. If JS fails to load, these forms become non-functional. Single fix: standardize field names. | |
| 228 | + | ### Promo code validation inconsistency (Payments + UX) | |
| 229 | + | The `starts_at` check is present in 4 checkout paths but missing from guest checkout. Pattern gap -- guest checkout was likely added later without copying all validations. A shared `validate_promo_code()` helper would prevent future divergence. | |
| 224 | 230 | ||
| 225 | - | ### Export memory pattern (Performance + Storage) | |
| 226 | - | Export buffering is the sole remaining pattern where large data is loaded entirely into RAM. The rest of the codebase consistently uses streaming (presigned URLs, chunked S3 uploads, scan pipeline). Fixing this would complete the streaming pattern across the entire codebase. | |
| 231 | + | ### Storage counter atomicity (Storage + Performance) | |
| 232 | + | The decrement-then-increment pattern in `versions.rs` is inconsistent with the atomic `try_replace_storage` used in `uploads.rs`. The versions path should use the same atomic pattern. | |
| 227 | 233 | ||
| 228 | 234 | ## Components Successfully Stress-Tested | |
| 229 | 235 | ||
| 230 | 236 | ### Payments (12 vectors survived) | |
| 231 | - | Webhook replay, double-credit, concurrent promo exhaustion, cross-user data access, self-purchase, negative/overflow amounts, floating-point money math, out-of-order webhooks, suspended creator purchases, SUM(BIGINT) pitfall, PWYW cart minimum enforcement, tip amount cap enforcement. | |
| 237 | + | Webhook replay, double-credit, concurrent promo exhaustion (atomic at DB level), cross-user data access, self-purchase, negative/overflow amounts, floating-point money math, out-of-order webhooks, suspended creator purchases, SUM(BIGINT) pitfall, PWYW cart minimum enforcement, tip amount cap enforcement. | |
| 232 | 238 | ||
| 233 | 239 | ### Storage (10 vectors survived) | |
| 234 | - | Cross-user file overwrites, path traversal in filenames, content type smuggling, storage quota bypass, double-spend on idempotent confirm, malware file serving, orphaned upload cleanup, SUM(BIGINT) pitfall, transactional confirm uploads, soft-delete purge with version S3 keys. | |
| 240 | + | Cross-user file overwrites, path traversal in filenames, content type smuggling, storage quota bypass (atomic enforcement), double-spend on idempotent confirm, malware file serving, orphaned upload cleanup, SUM(BIGINT) pitfall, transactional confirm uploads, soft-delete purge with version S3 keys. | |
| 235 | 241 | ||
| 236 | 242 | ### UX Wiring (10 vectors survived) | |
| 237 | 243 | XSS via template injection, open redirect, user enumeration, markdown/HTML injection, pagination abuse, integer overflow in pricing, internal detail leakage, CSV injection, Unicode boundary attacks, JSON-LD breakout. | |
| 238 | 244 | ||
| 239 | 245 | ### Security (17 vectors survived) | |
| 240 | - | Virus scan bypass via ClamAV downtime, ZIP bomb, content-type spoofing, path traversal in archives, session fixation, timing-based user enumeration, brute force login, X-Forwarded-For spoofing, session reuse after password change, OAuth code replay, PKCE downgrade, token prediction, CSRF on state-changing endpoints, SSH command injection, passkey cloning, TOTP replay, IDOR on passkeys/sessions. | |
| 246 | + | Virus scan bypass via ClamAV downtime (fail-closed), content-type spoofing, path traversal in archives, session fixation, timing-based user enumeration (dummy hash on all 3 login paths), brute force login (lockout), X-Forwarded-For spoofing (Cloudflare-aware), session reuse after password change, OAuth code replay, PKCE downgrade, token prediction (CSPRNG), CSRF on state-changing endpoints (constant-time), SSH command injection, passkey cloning (counter), TOTP replay (last_used_step), IDOR on passkeys/sessions, archive bombs (byte counting + 10x multiplier). | |
| 241 | 247 | ||
| 242 | 248 | ### Performance (10 vectors survived) | |
| 243 | - | Connection pool exhaustion, file scanning memory, SSE connection accumulation, scheduler job accumulation, background task leaks, ZIP bombs, path traversal, shell injection, lock ordering, discover page concurrent load. | |
| 249 | + | Connection pool exhaustion (bounded at 25), file scanning memory (semaphore of 4), SSE connection accumulation (bounded), scheduler job accumulation (advisory lock), background task leaks (monitored), ZIP bombs (byte counting), bulk operations (batch queries, 100-item cap), rate limiter bypass (Cloudflare-aware IP), concurrent scan memory pressure, large file handling (streamed). | |
| 244 | 250 | ||
| 245 | 251 | ## Confidence Assessment | |
| 246 | 252 | ||
| 247 | 253 | | Axis | Confidence | Notes | | |
| 248 | 254 | |------|-----------|-------| | |
| 249 | - | | Payments | HIGH | Three-layer idempotency, integer money math, bidirectional refund matching. Cart PWYW now enforced. Tip cap enforced. CSPRNG license keys. | | |
| 250 | - | | Storage | HIGH | Presigned URL security solid. Confirms now transactional. Soft-delete purge handles version keys. Pending S3 deletions durable queue deployed. | | |
| 251 | - | | UX Wiring | HIGH | Askama auto-escape, json_escape defense-in-depth, comprehensive CSRF (one field name bug). No detail leakage. | | |
| 252 | - | | Security | HIGH | No CRITICAL or SERIOUS findings. Argon2id, constant-time everywhere, fail-closed scanning, PKCE S256. Idempotency key scope is low-risk. | | |
| 253 | - | | Performance | HIGH (current scale) | Discover parallelized. Scheduler lock pinned. Export buffering is sole remaining concern, low-frequency endpoint. | | |
| 255 | + | | Payments | HIGH | One validation gap in guest checkout. Core payment logic excellent. | | |
| 256 | + | | Storage | HIGH | One error-path storage corruption. Normal path is atomic and correct. | | |
| 257 | + | | UX Wiring | HIGH | CSRF excellent. Templates safe. Minor UX polish only. | | |
| 258 | + | | Security | HIGH | No auth bypasses. DUMMY_HASH on all login paths. Fail-closed scanning. | | |
| 259 | + | | Performance | HIGH (current scale) | Pool size reasonable. Rate limiters well-tuned. Scheduler advisory-locked. | | |
| 254 | 260 | ||
| 255 | 261 | ## Metrics | |
| 256 | 262 | ||
| 257 | 263 | - Modules audited: 55+ | |
| 258 | - | - Total cold spots: 3 | |
| 259 | - | - Bugs by severity: 0 critical, 1 serious, 4 minor, 10 note | |
| 264 | + | - Total cold spots: 0 (all 2 resolved in remediation) | |
| 265 | + | - Bugs by severity: 0 critical, 2 serious (all fixed), 3 minor (all fixed, 1 false positive), 10 note | |
| 260 | 266 | - Axes at A or above: 5/5 | |
| 261 | 267 | ||
| 262 | 268 | ## Axis Summary Grades | |
| 263 | 269 | ||
| 264 | 270 | | Axis | Overall | Cold Spots | Mandatory Surprise | | |
| 265 | 271 | |------|---------|------------|-------------------| | |
| 266 | - | | Payments | A | None | Webhook signature verification gold standard | | |
| 267 | - | | Storage | A | None | Cleanup scheduler durable S3 deletion queue | | |
| 268 | - | | UX Wiring | A- | templates/ CSRF field name (B+) | Form error recovery preserves input without reflecting | | |
| 269 | - | | Security | A | None | Defense-in-depth: 6 independent auth layers, each correct | | |
| 270 | - | | Performance | A- | exports.rs perf (B-), size (B) | Export RAM buffering anomaly vs streaming everywhere else | | |
| 272 | + | | Payments | A | guest_checkout.rs (B) | Claim token architecture (good) | | |
| 273 | + | | Storage | A | versions.rs (B-) | LATERAL join batch storage recalc (good) | | |
| 274 | + | | UX Wiring | A | None | CSRF constant-time comparison (good) | | |
| 275 | + | | Security | A | None | TOTP replay prevention via time step (good) | | |
| 276 | + | | Performance | A | None | Storage quota atomicity + idempotency (good) | | |
| 271 | 277 | ||
| 272 | 278 | ## Recommended Priority Order | |
| 273 | 279 | ||
| 274 | - | 1. **[SERIOUS] Fix CSRF field name mismatch** (`templates/pages/fan_plus.html:97`, `project.html:209`, `sandbox.html:24`) -- Change `name="csrf_token"` to `name="_csrf"` in all three templates. 5-minute fix, restores defense-in-depth for non-JS form submissions. | |
| 275 | - | 2. **[MINOR] Fix idempotency key unique constraint** (`db/idempotency.rs`) -- Add migration to change unique constraint to `(key, user_id, method, path)`. Prevents wrong cached response on key reuse across endpoints. | |
| 276 | - | 3. **[MINOR] Stream content exports** (`routes/api/exports.rs:656-680`) -- Replace in-memory buffering with streaming ZIP writer or export-to-S3-then-presign. Eliminates last RAM exhaustion vector. | |
| 277 | - | 4. **[MINOR] Add UNIQUE on pending_s3_deletions** -- `CREATE UNIQUE INDEX idx_pending_s3_deletions_key_bucket ON pending_s3_deletions(s3_key, bucket)`. Schema hygiene. | |
| 278 | - | 5. **[DEFERRED] Split oversized route files** -- exports.rs (842), health.rs (846), tabs/user.rs (815). Chronic from Run 21. | |
| 279 | - | 6. **[DEFERRED] Add README.md to server/** -- Chronic from Run 19. | |
| 280 | + | 1. **[SERIOUS]** Add `starts_at` validation to guest checkout -- 2 lines, copy from `cart.rs:150` | |
| 281 | + | 2. **[SERIOUS]** Fix version replace storage rollback -- re-increment old storage on `try_increment` failure, or use `try_replace_storage` | |
| 282 | + | 3. **[MINOR]** Change pending_uploads to `ON CONFLICT DO NOTHING` -- prevents staleness attack | |
| 283 | + | 4. **[MINOR]** Blog editor: preserve input on validation error | |
| 284 | + | 5. **[MINOR]** Idempotency middleware: use `std::str::from_utf8` instead of `String::from_utf8(to_vec())` | |
| 285 | + | 6. **[DEFERRED]** Extract shared `validate_promo_code()` helper | |
| 280 | 286 | ||
| 281 | 287 | ## Action Items | |
| 282 | 288 | ||
| 283 | - | ### Run 22 (2026-05-09) | |
| 289 | + | ### Run 24 (2026-05-09) | |
| 290 | + | ||
| 291 | + | 97. ~~**[SERIOUS]** Add `starts_at` validation to guest checkout~~ -- **Fixed.** Added `starts_at` check to `guest_checkout.rs:102-107`, matching all authenticated paths. | |
| 292 | + | 98. ~~**[SERIOUS]** Fix version replace storage counter rollback~~ -- **Fixed.** `versions.rs` now uses atomic `try_replace_storage` for replacements, `try_increment_storage` for fresh uploads. No separate decrement/increment. | |
| 293 | + | 99. ~~**[MINOR]** Change pending_uploads ON CONFLICT to DO NOTHING~~ -- **Fixed.** `pending_uploads.rs:17` now uses `DO NOTHING`, preventing staleness attack via repeated presign. | |
| 294 | + | 100. ~~**[MINOR]** Blog editor: preserve form input on validation error~~ -- **False positive.** Blog editor uses JS `fetch()` API; form state is preserved on error. | |
| 295 | + | 101. ~~**[MINOR]** Idempotency middleware: avoid double allocation~~ -- **Fixed.** `metrics.rs:232` now uses `std::str::from_utf8` with `to_owned()` only for the spawned task. | |
| 296 | + | 102. **[DEFERRED]** Extract shared `validate_promo_code()` helper to prevent checkout path divergence. | |
| 284 | 297 | ||
| 285 | - | 82. **[SERIOUS]** Fix CSRF field name: change `csrf_token` to `_csrf` in `fan_plus.html`, `project.html`, `sandbox.html` | |
| 286 | - | 83. **[MINOR]** Add migration: unique constraint on `idempotency_keys(key, user_id, method, path)` replacing `(key, user_id)` | |
| 287 | - | 84. **[MINOR]** Stream content exports or export to S3 with presigned download (`routes/api/exports.rs:656-680`) | |
| 288 | - | 85. **[MINOR]** Add `UNIQUE(s3_key, bucket)` to `pending_s3_deletions` table (migration) | |
| 289 | - | 86. **[DEFERRED]** Split oversized route files: health.rs (846), exports.rs (842), tabs/user.rs (815) (chronic, from Run 21 #81) | |
| 290 | - | 87. **[DEFERRED]** Add README.md to server/ (chronic, from Run 19 #53 -> #63 -> #80) | |
| 298 | + | ### Run 23 (2026-05-09) -- All Fixed | |
| 299 | + | ||
| 300 | + | 88. ~~**[SERIOUS]** Enqueue old S3 key on version file replacement~~ -- **Fixed.** | |
| 301 | + | 89. ~~**[SERIOUS]** Harden archive decompression fallback~~ -- **Fixed.** | |
| 302 | + | 90. ~~**[SERIOUS]** Reserve promo code slot atomically at checkout creation~~ -- **Already implemented** (false positive). | |
| 303 | + | 91. ~~**[MINOR]** Consolidate cart toggle into single JOIN query~~ -- **Fixed.** | |
| 304 | + | 92. ~~**[MINOR]** Consolidate download access control into single query~~ -- **Fixed.** | |
| 305 | + | 93. ~~**[MINOR]** Stream content exports~~ -- **Fixed.** | |
| 306 | + | 94. ~~**[MINOR]** Tighten CSV email validation~~ -- **Fixed.** | |
| 307 | + | 95. ~~**[DEFERRED]** Split oversized route files~~ -- **Fixed.** | |
| 308 | + | 96. ~~**[DEFERRED]** Add README.md to server/~~ -- **Fixed.** | |
| 291 | 309 | ||
| 292 | 310 | ### Open (blocked on upstream) | |
| 293 | 311 | ||
| @@ -296,61 +314,56 @@ Connection pool exhaustion, file scanning memory, SSE connection accumulation, s | |||
| 296 | 314 | 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049) | |
| 297 | 315 | 33. bincode unmaintained (RUSTSEC-2025-0141) -- upstream via syntect/yara-x, warning only | |
| 298 | 316 | ||
| 299 | - | ## Previous Action Item Verification (Run 21) | |
| 317 | + | ## Previous Action Item Verification (Run 23) | |
| 300 | 318 | ||
| 301 | 319 | | # | Item | Status | | |
| 302 | 320 | |---|------|--------| | |
| 303 | - | | 65 | Pin scheduler advisory lock to acquired connection | **Fixed** | | |
| 304 | - | | 66 | Enforce PWYW minimum in cart effective_price_cents() | **Fixed** | | |
| 305 | - | | 67 | Fix soft-delete purge: version S3 keys + storage decrement | **Fixed** | | |
| 306 | - | | 68 | Wrap confirm_upload DB writes in transaction | **Fixed** | | |
| 307 | - | | 69 | Optimize discover page: parallelize facet queries | **Fixed** | | |
| 308 | - | | 70 | Delete old S3 objects on image/audio/video replacement | **Fixed** | | |
| 309 | - | | 71 | Validate checkout cancel item_id as UUID | **Fixed** | | |
| 310 | - | | 72 | ClamAV contains("FOUND") -> ends_with("FOUND") | **Fixed** | | |
| 311 | - | | 73 | Case-normalize URL-encoded path traversal check | **Fixed** | | |
| 312 | - | | 74 | Add UNIQUE(s3_key) to pending_uploads | **Fixed** | | |
| 313 | - | | 75 | Add #[instrument] to embed/ handlers | **Fixed** | | |
| 314 | - | | 76 | Add #[instrument] to payments/ functions | **Fixed** | | |
| 315 | - | | 77 | Initialize PWYW amount_cents hidden field server-side | Not verified (low priority) | | |
| 316 | - | | 78 | Use AuthUser for OAuth authorize | Not verified (low priority) | | |
| 317 | - | | 80 | Add README.md to server/ | **Unfixed** (chronic, carried as #87) | | |
| 318 | - | | 81 | Split oversized route files | **Unfixed** (carried as #86) | | |
| 319 | - | ||
| 320 | - | 12 of 14 actionable Run 21 items fixed. 2 deferred items carried forward. No regressions. | |
| 321 | + | | 88 | Enqueue old S3 key on version file replacement | **Fixed** (verified: versions.rs:203-204 enqueues old key) | | |
| 322 | + | | 89 | Harden archive decompression fallback | **Fixed** (verified: archive.rs:123 uses 10x multiplier) | | |
| 323 | + | | 90 | Reserve promo code slot atomically | **Already implemented** (false positive) | | |
| 324 | + | | 91 | Consolidate cart toggle | **Fixed** | | |
| 325 | + | | 92 | Consolidate download access control | **Fixed** | | |
| 326 | + | | 93 | Stream content exports | **Fixed** | | |
| 327 | + | | 94 | Tighten CSV email validation | **Fixed** | | |
| 328 | + | | 95 | Split oversized route files | **Fixed** | | |
| 329 | + | | 96 | Add README.md | **Fixed** | | |
| 330 | + | ||
| 331 | + | 9 of 9 Run 23 items verified fixed. | |
| 321 | 332 | ||
| 322 | 333 | ### Chronic Items (unfixed across 3+ consecutive runs) | |
| 323 | 334 | ||
| 324 | - | | Item | First flagged | Runs unfixed | | |
| 325 | - | |------|--------------|-------------| | |
| 326 | - | | Add README.md to server/ | Run 19 | 4 (19, 20, 21, 22) | | |
| 327 | - | ||
| 328 | - | Note: embed/ and payments/ observability gaps resolved in Run 22 after 2 runs (20, 21). async-trait closed as not removable. | |
| 329 | - | ||
| 330 | - | ## Delta Since Run 21 | |
| 331 | - | ||
| 332 | - | ### Fixed | |
| 333 | - | - All 5 SERIOUS Run 21 findings fixed (scheduler lock, cart PWYW, soft-delete purge, confirm transaction, discover queries) | |
| 334 | - | - All 7 MINOR Run 21 findings fixed (old S3 cleanup, cancel UUID, ClamAV FOUND, path traversal case, pending_uploads UNIQUE, embed instrumentation, payments instrumentation) | |
| 335 | - | - embed/ and payments/ now have full `#[instrument]` coverage -- resolves chronic observability gap | |
| 336 | - | - 4 new migrations (102-105): pending_uploads unique, unique_plays, nullable_item_id_indexes, pending_s3_deletions | |
| 337 | - | - Version bumped from 0.5.7 to 0.5.9 | |
| 338 | - | - LOC grew from ~87,427 to ~87,853 (+426) | |
| 339 | - | - Test annotations from 1,218 to 1,215 (-3, likely test consolidation) | |
| 340 | - | ||
| 341 | - | ### New Findings (not in Run 21) | |
| 342 | - | - CSRF field name mismatch in 3 templates (SERIOUS) | |
| 343 | - | - Idempotency key unique constraint too narrow (MINOR) | |
| 344 | - | - Content export RAM buffering (MINOR) | |
| 345 | - | - Pending S3 deletions missing UNIQUE constraint (MINOR) | |
| 346 | - | ||
| 347 | - | ### Grade Changes | |
| 348 | - | - Performance: A- -> A (discover page fixed) | |
| 349 | - | - Concurrency: A- -> A (scheduler lock fixed) | |
| 350 | - | - Observability: A- -> A (embed/ + payments/ instrumented) | |
| 351 | - | - Overall: A (held) | |
| 352 | - | - Cold spots: 5 -> 3 (net -2) | |
| 353 | - | - SERIOUS findings: 5 -> 1 (net -4) | |
| 335 | + | None. Both previous chronic items (README.md, oversized route files) resolved in Run 23. | |
| 336 | + | ||
| 337 | + | ### False Positives Identified (Run 24) | |
| 338 | + | ||
| 339 | + | The following agent findings were investigated and determined not to be real bugs: | |
| 340 | + | - **verify_password timing attack** -- Mitigated by DUMMY_HASH in all callers (routes/auth.rs:111, routes/synckit/auth.rs:59+69, routes/oauth.rs:294). Hash always comes from DB or dummy. | |
| 341 | + | - **Backup code SQL WHERE timing** -- Standard database practice. ~1ms query time variance is indistinguishable from network jitter over TCP. | |
| 342 | + | - **SQL injection via format!** -- Column names are hardcoded string literals from exhaustive enum match. No user input. | |
| 343 | + | - **Multi-seller Stripe isolation** -- Requires API key compromise. Cart scoped to single seller at application level. | |
| 344 | + | - **JWT iat validation** -- Standard JWT behavior. exp is validated. No security impact. | |
| 345 | + | - **Password length not centralized** -- Present at all entry points (routes/auth.rs, routes/synckit/auth.rs:58, routes/oauth.rs). Argon2id has internal limits. | |
| 346 | + | ||
| 347 | + | ## Delta Since Run 23 | |
| 348 | + | ||
| 349 | + | ### Fixed (from Run 23) | |
| 350 | + | All 9 Run 23 action items verified fixed. No regressions detected. |
Lines truncated
| @@ -10,7 +10,7 @@ Three documentation levels for every feature. Source: codebase harness DB (727 m | |||
| 10 | 10 | ||
| 11 | 11 | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 12 | 12 | |---------|-------------|:---:|:---:|:---:| | |
| 13 | - | | **Auth (login/signup/sessions)** | routes/auth.rs, auth.rs, db/sessions.rs, db/auth.rs | Y | -- | guide/01-getting-started | | |
| 13 | + | | **Auth (login/signup/sessions)** | routes/auth.rs, auth.rs, db/sessions.rs, db/auth.rs | Y | -- | guide/getting-started | | |
| 14 | 14 | | **Passkeys (WebAuthn)** | routes/api/passkeys.rs, db/passkeys.rs | Y | -- | guide/security | | |
| 15 | 15 | | **TOTP (2FA)** | routes/api/totp.rs, db/totp.rs | Y | -- | guide/security | | |
| 16 | 16 | | **User profiles** | routes/api/users/profile.rs, db/users.rs | Y | -- | guide/profile | |
| @@ -1,12 +1,24 @@ | |||
| 1 | 1 | # Makenotwork TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | v0.5.9 deployed 2026-05-09. Audit grade A (Run 22). ~88K LOC, 1,215 tests, 0 warnings. Migration 105. Sprints 1-9 complete (see `todo_done.md`). | |
| 4 | + | v0.5.9 deployed 2026-05-09. Audit grade A (Run 24). ~88K LOC, 1,933 tests, 0 warnings. Migration 107. Sprints 1-9 complete (see `todo_done.md`). | |
| 5 | 5 | ||
| 6 | 6 | Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |
| 7 | 7 | ||
| 8 | 8 | --- | |
| 9 | 9 | ||
| 10 | + | ## Next Steps (Soft Launch) | |
| 11 | + | ||
| 12 | + | Priority order. See `human_todo.md` for the full manual testing feature map. | |
| 13 | + | ||
| 14 | + | 1. **Deploy** — Run 24 fixes (4 items) are ready to ship. Build, bump version, deploy. | |
| 15 | + | 2. **Manual testing** — walk through `human_todo.md` sign-off table on live server (Stripe checkout, license keys, promo codes, cart, SyncKit sync) | |
| 16 | + | 3. **Content seeding** — at least one real creator with published content on discover page | |
| 17 | + | 4. **Invite testers** — generate invite codes, send hand-written emails per `docs/internal/outreach/tiers.md` | |
| 18 | + | 5. **Document undocumented features** — shopping cart, wishlist, creator pause (see below) | |
| 19 | + | ||
| 20 | + | --- | |
| 21 | + | ||
| 10 | 22 | ## Documentation: Undocumented Shipped Features | |
| 11 | 23 | ||
| 12 | 24 | These features are implemented but have no public-facing documentation yet. Add docs in the next documentation push. | |
| @@ -26,187 +38,43 @@ These features are implemented but have no public-facing documentation yet. Add | |||
| 26 | 38 | ||
| 27 | 39 | --- | |
| 28 | 40 | ||
| 29 | - | ## Ultra Fuzz Run 22 (2026-05-09) | |
| 30 | - | ||
| 31 | - | ### SERIOUS (fix before launch) | |
| 32 | - | - [x] Fix CSRF field name: change `csrf_token` to `_csrf` in `fan_plus.html:97`, `project.html:209`, `sandbox.html:24` | |
| 41 | + | ## Ultra Fuzz Run 24 (2026-05-09) -- All Fixed | |
| 33 | 42 | ||
| 34 | - | ### MINOR (current phase) | |
| 35 | - | - [x] Add migration: unique constraint on `idempotency_keys(key, user_id, method, path)` replacing `(key, user_id)` (migration 106) | |
| 36 | - | - [ ] Stream content exports or export to S3 with presigned download (`routes/api/exports.rs:656-680`) | |
| 37 | - | - [x] Add `UNIQUE(s3_key, bucket)` to `pending_s3_deletions` table (migration 107) | |
| 43 | + | - [x] **SERIOUS**: Add `starts_at` validation to guest checkout (`routes/api/guest_checkout.rs:105`). Copied from `cart.rs:150`. | |
| 44 | + | - [x] **SERIOUS**: Fix version replace storage counter rollback (`routes/storage/versions.rs`). Now uses atomic `try_replace_storage` for file replacements. | |
| 45 | + | - [x] MINOR: Change pending_uploads ON CONFLICT to DO NOTHING (`db/pending_uploads.rs:17`) | |
| 46 | + | - ~~MINOR: Blog editor: preserve form input on validation error~~ -- false positive; JS `fetch()` keeps form state on error | |
| 47 | + | - [x] MINOR: Idempotency middleware: avoid double allocation (`metrics.rs:232`) | |
| 48 | + | - [ ] DEFERRED: Extract shared `validate_promo_code()` helper to prevent checkout path divergence | |
| 38 | 49 | ||
| 39 | 50 | --- | |
| 40 | 51 | ||
| 41 | - | ## Ultra Fuzz Run 21 (2026-05-08) | |
| 42 | - | ||
| 43 | - | ### SERIOUS (fix before launch) | |
| 44 | - | - [x] Pin scheduler advisory lock to acquired connection — hold for tick duration (`scheduler/mod.rs:77-88`) | |
| 45 | - | - [x] Enforce PWYW minimum in cart `effective_price_cents()` — `.max(pwyw_min_cents)` (`db/cart.rs:104-108`) | |
| 46 | - | - [x] Fix soft-delete purge: query version S3 keys + decrement storage before CASCADE (`scheduler/cleanup.rs:188-222`) | |
| 47 | - | - [x] Wrap confirm_upload DB writes in single UPDATE — rollback storage on failure (`routes/storage/uploads.rs:225-248`) | |
| 48 | - | - [x] Optimize discover page: parallelize facet queries with try_join! (`routes/pages/public/discover.rs:292-513`) | |
| 49 | - | ||
| 50 | - | ### MINOR/MEDIUM (current phase) | |
| 51 | - | - [x] Delete old S3 objects on item image/audio/video replacement (`routes/storage/images.rs`, `routes/storage/uploads.rs`) | |
| 52 | - | - [x] Validate checkout cancel `item_id` as UUID (`routes/stripe/checkout/mod.rs:105-108`) | |
| 53 | - | - [x] Change ClamAV `contains("FOUND")` to `ends_with("FOUND")` (`scanning/clamav.rs:96`) | |
| 54 | - | - [x] Case-normalize URL-encoded path traversal check (`scanning/archive.rs:70`) | |
| 55 | - | - [x] Add UNIQUE(s3_key) to pending_uploads table (migration 102) | |
| 56 | - | - [x] Add `#[tracing::instrument(skip_all)]` to embed/ handlers (was mostly done; added item_player) | |
| 57 | - | - [x] payments/ already had #[instrument] on all async fns (webhook extractors are pure, no I/O) | |
| 58 | - | - [x] Initialize PWYW amount_cents hidden field server-side (`templates/pages/purchase.html`) | |
| 59 | - | - [x] Use re-auth for OAuth authorize with legacy sessions (`routes/oauth.rs:253-256`) | |
| 60 | - | ||
| 61 | - | --- | |
| 62 | - | ||
| 63 | - | ## Audit Remediations (2026-05-08, post-Sprint 9) | |
| 64 | - | ||
| 65 | - | - [x] Fix XSS: escape `s.category` and `s.url` in search suggestions innerHTML (discover.html) | |
| 66 | - | - [x] Fix XSS: replace inline `onsubmit` handler with `addEventListener` in collections.js | |
| 67 | - | - [x] Fix a11y: convert `<span onclick>` save buttons to `<button>` with `aria-label` on discover cards | |
| 68 | - | - [x] Performance: rewrite `get_user_purchases` double-nested correlated subquery — replaced with LATERAL joins counting total versions vs downloaded versions | |
| 69 | - | - [x] Remove unused import `spawn_email` in `stripe/checkout/cart.rs` | |
| 70 | - | - [x] Remove dead code `update_app_sync_sub_tier` in `db/app_sync.rs` | |
| 71 | - | ||
| 72 | - | --- | |
| 52 | + | ## Code Fuzz / Ultra Fuzz — Accepted Risks & Deferred | |
| 73 | 53 | ||
| 74 | - | ## Code Fuzz Findings (2026-05-08) | |
| 75 | - | ||
| 76 | - | Two-pass fuzz: initial scan + deep verification. Items marked REFUTED were disproven in the second pass. Items marked CONFIRMED were verified with exact evidence. | |
| 77 | - | ||
| 78 | - | ### Payments & Checkout | |
| 79 | - | - [x] SERIOUS: Cart refund only cleans up ONE item — changed `fetch_optional` to `fetch_all`, refund handler now iterates all matching transactions (`db/transactions.rs`, `routes/stripe/webhook/billing.rs`) | |
| 80 | - | - [x] MINOR: No buyer != seller validation in cart checkout — added `user.id == seller_id` guard (`routes/stripe/checkout/cart.rs`) | |
| 81 | - | - [x] MINOR: Guest checkout `increment_sales_count` error was silently swallowed — now propagates error so Stripe retries (`routes/stripe/webhook/checkout.rs`) | |
| 82 | - | - [x] MINOR: Tips lack `check_not_suspended()` — added (`routes/stripe/checkout/tips.rs`) | |
| 83 | - | - [x] MINOR: Orphaned pending transactions on partial cart failure — investigated: failed seller's transactions are never created (error precedes create_transaction); earlier sellers' sessions expire naturally; 25h stale cleanup is correct. No additional fix needed. | |
| 84 | - | - [x] MINOR: `CartLineItem.amount_cents` is `i32` while `Cents` wraps `i64` — changed to `i64`, removed redundant cast (`payments/checkout.rs`) | |
| 85 | - | - [x] MINOR: No Stripe minimum amount ($0.50) enforcement before creating session — added `STRIPE_MINIMUM_CHARGE_CENTS` check to all 3 checkout methods (`payments/checkout.rs`, `constants.rs`) | |
| 86 | - | - [x] MINOR: Cart items removed before Stripe session completes — moved cart removal to webhook handler so items survive cancelled checkouts (`routes/stripe/checkout/cart.rs`, `routes/stripe/webhook/checkout.rs`) | |
| 87 | - | - [x] MINOR: Project checkout uses `ItemId::nil()` — made item_id optional in CreateTransactionParams, CheckoutParams, and CheckoutMetadata; project purchases now use NULL; fixed unique indexes to exclude NULL item_id (migration 104) | |
| 88 | - | - [x] MINOR: v2 webhook handler swallows account update failures — on failure, unmarks event from processed set and returns 500 so Stripe retries (`routes/stripe/webhook_v2.rs`, `db/webhook_events.rs`) | |
| 89 | - | - ~~SERIOUS: Partial Stripe refunds revoke full access~~ REFUTED — `is_full_refund()` check at `billing.rs:272` prevents this | |
| 90 | - | - ~~MINOR: $0 items dropped in `process_seller_checkout`~~ REFUTED — unreachable; `process_seller_checkout` is only called with `promo_code: None` | |
| 91 | - | ||
| 92 | - | ### Pricing, Promo Codes & Transactions | |
| 93 | - | - [x] MINOR: Cross-creator promo code collision — added `code_purpose = 'free_access'` filter to `get_promo_code_by_code` (`db/promo_codes.rs`) | |
| 94 | - | - [x] MINOR: `remove_free_item_from_library` doesn't decrement promo code `use_count` — now returns and decrements via `release_use_count` (`db/transactions.rs`) | |
| 95 | - | - [x] MINOR: `claim_free_with_promo_code` doesn't record `promo_code_id` — added to INSERT (`db/transactions.rs`) | |
| 96 | - | - [x] MINOR: Tautology `if s == s { 23 } else { 0 }` in `parse_date` — removed dead branch (`routes/api/promo_codes.rs`) | |
| 97 | - | - [x] MINOR: Duplicate promo code creation returns 500 — added 23505 handling with friendly message (`routes/api/promo_codes.rs`) | |
| 98 | - | - [x] MINOR: Can create already-expired promo codes — added `expires_at > now()` check (`routes/api/promo_codes.rs`) | |
| 99 | - | - [x] MINOR: Subscription trial promo code `use_count` never released on abandoned checkout — subscription checkout now creates pending transaction row, cleaned up by existing 25h stale cleanup (`db/transactions.rs`, `routes/stripe/checkout/subscriptions.rs`, `routes/stripe/webhook/checkout.rs`) | |
| 100 | - | - [x] MINOR: `update_promo_code` allows setting `max_uses` below current `use_count` — added guard rejecting max_uses < current use_count (`routes/api/promo_codes.rs`) | |
| 101 | - | - [x] MINOR: License key generation after promo claim is non-transactional — `claim_free_with_promo_code` now accepts `LicenseKeyParams` and creates key inside the same transaction (`db/transactions.rs`, `routes/api/promo_codes.rs`, `routes/stripe/checkout/item.rs`) | |
| 102 | - | - [x] NOTE: `FixedPricing::validate_amount` has no upper cap — not exploitable, fixed-price checkout uses `item.price_cents` (`pricing.rs:134-143`) | |
| 103 | - | ||
| 104 | - | ### Subscriptions | |
| 105 | - | - [x] SERIOUS: Re-subscription after cancel silently no-ops — changed `ON CONFLICT DO NOTHING` to `DO UPDATE` with reactivation (`db/creator_tiers.rs`, `db/fan_plus.rs`) | |
| 106 | - | - [x] SERIOUS: `handle_subscription_updated` with `status=canceled` never sets `canceled_at` — all 4 `update_*_status` functions now set `canceled_at` via CASE/COALESCE (`db/subscriptions.rs`, `db/creator_tiers.rs`, `db/fan_plus.rs`, `db/app_sync.rs`) | |
| 107 | - | - [x] MINOR: `cancel_subscription` overwrites `canceled_at` on re-cancel — all 4 cancel functions now use `COALESCE(canceled_at, NOW())` | |
| 108 | - | - [x] MINOR: `resume_subscriptions_for_creator` doesn't filter on `status = 'active'` — added `AND status = 'active'` (`db/subscriptions.rs`) | |
| 109 | - | - [x] MINOR: `get_user_subscribed_item_ids` grants access to paused item subs — added `AND paused_at IS NULL` (`db/subscriptions.rs`) | |
| 110 | - | - [x] LOW: `get_project_subscriber_count` counts paused subscriptions — added `AND paused_at IS NULL` (`db/subscriptions.rs`) | |
| 111 | - | - [x] LOW: Grace period upload rejection returns bare 403 — changed to `BadRequest` with descriptive message (`db/creator_tiers.rs`) | |
| 112 | - | - [x] NOTE: Everything tier checkout hard-blocked — guard removed, tier is live and priced (`routes/stripe/checkout/subscriptions.rs`) | |
| 113 | - | ||
| 114 | - | ### Auth & Sessions | |
| 115 | - | - [x] MEDIUM: Passkey registration does not require re-authentication — added password confirmation to `register_start` + JS prompt (`routes/api/passkeys.rs`, `static/passkey.js`) | |
| 116 | - | - [x] MINOR: Password reset doesn't invalidate other sessions — added `delete_all_sessions_for_user` + cache eviction (`routes/pages/email_actions/password.rs`) | |
| 117 | - | - [x] LOW: Email verification HMAC lacks purpose prefix — added `"verify:"` prefix to generate and verify (`email/tokens.rs`) | |
| 118 | - | - [x] NOTE: CSRF exemption comment says SameSite=Strict but cookie is Lax — corrected comment (`csrf.rs:131`) | |
| 119 | - | - [x] NOTE: `authorize_post` duplicates `AuthUser` session validation logic — correct but fragile, accepted (`routes/oauth.rs:222-258`) | |
| 54 | + | Remaining open items from Runs 21-24 and Code Fuzz (2026-05-08). All SERIOUS items resolved through Run 23. Run 24 SERIOUS items above. | |
| 120 | 55 | ||
| 121 | 56 | ### Storage & Uploads | |
| 122 | - | - [x] SERIOUS: Internal CLI upload skips S3 key validation — added `expected_prefix` check matching all other confirm handlers (`routes/api/internal/uploads.rs`) | |
| 123 | - | - [x] SERIOUS: Internal CLI upload increments storage AFTER writing DB record — moved `try_increment_storage` before DB writes, deletes S3 on quota failure (`routes/api/internal/uploads.rs`) | |
| 124 | - | - [x] MINOR: Insertion presign skips `check_presign_allowed` quota check — added quota check before presign (`routes/api/content_insertions.rs`) | |
| 125 | - | - [x] MINOR: Filenames sanitizing to empty create ambiguous S3 keys — fallback to `"file"` basename (`storage.rs`) | |
| 126 | - | - [x] MINOR: Insertion confirm stores client-supplied `mime_type` without re-validation — added `validate_content_type` at confirm (`routes/api/content_insertions.rs`) | |
| 127 | - | - [x] LOW: Media delete swallows S3 error then deletes DB record — now logs S3 errors, decrements storage before DB delete (`routes/storage/media.rs`) | |
| 128 | - | - [x] SERIOUS: Presigned URLs have no S3 lifecycle cleanup — added `pending_uploads` table, reaper job (24h), S3 lifecycle rule (36h) in human_todo (`routes/storage/uploads.rs`) | |
| 129 | - | - [x] MINOR: Storage counter decrement-then-increment for file replacement not transactional — added `try_replace_storage` that atomically decrements old + increments new in a single UPDATE (`db/creator_tiers.rs`, `routes/storage/uploads.rs`) | |
| 130 | 57 | - [ ] MINOR: `classify_media` trusts client-supplied `content_type` for size limits — attacker wastes own quota (`media.rs:80-91`) | |
| 131 | - | - [x] MINOR: Creator cannot preview own draft content via stream/download — added `is_creator` bypass for draft, scan status, and payment checks (`routes/storage/downloads.rs`) | |
| 132 | - | - [x] LOW: Play count inflatable via unauthenticated spam — added per-IP rate limit (10 burst, 1/3s) on stream/download routes + unique_play_count tracking for authenticated users (migration 103, `user_plays` table) | |
| 133 | 58 | - [ ] NOTE: `extract_s3_key_from_url` may include bucket name for path-style URLs — only used for project images which handle it (`storage.rs:427-446`) | |
| 134 | 59 | - [ ] NOTE: Project image old-file cleanup relies on URL-to-key extraction — fragile but functional (`routes/storage/images.rs:169-177`) | |
| 135 | 60 | ||
| 136 | 61 | ### File Scanning | |
| 137 | - | - [x] MEDIUM: Archive decompression read error silently swallowed — on read error, now falls back to claimed size instead of zero (`scanning/archive.rs`) | |
| 138 | - | - [x] MEDIUM: Audio/Insertion files with unrecognized magic bytes pass ALL layers — now Fail like images do (`scanning/content_type.rs`) | |
| 139 | 62 | - [ ] MEDIUM: No scanning when scanner=None + trusted user — by-design trust model; needs async background scan mode to fix without breaking upload UX (`scanning/mod.rs:142-151`) | |
| 140 | - | - [x] MINOR: `ends_with("OK")` too permissive for ClamAV response parsing — changed to exact match `response == "stream: OK"` (`scanning/clamav.rs`) | |
| 141 | 63 | - [ ] MINOR: Archive scan skips cover images with ZIP magic bytes — layer 1 catches type mismatch; removing skip would be defense-in-depth but no current exploit path (`scanning/archive.rs:29-36`) | |
| 142 | - | - [x] MINOR: Path traversal check misses URL-encoded and Unicode normalization variants — added `%2e%2e`, absolute path, and null byte checks (`scanning/archive.rs`) | |
| 143 | - | - [x] MINOR: Nested archive detection double-decompresses entries — magic bytes now captured during initial decompression pass (`scanning/archive.rs`) | |
| 144 | - | - [x] NOTE: Unrecognized video data gets `Pass` instead of failing — now Fail like images (`scanning/content_type.rs`) | |
| 145 | - | - [x] NOTE: Mach-O binaries get unconditional `Pass` — added suspicious import analysis matching PE/ELF pattern: ptrace, task_for_pid, mach_vm_write/protect, thread_create_running, posix_spawn, dlopen (`scanning/structural.rs`) | |
| 146 | - | - [x] NOTE: Download XSS blocklist missing `image/svg+xml` and `application/xhtml+xml` — added both (`scanning/content_type.rs`) | |
| 147 | 64 | ||
| 148 | 65 | ### DB & Validation | |
| 149 | - | - [x] SERIOUS: `count_discover_items` missing `deleted_at IS NULL` — added filter (`db/discover.rs`) | |
| 150 | - | - [x] MINOR: `get_bundleable_items` includes soft-deleted items — added `deleted_at IS NULL` (`db/bundles.rs`) | |
| 151 | - | - [x] MINOR: `get_item_type_counts`, `get_price_range_counts`, `get_ai_tier_counts` missing `deleted_at IS NULL` — all three fixed (`db/discover.rs`) | |
| 152 | - | - [x] MINOR: `get_tag_counts` missing `deleted_at IS NULL` and `listed = true` — added both (`db/tags.rs`) | |
| 153 | - | - [x] MINOR: `get_all_tag_counts` missing `listed` and `deleted_at` filters — added (`db/tags.rs`) | |
| 154 | - | - [x] MINOR: `search_suggestions` exposes suspended/deactivated usernames — added `suspended_at IS NULL AND deactivated_at IS NULL` (`db/discover.rs`) | |
| 155 | - | - [x] NOTE: `discover_projects` item_count includes soft-deleted items — added `deleted_at IS NULL` to FILTER clause (`db/discover.rs`) | |
| 156 | - | - [x] MINOR: `get_items_by_user` and `count_items_by_user_projects` include soft-deleted items — added `deleted_at IS NULL` (`db/items.rs`) | |
| 157 | - | - [x] MINOR: `bulk_update_price` takes raw `i32` instead of `PriceCents` — changed to `PriceCents` (`db/items.rs`, `routes/api/items/bulk.rs`) | |
| 158 | - | - [x] MINOR: `duplicate_item` slug retry loop has no counter cap — added cap at 100 (`db/items.rs`) | |
| 159 | - | - [x] MINOR: `get_deleted_items_by_project` has no LIMIT clause — added LIMIT 500 (`db/items.rs`) | |
| 160 | 66 | - [ ] MINOR: `item_slug_exists` considers soft-deleted items — intentional during 7-day recovery window (`db/items.rs:80-94`) | |
| 161 | - | - [x] MINOR: `get_all_users` accepts arbitrary `limit`/`offset` — clamped to 200 (`db/users.rs`) | |
| 162 | - | - [x] MINOR: Guest checkout auto-attach not in transaction — wrapped user lookup + transaction update in DB transaction (`db/transactions.rs`) | |
| 163 | - | - [x] NOTE: `set_bundle_items` no ownership check at DB layer — added `owner_id` param with bundle + item ownership verification (`db/bundles.rs`) | |
| 164 | - | - [x] NOTE: Idempotency key scope mismatch — widened PK to `(key, user_id, method, path)`, updated ON CONFLICT (migration 106, `db/idempotency.rs`) | |
| 165 | - | ||
| 166 | - | ### Git SSH & Build Runner | |
| 167 | - | - [x] MEDIUM: Unsanitized SSH command passthrough — `exec_git_shell` now receives a reconstructed command from validated components (`git_ssh.rs`) | |
| 168 | - | - [x] MEDIUM: Partial-failure builds create live OTA releases — now returns early with Failed status, no release created (`build_runner.rs`) | |
| 169 | - | - [x] MINOR: `&repo.description[..25]` byte-position slice panics on multi-byte UTF-8 — uses `chars().take(25)` (`git_ssh.rs`) | |
| 170 | - | - [x] LOW: `cmd_ssh_repo_delete` path not canonicalized — added `canonicalize` + `starts_with` check (`git_ssh.rs`) | |
| 171 | - | - [x] MEDIUM: Empty signature on automated builds — build runner now SCPs .sig files, passes to release. Key generation in human_todo (`build_runner.rs`) | |
| 172 | - | - [x] MINOR: TOCTOU race between `has_running_build` and `get_pending_build` — replaced with atomic `claim_pending_build` using `FOR UPDATE SKIP LOCKED` (`db/builds.rs`, `build_runner.rs`) | |
| 173 | - | - [x] LOW: Global build trigger token readable from repo hooks directory — replaced with per-repo HMAC (`build_runner.rs`) | |
| 174 | - | - [x] LOW: `get_latest_release` SQL `::int[]` cast crashes on non-numeric version parts — added regex guard, non-numeric sorts last (`db/ota.rs`) | |
| 175 | - | - [x] LOW: No version uniqueness enforcement on OTA releases — constraint already existed in migration 033, added `ON CONFLICT` handling returning 409 (`db/ota.rs`) | |
| 176 | - | - [x] LOW: S3 key collision on same-version rebuilds — resolved by OTA version uniqueness constraint (`db/ota.rs`) | |
| 177 | - | - [x] LOW: Build log stores unsanitized build output — added ANSI escape stripping (`build_runner.rs`) | |
| 178 | - | - [x] LOW: `StrictHostKeyChecking=accept-new` trusts on first SSH connect — now uses pinned known_hosts when `/opt/makenotwork/ssh/known_hosts` exists (`build_runner.rs`) | |
| 179 | - | - ~~MINOR: `hook_trigger` doesn't check `config.enabled`~~ REFUTED — DB query includes `AND enabled = true` | |
| 180 | 67 | ||
| 181 | 68 | ### Scheduler & Infrastructure | |
| 182 | - | - [x] SERIOUS: Soft-deleted item purge never cleans S3 — added `get_expired_deleted_item_s3_keys` query + S3 delete before DB purge (`scheduler/cleanup.rs`, `db/items.rs`) | |
| 183 | - | - [x] MODERATE: Dead webhook retry storm — added `AND attempts < 5` guard to `get_retryable_events` query (`db/webhook_events.rs`) | |
| 184 | - | - [x] MINOR: WAM client double-slash URL — `trim_end_matches('/')` on base_url (`wam_client.rs`) | |
| 185 | - | - [x] NOTE: `COUNT(*) LIMIT 1` no-op in monitor — changed to `SELECT EXISTS(...)` (`monitor.rs`) | |
| 186 | - | - [x] MODERATE: S3 delete then DB delete crash gap — implemented `pending_s3_deletions` durable queue across all 5 deletion paths (migration 105) | |
| 187 | - | - [x] MINOR: Stale refund escalation sends duplicate alerts — `mark_escalated` now runs before alerts, skips on failure (`scheduler/webhooks.rs`) | |
| 188 | 69 | - [ ] NOTE: Announcement emails lost on server restart — no delivery persistence (`scheduler/announcements.rs:59-85`) | |
| 189 | - | - [x] NOTE: Duplicate onboarding emails on step-advance DB failure — advance step before sending email; missing one email beats duplicates (`scheduler/announcements.rs`) | |
| 190 | - | - [x] NOTE: Idempotency middleware drops >1MB response bodies silently — check content-length before consuming body, skip caching for large responses (`metrics.rs`) | |
| 191 | 70 | - [ ] NOTE: Unconfigured S3 reports `s3_ok = true` — intentional but masks misconfig (`monitor.rs:56-65`) | |
| 192 | 71 | - [ ] NOTE: `X-Forwarded-For` spoofable without Cloudflare — accepted risk (`rate_limit.rs:26-31`) | |
| 193 | - | - ~~MINOR: Advisory lock accumulates across ticks~~ REFUTED — reentrant by design, harmless | |
| 194 | - | ||
| 195 | - | ### Email, Templates, Config & Import | |
| 196 | - | - [x] MEDIUM: Dev-mode email logging exposes sensitive tokens — body redacted from log output (`email/mod.rs`) | |
| 197 | - | - [x] MEDIUM: CSV import has no row count limit — added 100K row cap (`import/csv_converter.rs`) | |
| 198 | - | - [x] LOW: `parse_amount_cents` wrong for negative decimals — fixed sign handling (`import/csv_converter.rs`) | |
| 199 | - | - [x] LOW: `format_revenue` displays negative as `"$-5.00"` — changed to `"-$5.00"` (`formatting.rs`) | |
| 200 | - | - [x] LOW: `slugify` produces unbounded-length output — capped at 128 chars (`formatting.rs`) | |
| 201 | - | - [x] LOW: Dev signing secret uses UUID v4 — changed to 256-bit CSPRNG (`config.rs`) | |
| 202 | - | - [x] NOTE: Email verification HMAC lacks `"verify:"` prefix — fixed in Auth section above (`email/tokens.rs`) | |
| 203 | - | - [x] LOW: Negative `price_cents` produces malformed `price_decimal` in JSON-LD — fixed sign handling (`types/mod.rs`) | |
| 204 | - | - [x] NOTE: `sanitize_field` tab/CR branches are dead code — removed (`import/csv_converter.rs`) | |
| 205 | - | - [x] MEDIUM: Unsubscribe `action` parameter not validated against enum — added `UnsubscribeAction` enum with 9 variants, updated 17 call sites, fixed missing `notify_tip` handler (`email/tokens.rs`, 13 files) | |
| 206 | - | - [x] LOW: Issue reply signature truncated to 64 bits — switched hex to base64url encoding, now 96 bits in same 16 chars (`email/tokens.rs`) | |
| 207 | - | - [x] LOW: Import tier `price_cents` silently clamped from i64 to i32 — replaced with `try_into()` + descriptive error (`import/pipeline.rs`) | |
| 208 | - | - [x] LOW: Import `strip_html_tags` has no output length limit — capped at 512KB (`import/pipeline.rs`) | |
| 209 | - | - [x] LOW: Lossy i64-to-u32 casts in WaveStats/DbDiscoverItemRow — replaced with saturating casts (`types/conversions.rs`) | |
| 72 | + | ||
| 73 | + | --- | |
| 74 | + | ||
| 75 | + | ## S3/DB Crash-Gap Fix: `pending_s3_deletions` Durable Queue -- DONE | |
| 76 | + | ||
| 77 | + | All 5 paths migrated, scheduler retry job wired. See `todo_done.md` for details. | |
| 210 | 78 | ||
| 211 | 79 | --- | |
| 212 | 80 | ||
| @@ -223,38 +91,11 @@ Two-pass fuzz: initial scan + deep verification. Items marked REFUTED were dispr | |||
| 223 | 91 | ||
| 224 | 92 | ### Media Player | |
| 225 | 93 | - [ ] Video URL fallback on S3 presign failure (migration: `video_url` column) | |
| 226 | - | - [x] Arrow key seek should use virtual timeline (segment-aware) — extracted `seekToVirtualMs`, keyboard handler uses virtual time in segment mode | |
| 227 | - | ||
| 228 | - | ### S3/DB Crash-Gap Fix: `pending_s3_deletions` Durable Queue | |
| 229 | - | ||
| 230 | - | S3 deletes then DB deletes create a crash gap: if the server dies between the two steps, | |
| 231 | - | the DB references deleted S3 objects (or S3 objects are orphaned with no DB reference). | |
| 232 | - | ||
| 233 | - | **Affected paths (5 total):** | |
| 234 | - | 1. `scheduler/cleanup.rs` — `cleanup_user_s3_and_delete` (user CASCADE, main + synckit buckets) | |
| 235 | - | 2. `scheduler/cleanup.rs` — `purge_expired_deleted_items` (soft-delete purge, main bucket) | |
| 236 | - | 3. `routes/ota.rs:250-257` — `delete_release` (OTA artifacts, synckit bucket) | |
| 237 | - | 4. `routes/storage/media.rs:374-384` — media file delete (main bucket) | |
| 238 | - | 5. `routes/api/content_insertions.rs:284-290` — insertion delete (main bucket) | |
| 239 | - | ||
| 240 | - | **Implementation:** | |
| 241 | - | ||
| 242 | - | - [x] Migration 105: `pending_s3_deletions` table | |
| 243 | - | - [x] DB module `db/pending_s3_deletions.rs`: enqueue, remove, get_stale | |
| 244 | - | - [x] `cleanup_user_s3_and_delete`: enqueue before S3/CASCADE, bail on failure | |
| 245 | - | - [x] `purge_expired_deleted_items`: enqueue before S3/purge, bail on failure | |
| 246 | - | - [x] `delete_release` (ota.rs): enqueue before S3/DB delete | |
| 247 | - | - [x] media file delete: enqueue before S3/DB delete | |
| 248 | - | - [x] insertion delete: enqueue before S3/DB delete | |
| 249 | - | - [x] Scheduler retry job `retry_pending_s3_deletions` (10min, batch 100, sandbox cadence) | |
| 250 | - | - [x] Wired into `scheduler/mod.rs` sandbox block | |
| 251 | - | ||
| 252 | - | **Edge cases:** S3 deletes are idempotent (404 = success). Duplicate rows are harmless. Enqueue failure = bail before any destructive work. Crash between enqueue and S3 = retry job picks up. Crash after S3 but before DB = next scheduler tick re-processes (re-enqueue is harmless). Advisory lock prevents concurrent schedulers. | |
| 253 | 94 | ||
| 254 | 95 | ### Code Quality | |
| 255 | 96 | - ~~Remove `async-trait`~~ — kept: all 3 traits are used as `dyn` objects; Rust 2024 async fn in traits is not dyn-compatible. `async-trait` is the correct tool until `dyn async fn` stabilizes (RFC 3245) | |
| 256 | - | - [ ] Add README.md to server/ | |
| 257 | - | - [ ] Split oversized route files: exports.rs, license_keys.rs, health.rs, tabs/user.rs | |
| 97 | + | - [x] Add README.md to server/ (Run 23) | |
| 98 | + | - [x] Split oversized route files: exports.rs, health.rs, tabs/user.rs (Run 23) | |
| 258 | 99 | - [ ] Monitor scheduler.rs, git/mod.rs for growth | |
| 259 | 100 | ||
| 260 | 101 | ### Feature Completeness | |
| @@ -365,7 +206,7 @@ MNW/server/src/ | |||
| 365 | 206 | import/ (CSV converter, pipeline, intermediate format) | |
| 366 | 207 | MNW/server/tests/ | |
| 367 | 208 | integration.rs, harness/, workflows/*.rs | |
| 368 | - | MNW/server/migrations/ (001-096) | |
| 209 | + | MNW/server/migrations/ (001-107) | |
| 369 | 210 | MNW/server/templates/ | |
| 370 | 211 | MNW/server/deploy/ | |
| 371 | 212 | MNW/server/site-docs/public/, MNW/server/site-docs/unpublished/ |
| @@ -331,3 +331,62 @@ All 6 docs updated with feature map cross-reference after /map-features audit. | |||
| 331 | 331 | - [x] License Keys docs — added offline verify endpoint, license.txt, revocation, PWYW, /api/v1/ prefix | |
| 332 | 332 | - [x] SyncKit docs — added SSE, key rotation, subscription gating, batch_id, sync status | |
| 333 | 333 | - [x] FAQ — added For Developers section, tier features, custom domains, data retention, content moderation | |
| 334 | + | ||
| 335 | + | --- | |
| 336 | + | ||
| 337 | + | ## Ultra Fuzz Run 22 (2026-05-09) | |
| 338 | + | ||
| 339 | + | - [x] Fix CSRF field name: change `csrf_token` to `_csrf` in `fan_plus.html:97`, `project.html:209`, `sandbox.html:24` | |
| 340 | + | - [x] Add migration: unique constraint on `idempotency_keys(key, user_id, method, path)` replacing `(key, user_id)` (migration 106) | |
| 341 | + | - [x] Add `UNIQUE(s3_key, bucket)` to `pending_s3_deletions` table (migration 107) | |
| 342 | + | ||
| 343 | + | --- | |
| 344 | + | ||
| 345 | + | ## Ultra Fuzz Run 21 (2026-05-08) | |
| 346 | + | ||
| 347 | + | ### SERIOUS | |
| 348 | + | - [x] Pin scheduler advisory lock to acquired connection — hold for tick duration (`scheduler/mod.rs:77-88`) | |
| 349 | + | - [x] Enforce PWYW minimum in cart `effective_price_cents()` — `.max(pwyw_min_cents)` (`db/cart.rs:104-108`) | |
| 350 | + | - [x] Fix soft-delete purge: query version S3 keys + decrement storage before CASCADE (`scheduler/cleanup.rs:188-222`) | |
| 351 | + | - [x] Wrap confirm_upload DB writes in single UPDATE — rollback storage on failure (`routes/storage/uploads.rs:225-248`) | |
| 352 | + | - [x] Optimize discover page: parallelize facet queries with try_join! (`routes/pages/public/discover.rs:292-513`) | |
| 353 | + | ||
| 354 | + | ### MINOR/MEDIUM | |
| 355 | + | - [x] Delete old S3 objects on item image/audio/video replacement (`routes/storage/images.rs`, `routes/storage/uploads.rs`) | |
| 356 | + | - [x] Validate checkout cancel `item_id` as UUID (`routes/stripe/checkout/mod.rs:105-108`) | |
| 357 | + | - [x] Change ClamAV `contains("FOUND")` to `ends_with("FOUND")` (`scanning/clamav.rs:96`) | |
| 358 | + | - [x] Case-normalize URL-encoded path traversal check (`scanning/archive.rs:70`) | |
| 359 | + | - [x] Add UNIQUE(s3_key) to pending_uploads table (migration 102) | |
| 360 | + | - [x] Add `#[tracing::instrument(skip_all)]` to embed/ handlers | |
| 361 | + | - [x] Initialize PWYW amount_cents hidden field server-side (`templates/pages/purchase.html`) | |
| 362 | + | - [x] Use re-auth for OAuth authorize with legacy sessions (`routes/oauth.rs:253-256`) | |
| 363 | + | ||
| 364 | + | --- | |
| 365 | + | ||
| 366 | + | ## Audit Remediations (2026-05-08, post-Sprint 9) | |
| 367 | + | ||
| 368 | + | - [x] Fix XSS: escape `s.category` and `s.url` in search suggestions innerHTML (discover.html) | |
| 369 | + | - [x] Fix XSS: replace inline `onsubmit` handler with `addEventListener` in collections.js | |
| 370 | + | - [x] Fix a11y: convert `<span onclick>` save buttons to `<button>` with `aria-label` on discover cards | |
| 371 | + | - [x] Performance: rewrite `get_user_purchases` double-nested correlated subquery — replaced with LATERAL joins | |
| 372 | + | - [x] Remove unused import `spawn_email` in `stripe/checkout/cart.rs` | |
| 373 | + | - [x] Remove dead code `update_app_sync_sub_tier` in `db/app_sync.rs` | |
| 374 | + | ||
| 375 | + | --- | |
| 376 | + | ||
| 377 | + | ## Code Fuzz Findings (2026-05-08) | |
| 378 | + | ||
| 379 | + | Two-pass fuzz: 100+ findings across payments, subscriptions, auth, storage, scanning, DB, git, scheduler, email/import. All SERIOUS and MEDIUM items fixed. See git history (commits `42405fe`, `f0e40b7`, `fed8192`, `80bfdd9`, `bac01d6`, `ba3bbac`) for full details. Key fixes: | |
| 380 | + | ||
| 381 | + | - Cart refund iterates all matching transactions (was single fetch) | |
| 382 | + | - Re-subscription after cancel now reactivates (was DO NOTHING) | |
| 383 | + | - `handle_subscription_updated` sets `canceled_at` across all 4 subscription types | |
| 384 | + | - Internal CLI upload validates S3 key prefix + storage order | |
| 385 | + | - Soft-deleted item purge cleans S3 via `pending_s3_deletions` durable queue (migration 105) | |
| 386 | + | - Passkey registration requires re-authentication | |
| 387 | + | - Password reset invalidates all other sessions | |
| 388 | + | - Build runner: atomic claim, per-repo HMAC, pinned known_hosts, ANSI stripping | |
| 389 | + | - Promo codes: collision fix, use_count release, transactional license keys | |
| 390 | + | - Unsubscribe action validated against enum (9 variants, 17 call sites) | |
| 391 | + | - CSV import: row cap, amount parsing, HTML strip limit | |
| 392 | + | - Archive scanning: URL-encoded path traversal, nested archive, content type hardening |
| @@ -0,0 +1,36 @@ | |||
| 1 | + | <div style="max-width: 400px; text-align: center;"> | |
| 2 | + | <header style="margin-bottom: 2rem;"> | |
| 3 | + | <div style="width: 100px; height: 100px; background: var(--light-background); margin: 0 auto 1rem; display: flex; align-items: center; justify-content: center; font-size: 2.5rem; opacity: 0.5; font-family: var(--font-heading);">AK</div> | |
| 4 | + | <h1 style="font-size: 1.8rem; margin-bottom: 0.25rem; font-family: var(--font-heading);">Ada Karras</h1> | |
| 5 | + | <div style="font-size: 0.9rem; opacity: 0.7; margin-bottom: 0.75rem;">adakarras</div> | |
| 6 | + | <p style="font-size: 0.95rem; max-width: 340px; margin: 0 auto;">Ambient composer and field recordist. New album every spring. Samples and stems available for remix.</p> | |
| 7 | + | <div style="margin-top: 0.75rem; display: flex; gap: 0.75rem; justify-content: center; align-items: center;"> | |
| 8 | + | <button class="secondary" style="font-size: 0.85rem; padding: 0.4rem 0.8rem;">Follow (48)</button> | |
| 9 | + | </div> | |
| 10 | + | <div style="margin-top: 0.5rem; display: flex; gap: 1rem; justify-content: center; font-size: 0.85rem;"> | |
| 11 | + | <span style="opacity: 0.7;">RSS Feed</span> | |
| 12 | + | <span style="opacity: 0.7;">Copy link</span> | |
| 13 | + | </div> | |
| 14 | + | </header> | |
| 15 | + | ||
| 16 | + | <section style="margin-bottom: 1.5rem;"> | |
| 17 | + | <div class="card" style="text-align: center; padding: 0.75rem 1rem;"> | |
| 18 | + | <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1rem;">bandcamp.com/adakarras</div> | |
| 19 | + | <div style="font-size: 0.8rem; opacity: 0.7;">Back catalog and vinyl</div> | |
| 20 | + | </div> | |
| 21 | + | </section> | |
| 22 | + | ||
| 23 | + | <section> | |
| 24 | + | <h2 class="section-header" style="font-size: 1rem; text-align: left;">Projects</h2> | |
| 25 | + | <a class="card" style="display: block; text-decoration: none; color: inherit;"> | |
| 26 | + | <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1.05rem; margin-bottom: 0.25rem;">Liminality</div> | |
| 27 | + | <div style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.25rem;">Music · 8 items</div> | |
| 28 | + | <div style="font-size: 0.85rem; opacity: 0.8;">Field recordings and processed ambient. Spring 2026.</div> | |
| 29 | + | </a> | |
| 30 | + | <a class="card" style="display: block; text-decoration: none; color: inherit;"> | |
| 31 | + | <div style="font-family: var(--font-heading); font-weight: bold; font-size: 1.05rem; margin-bottom: 0.25rem;">Sample Library</div> | |
| 32 | + | <div style="font-size: 0.8rem; opacity: 0.7; margin-bottom: 0.25rem;">Sample · 24 items</div> | |
| 33 | + | <div style="font-size: 0.85rem; opacity: 0.8;">Field recordings, textures, and atmospheres for your productions.</div> | |
| 34 | + | </a> | |
| 35 | + | </section> | |
| 36 | + | </div> |
| @@ -0,0 +1,19 @@ | |||
| 1 | + | <div style="max-width: 500px;"> | |
| 2 | + | <div class="form-group"> | |
| 3 | + | <label>Project Name</label> | |
| 4 | + | <input type="text" value="Liminality"> | |
| 5 | + | </div> | |
| 6 | + | ||
| 7 | + | <div class="form-group"> | |
| 8 | + | <label>Description</label> | |
| 9 | + | <textarea rows="3">Field recordings and processed ambient. Eight tracks exploring the spaces between silence and sound.</textarea> | |
| 10 | + | </div> | |
| 11 | + | ||
| 12 | + | <div class="form-group"> | |
| 13 | + | <label>Category</label> | |
| 14 | + | <input type="text" value="Music" placeholder="What kind of project is this?"> | |
| 15 | + | <div class="hint">Choose an existing category or type a new one.</div> | |
| 16 | + | </div> | |
| 17 | + | ||
| 18 | + | <button class="primary">Save Changes</button> | |
| 19 | + | </div> |
| @@ -0,0 +1,58 @@ | |||
| 1 | + | <div style="max-width: 500px;"> | |
| 2 | + | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1.5rem;"> | |
| 3 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 4 | + | <label style="font-size: 0.85rem;">Type</label> | |
| 5 | + | <select style="width: 100%;"> | |
| 6 | + | <option selected>Discount</option> | |
| 7 | + | <option>Free Access</option> | |
| 8 | + | <option>Free Trial</option> | |
| 9 | + | </select> | |
| 10 | + | </div> | |
| 11 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 12 | + | <label style="font-size: 0.85rem;">Code</label> | |
| 13 | + | <input type="text" value="LAUNCH50" style="text-transform: uppercase;"> | |
| 14 | + | </div> | |
| 15 | + | </div> | |
| 16 | + | ||
| 17 | + | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1.5rem; margin-top: 0.75rem;"> | |
| 18 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 19 | + | <label style="font-size: 0.85rem;">Discount type</label> | |
| 20 | + | <select style="width: 100%;"> | |
| 21 | + | <option selected>Percentage</option> | |
| 22 | + | <option>Fixed ($)</option> | |
| 23 | + | </select> | |
| 24 | + | </div> | |
| 25 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 26 | + | <label style="font-size: 0.85rem;">Amount</label> | |
| 27 | + | <input type="text" value="50"> | |
| 28 | + | </div> | |
| 29 | + | </div> | |
| 30 | + | ||
| 31 | + | <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1.5rem; margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border);"> | |
| 32 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 33 | + | <label style="font-size: 0.85rem;">Max uses</label> | |
| 34 | + | <input type="text" placeholder="Unlimited"> | |
| 35 | + | </div> | |
| 36 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 37 | + | <label style="font-size: 0.85rem;">Starts</label> | |
| 38 | + | <input type="text" value="2026-05-15"> | |
| 39 | + | </div> | |
| 40 | + | <div class="form-group" style="margin-bottom: 0;"> | |
| 41 | + | <label style="font-size: 0.85rem;">Expires</label> | |
| 42 | + | <input type="text" value="2026-06-15"> | |
| 43 | + | </div> | |
| 44 | + | <div class="form-group" style="margin-bottom: 0; grid-column: 1 / -1;"> | |
| 45 | + | <label style="font-size: 0.85rem;">Scope</label> | |
| 46 | + | <select style="width: 100%;"> | |
| 47 | + | <option selected>All items in project</option> | |
| 48 | + | <option>Liminality (album)</option> | |
| 49 | + | <option>Sample Library Vol. 1</option> | |
| 50 | + | </select> | |
| 51 | + | </div> | |
| 52 | + | </div> | |
| 53 | + | ||
| 54 | + | <div style="margin-top: 1rem;"> | |
| 55 | + | <button class="secondary">Create Code</button> | |
| 56 | + | </div> | |
| 57 | + | <p class="hint" style="margin-top: 0.5rem;">Percentage: 1-100. Fixed: dollar amount (e.g. 5 = $5.00 off). Free access codes auto-generate if code left blank.</p> | |
| 58 | + | </div> |
| @@ -37,7 +37,7 @@ Each creator costs money to serve. The amount depends on the tier, because deliv | |||
| 37 | 37 | | Big Files | $30/mo | $4-9/mo | $21-26/mo | | |
| 38 | 38 | | Everything | $60/mo | $5-10/mo | $50-55/mo | | |
| 39 | 39 | ||
| 40 | - | Costs include storage, CDN, transcoding, and shared infrastructure. The ranges reflect that a creator uploading weekly to a large audience costs more than one uploading monthly to a small one. | |
| 40 | + | Costs include storage, CDN, and shared infrastructure. The ranges reflect that a creator uploading weekly to a large audience costs more than one uploading monthly to a small one. | |
| 41 | 41 | ||
| 42 | 42 | Flat pricing means high-activity creators are subsidized by the average. Your tier fee shouldn't punish you for success. See the [pricing calculator](/pricing) to compare what you'd keep here versus other platforms at any revenue level. | |
| 43 | 43 |
| @@ -119,7 +119,7 @@ A note from the founder: | |||
| 119 | 119 | ||
| 120 | 120 | ## This Definition Will Change | |
| 121 | 121 | ||
| 122 | - | The generative AI landscape moves fast. This policy is a living document. When we update it, we will: | |
| 122 | + | This field moves fast. This policy is a living document. When we update it, we will: | |
| 123 | 123 | ||
| 124 | 124 | - Publish the change with a version date | |
| 125 | 125 | - Explain what changed and why |
| @@ -7,7 +7,7 @@ Flat fee. All your revenue passes through to you. | |||
| 7 | 7 | ## For Creators | |
| 8 | 8 | ||
| 9 | 9 | 1. **Sign up** and choose a pricing tier ($10-$60/month based on content type) | |
| 10 | - | 2. **Upload content**: text, audio, software, or digital files | |
| 10 | + | 2. **Upload content**: text, audio, video, software, or digital files | |
| 11 | 11 | 3. **Organize** using hierarchical tags and projects | |
| 12 | 12 | 4. **Set pricing**: free, pay-what-you-want, fixed price, or membership | |
| 13 | 13 | 5. **Get paid**: 0% platform fee, only payment processing fees | |
| @@ -69,53 +69,9 @@ The gap widens as you grow. A percentage-cut platform is most expensive exactly | |||
| 69 | 69 | | **Big Files** | $30 | Video, games, large software | 20GB | 500GB | | |
| 70 | 70 | | **Everything** | $60 | All features, current and future | 20GB | 500GB | | |
| 71 | 71 | ||
| 72 | - | All files (content, covers, downloads, supplementary materials) count toward total storage. Big Files and Everything creators can request a per-file size increase beyond 20GB from their dashboard. | |
| 72 | + | Every tier includes 0% platform fee on fan payments, custom profile, project organization, data export, memberships, RSS, promo codes, and 2FA/passkeys. Use the [pricing calculator](/pricing) to compare what you'd keep versus other platforms. | |
| 73 | 73 | ||
| 74 | - | Prices reflect what it costs to store and deliver each content type. Use the [pricing calculator](/pricing) to see what you'd keep at any revenue level, with a side-by-side comparison against other platforms. | |
| 75 | - | ||
| 76 | - | ### Choosing Your Tier | |
| 77 | - | ||
| 78 | - | | If you create... | Choose | You get | | |
| 79 | - | |------------------|--------|---------| | |
| 80 | - | | Blog posts, articles, newsletters | Basic ($10) | 50GB storage, 10MB/file | | |
| 81 | - | | Music, podcasts, audio | Small Files ($20) | 250GB storage, 500MB/file | | |
| 82 | - | | Software, plugins, sample packs | Small Files ($20) | 250GB storage, 500MB/file | | |
| 83 | - | | Games, large applications | Big Files ($30) | 500GB storage, 20GB/file | | |
| 84 | - | | Video content, courses | Big Files ($30) | 500GB storage, 20GB/file | | |
| 85 | - | | All current and future features + live streaming (roadmap) | Everything ($60) | 500GB storage, 20GB/file | | |
| 86 | - | ||
| 87 | - | ### What Every Tier Includes | |
| 88 | - | ||
| 89 | - | - Storage and per-file limits based on your tier (see table above) | |
| 90 | - | - Custom profile page (`/u/yourname`) | |
| 91 | - | - Project organization (`/p/projectname`) | |
| 92 | - | - Permanent content links (`/i/UUID`) | |
| 93 | - | - Complete data export (projects, items, blog posts, transactions) | |
| 94 | - | - Direct fan payments (0% platform fee) | |
| 95 | - | - Contact sharing with fans (opt-in at purchase) | |
| 96 | - | - RSS feed generation (project + blog feeds) | |
| 97 | - | - License keys, discount codes, download codes | |
| 98 | - | - Pay-what-you-want pricing option | |
| 99 | - | - Membership tiers with automated billing | |
| 100 | - | - Follows, broadcast emails, email notifications (sales, followers, releases, logins) | |
| 101 | - | - 2FA/TOTP, passkeys/WebAuthn, session management, account lockout | |
| 102 | - | ||
| 103 | - | ### Earn-Back Credit Program | |
| 104 | - | ||
| 105 | - | On the [roadmap](./roadmap.md#earn-back-credit-program). If your revenue doesn't cover your tier fees over 12 months, the difference is credited as free months the following year. | |
| 106 | - | ||
| 107 | - | ### Add-Ons | |
| 108 | - | ||
| 109 | - | Base tiers include all platform software features. Cloud sync, automated email, and DSP distribution have direct infrastructure costs and will be available as monthly add-ons at cost. See the [roadmap](./roadmap.md) for status. | |
| 110 | - | ||
| 111 | - | ### Billing | |
| 112 | - | ||
| 113 | - | - Monthly billing only, no annual contracts | |
| 114 | - | - Cancel anytime, effective immediately | |
| 115 | - | - No setup fees, no hidden charges | |
| 116 | - | - Upgrade or downgrade at any time | |
| 117 | - | ||
| 118 | - | See our [written guarantees](./guarantees.md) for pricing commitments. | |
| 74 | + | Monthly billing, no contracts, cancel anytime. An [earn-back credit program](./roadmap.md#earn-back-credit-program) is on the roadmap. See [Pricing Tiers](../guide/tiers.md) for full feature breakdowns, storage details, and tier selection guidance. See our [written guarantees](./guarantees.md) for pricing commitments. | |
| 119 | 75 | ||
| 120 | 76 | --- | |
| 121 | 77 |
| @@ -84,7 +84,7 @@ Everything listed here is live and working. | |||
| 84 | 84 | ### Platform | |
| 85 | 85 | ||
| 86 | 86 | - **Source-available codebase**: PolyForm Noncommercial 1.0.0 | |
| 87 | - | - **Creator applications**: Apply to create; most applications approved within a few days | |
| 87 | + | - **Creator applications**: Apply to create; most applications approved within 1 business day | |
| 88 | 88 | - **Admin tools**: Waitlist management, creator approval, suspension/appeal processing, revenue reports, data export | |
| 89 | 89 | - **Rich link previews**: Your content shows up properly when shared on social media, search engines, and podcast apps | |
| 90 | 90 | - **Documentation**: Creator guide covering the full platform | |
| @@ -100,7 +100,7 @@ Everything listed here is live and working. | |||
| 100 | 100 | - **Creator tier enforcement**: Storage tracking and per-tier limits with grace period | |
| 101 | 101 | - **Health monitoring**: Uptime tracking, service connectivity checks | |
| 102 | 102 | - **Malware scanning**: Every uploaded file is scanned for malware before it's made available | |
| 103 | - | - **Comprehensive automated test suite** covering all platform features | |
| 103 | + | - **Automated test suite** covering all platform features | |
| 104 | 104 | ||
| 105 | 105 | ### Developer Infrastructure (SyncKit) | |
| 106 | 106 |
| @@ -14,7 +14,7 @@ When a platform takes venture capital, the pressure compounds. Investors need re | |||
| 14 | 14 | ||
| 15 | 15 | ## The Alternative | |
| 16 | 16 | ||
| 17 | - | We charge a flat monthly fee based on what you need to host: $10 for text, $20 for audio and software, $30 for video and large files, $60 for live streaming and all current and future features. We take 0% of your revenue. The only deduction from fan payments is the payment processor's fee (~3%), which goes to the processor, not us. | |
| 17 | + | We charge a flat monthly fee based on what you need to host: $10 for text, $20 for audio and software, $30 for video and large files, $60 for all features, current and future. We take 0% of your revenue. The only deduction from fan payments is the payment processor's fee (~3%), which goes to the processor, not us. | |
| 18 | 18 | ||
| 19 | 19 | Your tier fee funds the platform. No incentive to take a cut of your sales, show ads to your fans, or lock you in. | |
| 20 | 20 |
| @@ -16,7 +16,7 @@ What are you making, and how should fans experience it? | |||
| 16 | 16 | | Sample packs or presets | Digital items | Any file format, download tracking | | |
| 17 | 17 | | A course | Mixed item types in a project | Combine text lessons, audio, and downloads | | |
| 18 | 18 | ||
| 19 | - | The type determines which player/viewer fans get and what features are available (chapters, versioning, etc.). | |
| 19 | + | The type determines which player/viewer fans get and which features are available (chapters, versioning, license keys). | |
| 20 | 20 | ||
| 21 | 21 | ## Items | |
| 22 | 22 |
| @@ -28,12 +28,14 @@ For software products, license keys are generated automatically on purchase: | |||
| 28 | 28 | ||
| 29 | 29 | License keys appear in the fan's library after purchase. | |
| 30 | 30 | ||
| 31 | - | ## Discount Codes | |
| 31 | + | ## Promo Codes | |
| 32 | 32 | ||
| 33 | - | Create promotional codes with: | |
| 33 | + | Create promotional codes for discounts, free trials, and free access: | |
| 34 | 34 | ||
| 35 | 35 | - **Percentage or fixed amount** off the price | |
| 36 | - | - **Scope**: Apply to a specific item or all your items | |
| 36 | + | - **Free trial** periods for memberships | |
| 37 | + | - **Free access** grants for individual items | |
| 38 | + | - **Scope**: Apply to a specific item, project, or all your items | |
| 37 | 39 | - **Usage limits**: Cap how many times a code can be used | |
| 38 | 40 | - **Expiration dates**: Codes stop working after a date you set | |
| 39 | 41 | - **Auto-apply via URL**: Share a link with `?discount=CODE` to pre-fill the code |
| @@ -6,7 +6,7 @@ Strategies for getting the most out of Makenot.work. | |||
| 6 | 6 | ||
| 7 | 7 | ### Start with Your Project | |
| 8 | 8 | ||
| 9 | - | - **Single-creator projects** are straightforward: your name, your work, your earnings. | |
| 9 | + | - **Single-creator projects**: your name, your work, your earnings. | |
| 10 | 10 | - **Collaborative projects** need upfront agreement on splits and who can publish what. | |
| 11 | 11 | - **Multiple projects** make sense if you have distinct bodies of work (a music project and a writing project, for instance). | |
| 12 | 12 | ||
| @@ -84,7 +84,7 @@ Tell fans what they're getting: how often you release, what membership includes, | |||
| 84 | 84 | ||
| 85 | 85 | ### Have a Refund Policy | |
| 86 | 86 | ||
| 87 | - | You control refund decisions. Consider adding a brief refund policy to your profile or project description. For digital goods, a reasonable policy might be: | |
| 87 | + | You control refund decisions. Consider adding a brief refund policy to your profile or project description. Examples of refund policies for digital goods: | |
| 88 | 88 | ||
| 89 | 89 | - **No refunds after download** (standard for digital content) | |
| 90 | 90 | - **Refunds within 24-48 hours if not downloaded** (generous, builds trust) |
| @@ -30,7 +30,7 @@ Pay once, own forever. Download in original format, re-download anytime, no DRM. | |||
| 30 | 30 | ||
| 31 | 31 | ### Memberships | |
| 32 | 32 | ||
| 33 | - | Monthly billing, cancel anytime. Access member-only content while subscribed; some creators grant permanent access to items released during your membership. | |
| 33 | + | Monthly billing, cancel anytime. Access member-only content while subscribed. Creators can grant permanent access to items released during your membership. | |
| 34 | 34 | ||
| 35 | 35 | ### Pay-What-You-Want | |
| 36 | 36 |
| @@ -24,14 +24,14 @@ To sell your work, apply for creator access: | |||
| 24 | 24 | 1. Go to the [creators page](/creators) or your dashboard | |
| 25 | 25 | 2. Tell us what you make (20-500 characters). A portfolio or storefront link helps | |
| 26 | 26 | 3. Choose which tier fits your content type | |
| 27 | - | 4. Optionally, request a **free trial** (2-6 weeks) | |
| 27 | + | 4. Optionally, request a **free trial** (2 weeks to 3 months) | |
| 28 | 28 | 5. Submit your application | |
| 29 | 29 | ||
| 30 | 30 | Most applications are approved within 1 business day. All applications receive a response within 5 business days. | |
| 31 | 31 | ||
| 32 | 32 | ### Free Trials | |
| 33 | 33 | ||
| 34 | - | Check "Request a free trial" when you apply. Pick a trial length (2, 4, or 6 weeks) and briefly describe what you want to test. No credit card required. At the end, join a tier to keep your content live or export everything and walk away. | |
| 34 | + | Check "Request a free trial" when you apply. Pick a trial length (2 weeks, 1 month, 2 months, or 3 months) and briefly describe what you want to test. No credit card required. At the end, join a tier to keep your content live or export everything and walk away. | |
| 35 | 35 | ||
| 36 | 36 | **Trial details:** | |
| 37 | 37 | - Trials are available by application only. Check the box when you apply for creator access. | |
| @@ -103,7 +103,7 @@ Projects organize your work -- albums, podcast feeds, or product lines. | |||
| 103 | 103 | ||
| 104 | 104 | **You are the merchant.** Payments go directly to your Stripe account. That means chargebacks ($15 dispute fee) come from your balance, not ours. A $5 refund is always cheaper than a disputed charge. See [Payouts](./payouts.md) for details. | |
| 105 | 105 | ||
| 106 | - | **Stripe is currently the only payment processor.** If Stripe doesn't operate in your country or suspends your account, you can still manage content and export data, but you won't receive payments until the issue is resolved. Check [Stripe's supported countries](https://stripe.com/global) before applying. | |
| 106 | + | **Stripe is currently the only payment processor.** If Stripe doesn't operate in your country or suspends your account, you can still manage content and export data, but you won't receive payments until the issue is resolved. See [Receiving Payouts](./payouts.md) for supported countries, payout schedules, and troubleshooting. | |
| 107 | 107 | ||
| 108 | 108 | **This is a one-person operation.** Source code is public, all data is exportable, and fan payments are held in your Stripe account (not ours). Support responses happen during business hours, not 24/7. See [What We Guarantee](../about/guarantees.md) for the continuity plan. | |
| 109 | 109 |
| @@ -75,7 +75,7 @@ Unpublish anytime to revert an item to draft. All settings and data are retained | |||
| 75 | 75 | ||
| 76 | 76 | From the project dashboard, select multiple items to: | |
| 77 | 77 | ||
| 78 | - | - **Publish**: Make several items public at once | |
| 78 | + | - **Publish**: Make multiple items public at once | |
| 79 | 79 | - **Unpublish**: Revert multiple items to draft | |
| 80 | 80 | - **Delete**: Remove multiple items permanently | |
| 81 | 81 |
| @@ -63,11 +63,13 @@ When customization ships, it will be available on every tier. | |||
| 63 | 63 | ||
| 64 | 64 | When someone visits your profile, they see: | |
| 65 | 65 | ||
| 66 | - | 1. **Header image** (if set) spanning the top | |
| 67 | - | 2. **Profile picture** and **display name** | |
| 68 | - | 3. **Bio** text | |
| 69 | - | 4. **Links** as clickable buttons | |
| 70 | - | 5. **Projects** listed below, each showing its cover art, title, and description | |
| 66 | + | 1. **Profile picture** and **display name** | |
| 67 | + | 2. **Bio** text | |
| 68 | + | 3. **Follow button** and follower count | |
| 69 | + | 4. **Custom links** as clickable cards | |
| 70 | + | 5. **Projects** listed below, each showing title, category, item count, and description | |
| 71 | + | ||
| 72 | + | > [!UI] profile-layout | |
| 71 | 73 | ||
| 72 | 74 | Most fans click through to a specific project within seconds, so your bio and links need to do their work fast. | |
| 73 | 75 |