max / makenotwork
31 files changed,
+1929 insertions,
-191 deletions
| @@ -3445,7 +3445,7 @@ dependencies = [ | |||
| 3445 | 3445 | ||
| 3446 | 3446 | [[package]] | |
| 3447 | 3447 | name = "makenotwork" | |
| 3448 | - | version = "0.5.14" | |
| 3448 | + | version = "0.5.15" | |
| 3449 | 3449 | dependencies = [ | |
| 3450 | 3450 | "anyhow", | |
| 3451 | 3451 | "argon2", | |
| @@ -4987,6 +4987,7 @@ version = "0.1.1" | |||
| 4987 | 4987 | dependencies = [ | |
| 4988 | 4988 | "aws-config", | |
| 4989 | 4989 | "aws-sdk-s3", | |
| 4990 | + | "tokio", | |
| 4990 | 4991 | "tracing", | |
| 4991 | 4992 | ] | |
| 4992 | 4993 |
| @@ -71,6 +71,9 @@ sha2 = "0.10.9" | |||
| 71 | 71 | hex = "0.4.3" | |
| 72 | 72 | base64 = "0.22.1" | |
| 73 | 73 | ||
| 74 | + | # Temp files (content export) | |
| 75 | + | tempfile = "3" | |
| 76 | + | ||
| 74 | 77 | # File scanning | |
| 75 | 78 | infer = "0.19" | |
| 76 | 79 | goblin = "0.10" |
| @@ -1,11 +1,11 @@ | |||
| 1 | 1 | # MakeNotWork -- Audit Review | |
| 2 | 2 | ||
| 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) | |
| 3 | + | **Last audited:** 2026-05-11 (Run 25, Ultra Fuzz -- 5-axis deep audit) | |
| 4 | + | **Previous audit:** 2026-05-09 (Run 24, Ultra Fuzz -- 5-axis deep audit) | |
| 5 | 5 | ||
| 6 | 6 | ## Overall Grade: A | |
| 7 | 7 | ||
| 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. | |
| 8 | + | Run 25: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.14. ~88,978 LOC. ~1,225 test annotations. 111 migrations. 3 SERIOUS + 6 MINOR findings identified. 5 cold spots. 5/5 axes at A. All Run 24 fixes verified intact. No regressions. | |
| 9 | 9 | ||
| 10 | 10 | ## Scorecard | |
| 11 | 11 | ||
| @@ -13,9 +13,9 @@ Run 24: 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 | | |
| 16 | + | | Testing | A | ~1,225 test annotations, proptest active, adversarial tests, comprehensive harness | | |
| 17 | 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 | | |
| 18 | + | | Performance | A | Discover facets parallelized. Batch loading. Bounded scan semaphore. Advisory lock pinned | | |
| 19 | 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 | 21 | | Frontend | A | Askama auto-escape, json_escape prevents JSON-LD XSS, HTMX patterns consistent. CSRF field names consistent | | |
| @@ -24,7 +24,7 @@ Run 24: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 24 | 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 | 107 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout | | |
| 27 | + | | Migration Safety | A | 111 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout | | |
| 28 | 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 | |
| @@ -42,33 +42,39 @@ Run 24: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 42 | 42 | | storage.rs | A | A | A | A | A | A | A+ | B+ | A | | |
| 43 | 43 | | pricing.rs | A | A+ | A+ | A | A | A | A- | n/a | A | | |
| 44 | 44 | | crypto.rs | A | A | A | A+ | A | A | A | n/a | A | | |
| 45 | - | | formatting.rs | A | A | A+ | A | A | A- | A | n/a | A | | |
| 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 | 48 | | metrics.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 49 | 49 | | db/enums.rs | A | A | A | A | n/a | A | A+ | n/a | A | | |
| 50 | - | | db/id_types.rs | A | A | A | n/a | n/a | A | A+ | n/a | A | | |
| 50 | + | | db/id_types.rs | A+ | A | A | n/a | n/a | A | A+ | n/a | A+ | | |
| 51 | 51 | | db/validated_types.rs | A | A | A+ | A | A | A | A+ | n/a | A | | |
| 52 | 52 | | db/users.rs | A | A | B | A | A- | A | A | A | A | | |
| 53 | 53 | | db/items.rs | A | A | B | A | A- | A | A | A | A | | |
| 54 | 54 | | db/synckit.rs | A | A | B | A | A | A | A | A | A | | |
| 55 | - | | db/transactions.rs | A | A | B+ | A | A | A | A | A | A | | |
| 55 | + | | db/transactions.rs | A | A | B+ | A | A | A | A- | A | A | | |
| 56 | 56 | | db/discover.rs | A | A | B | A | A | A | A | n/a | A- | | |
| 57 | - | | db/cart.rs | A | A | B | A | A | A | A | n/a | A | | |
| 57 | + | | db/cart.rs | **B+** | A | B | A | A | A | A | n/a | A | | |
| 58 | 58 | | db/creator_tiers.rs | A | A+ | A | A | A | A | A | n/a | A | | |
| 59 | 59 | | db/versions.rs | A | A | A | A | A | A | A | n/a | A | | |
| 60 | 60 | | db/builds.rs | A | A+ | A | A | A | A | A | n/a | A | | |
| 61 | - | | db/pending_refunds.rs | A | A | B | A | A | A | A | n/a | A | | |
| 61 | + | | db/pending_refunds.rs | A+ | A | B | A | A | A | A | n/a | A | | |
| 62 | + | | db/pending_s3_deletions.rs | A | A- | n/a | A | A | A | A | n/a | A | | |
| 62 | 63 | | db/license_keys.rs | A | A | B | A | A | A | A | n/a | A | | |
| 63 | 64 | | db/promo_codes.rs | A | A | A | A | A | A | A | n/a | A | | |
| 64 | 65 | | db/tips.rs | A | A | B | A | A | A | A | n/a | A | | |
| 65 | 66 | | 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 | | |
| 67 | + | | db/pending_uploads.rs | A | A | n/a | A | A | A | A | n/a | A | | |
| 68 | + | | db/moderation.rs | A | A | B+ | A | n/a | A | A | A | A | | |
| 69 | + | | db/reports.rs | A | A | B+ | A | n/a | A | A | A | A | | |
| 67 | 70 | | db/models/* | A | A | B+ | A | n/a | A- | A | n/a | A | | |
| 68 | 71 | | types/ | A | A | B | A | n/a | A | A | n/a | A | | |
| 69 | 72 | | scanning/ | A | A+ | A- | A+ | A- | A | A- | A | A | | |
| 70 | - | | scanning/archive.rs | A | A | A- | A- | A- | A | A- | A | A | | |
| 73 | + | | scanning/archive.rs | A- | A | A- | A- | A- | A | A- | A | A | | |
| 74 | + | | scanning/structural.rs | A- | A | A | B+ | A | A | A | A- | A | | |
| 71 | 75 | | payments/ | A | A | A- | A | A- | A | A | A | B+ | | |
| 76 | + | | payments/webhooks.rs | A | A | A | A | A | A | A | A | A | | |
| 77 | + | | payments/checkout.rs | A- | A | A- | A | A | A | A- | A | A | | |
| 72 | 78 | | email/ | A | A | A | A | A- | A | A | B+ | A- | | |
| 73 | 79 | | scheduler/ | A | A+ | B+ | A | A | A | A | A- | A | | |
| 74 | 80 | | scheduler/cleanup.rs | A | A | n/a | A | A | A | A | A- | A | | |
| @@ -79,13 +85,14 @@ Run 24: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 79 | 85 | | build_runner.rs | A | A- | B+ | A+ | A- | A- | A | A | A | | |
| 80 | 86 | | monitor.rs | A | A | A- | A | A | A | A | A+ | A | | |
| 81 | 87 | | templates/ | A | A- | n/a | A | A | A- | A | B+ | B+ | | |
| 88 | + | | templates/purchase.html | A- | n/a | n/a | A | n/a | n/a | n/a | n/a | **B+** | | |
| 82 | 89 | | routes/auth.rs | A | A | n/a | A+ | A | A | A | A | A | | |
| 83 | - | | routes/oauth.rs | A | A | n/a | A- | A | A | A | A- | A- | | |
| 90 | + | | routes/oauth.rs | A | A | n/a | A | A | A | A | A- | A- | | |
| 84 | 91 | | routes/admin/ | A | A | n/a | A | A | A | A | A | A | | |
| 85 | 92 | | 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 | | |
| 93 | + | | routes/api/projects.rs (delete) | **B-** | A | n/a | A | A | A | A | A | A | | |
| 88 | 94 | | routes/api/exports/ | A- | A- | n/a | A | A- | A | A | A | A- | | |
| 95 | + | | routes/api/exports/content.rs | **B+** | A- | n/a | A | **B+** | A | A | A | A | | |
| 89 | 96 | | routes/stripe/ | A | A | n/a | A | A- | A | A | A | B+ | | |
| 90 | 97 | | routes/stripe/checkout/ | A | A | n/a | A- | A | A | A | A | A | | |
| 91 | 98 | | routes/stripe/checkout/cart.rs | A | A | n/a | A- | A | A | A | A | A | | |
| @@ -95,17 +102,22 @@ Run 24: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.9 | |||
| 95 | 102 | | routes/embed/ | A | A | n/a | A | A | A- | A | A | A | | |
| 96 | 103 | | routes/git/ | A | A | n/a | A | A | A | A | A | A | | |
| 97 | 104 | | 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 | | |
| 105 | + | | routes/storage/versions.rs | A | A- | n/a | A | A | A | A | A | A | | |
| 99 | 106 | | routes/storage/uploads.rs | A | A | n/a | A | A | A | A | A | A | | |
| 100 | 107 | | routes/storage/downloads.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 101 | - | | routes/storage/images.rs | A | A | n/a | A | A | A | A | A | A | | |
| 102 | - | | routes/postmark/ | A | A | n/a | A | A | A | n/a | A- | B+ | | |
| 108 | + | | routes/storage/images.rs | **B+** | A | n/a | A | A | A | A | A | A- | | |
| 103 | 109 | ||
| 104 | 110 | **Bold** = cold spot (B or below). | |
| 105 | 111 | ||
| 106 | 112 | ### Cold Spots | |
| 107 | 113 | ||
| 108 | - | None. All Run 24 cold spots resolved in remediation. | |
| 114 | + | | Module | Grade | Issue | | |
| 115 | + | |--------|:-----:|-------| | |
| 116 | + | | db/cart.rs | B+ | SQL references `t.user_id` instead of `t.buyer_id` — runtime error | | |
| 117 | + | | routes/api/projects.rs (delete) | B- | Project deletion orphans S3 objects, no storage decrement | | |
| 118 | + | | routes/api/exports/content.rs | B+ | Buffers up to 2GB ZIP in memory per export request | | |
| 119 | + | | routes/storage/images.rs | B+ | Non-atomic 3-UPDATE cover image, non-atomic storage swap on project images, missing S3 cleanup | | |
| 120 | + | | templates/purchase.html | B+ | Cart add-to-cart JS doesn't check HTTP error status | | |
| 109 | 121 | ||
| 110 | 122 | ### Resolved Cold Spots (from Run 24) | |
| 111 | 123 | ||
| @@ -123,20 +135,22 @@ None. All Run 24 cold spots resolved in remediation. | |||
| 123 | 135 | ||
| 124 | 136 | ## Mandatory Surprises | |
| 125 | 137 | ||
| 126 | - | **Run 24 (5 surprises, one per axis):** | |
| 138 | + | **Run 25 (5 surprises, one per axis):** | |
| 127 | 139 | ||
| 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. | |
| 140 | + | 1. **Payments -- Pending refund queue with FOR UPDATE SKIP LOCKED (unexpectedly good):** `db/pending_refunds.rs` + `checkout_helpers.rs:check_pending_refund`. When a `charge.refunded` webhook arrives before `checkout.session.completed`, the refund is queued and processed after checkout completion. Claims use `FOR UPDATE SKIP LOCKED` for safe concurrency. Stale refunds are escalated. This solves one of the hardest webhook ordering problems correctly. | |
| 129 | 141 | ||
| 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. | |
| 142 | + | 2. **Storage -- Durable S3 deletion queue as transactional outbox (unexpectedly good):** `db/pending_s3_deletions.rs` + `scheduler/cleanup.rs:365-418`. Every destructive path enqueues S3 keys to a durable DB table before deletion. Retry with attempt counting, `FOR UPDATE SKIP LOCKED`, and prefix vs single-key distinction. Production-grade pattern -- the one exception (project deletion, see SERIOUS finding) makes its absence there even more glaring. | |
| 131 | 143 | ||
| 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. | |
| 144 | + | 3. **UX Wiring -- json_escape with HTML entity protection (unexpectedly good):** `types/mod.rs:214`. Hand-rolled escaper goes beyond standard JSON to also escape `<`, `>`, `&` as Unicode escapes for safe embedding in `<script>` tags via `|safe`. Combined with `build_segments_json`'s `</` to `<\/` replacement. Defense-in-depth approach most codebases skip entirely. | |
| 133 | 145 | ||
| 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. | |
| 146 | + | 4. **Security -- Anti-timing dummy hash on ALL login paths (unexpectedly good):** `routes/auth.rs:29-31` and `routes/oauth.rs:33-35`. Pre-computed `DUMMY_HASH` via `LazyLock` ensures "user not found" takes the same wall-clock time as "wrong password". Applied independently across both web and OAuth login surfaces. Plus `crypto.rs:7-18` hashes both inputs with SHA-256 before constant-time XOR comparison -- neutralizes length-leak. | |
| 135 | 147 | ||
| 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. | |
| 148 | + | 5. **Performance -- Discover page runs 5 facet queries in parallel via tokio::try_join! (unexpectedly good):** `routes/pages/public/discover.rs:342-361`. Type counts, tag counts, followed tags, AI tier counts, and price range counts all run concurrently. Textbook optimization that most production codebases miss. The codebase consistently avoids N+1 through batch loading across exports, items, and versions. | |
| 137 | 149 | ||
| 138 | 150 | ### Previous Surprises | |
| 139 | 151 | ||
| 152 | + | **Run 24:** Claim token architecture, LATERAL join batch storage recalc, CSRF constant-time comparison, TOTP replay prevention via time step, storage quota atomicity + idempotency checks. | |
| 153 | + | ||
| 140 | 154 | **Run 23:** Webhook signature gold standard, atomic storage quota enforcement, CSRF dual-layer extraction, archive decompression fallback (bad, fixed), advisory lock protocol. | |
| 141 | 155 | ||
| 142 | 156 | **Run 22:** Webhook signature gold standard, cleanup scheduler durable queue, form error recovery, defense-in-depth 6 layers, export RAM buffering. | |
| @@ -156,80 +170,100 @@ Zero SQL injection vectors across 200+ queries. Argon2id (46MiB/2 iterations), S | |||
| 156 | 170 | 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. | |
| 157 | 171 | ||
| 158 | 172 | ### 3. Payment robustness | |
| 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. | |
| 173 | + | Three-layer webhook idempotency. Bidirectional pending refund matching with FOR UPDATE SKIP LOCKED. 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. | |
| 160 | 174 | ||
| 161 | 175 | ### 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. | |
| 176 | + | All Run 23-24 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. | |
| 163 | 177 | ||
| 164 | 178 | ## Weaknesses | |
| 165 | 179 | ||
| 166 | - | ### Active (Run 24) | |
| 180 | + | ### Active (Run 25) | |
| 167 | 181 | ||
| 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. | |
| 182 | + | - **Cart preflight SQL column name bug** -- `db/cart.rs:106` references `t.user_id` which does not exist on the `transactions` table (column is `buyer_id`). Runtime SQL error on cart toggle preflight. | |
| 183 | + | - **Project deletion orphans S3 objects** -- `routes/api/projects.rs:270-282` CASCADEs DB records but does not enqueue S3 files for deletion or decrement storage counters. Permanent S3 orphans. | |
| 184 | + | - **Content export buffers up to 2GB in memory** -- `routes/api/exports/content.rs:120-127` writes entire ZIP to in-memory Vec. OOM risk under concurrent export requests. | |
| 185 | + | - **Non-atomic item cover image update** -- `routes/storage/images.rs:369-371` does 3 separate UPDATEs for cover_image_url, cover_s3_key, and cover_file_size_bytes. Crash between any two leaves inconsistent state. | |
| 186 | + | - **Project image non-atomic storage swap** -- `routes/storage/images.rs:173-185` decrements then increments in two queries. Should use `try_replace_storage`. | |
| 187 | + | - **Project image missing S3 deletion enqueue** -- `routes/storage/images.rs:173-179` does not enqueue old project image to `pending_s3_deletions`. Item images do clean up. | |
| 170 | 188 | ||
| 171 | - | ### Resolved (Run 23) | |
| 189 | + | ### Resolved (Run 24) | |
| 172 | 190 | ||
| 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. | |
| 191 | + | - ~~Guest checkout promo code validation gap~~ -- Fixed. Added `starts_at` check. | |
| 192 | + | - ~~Version replace storage counter corruption~~ -- Fixed. Uses `try_replace_storage`. | |
| 177 | 193 | ||
| 178 | 194 | ## Bug Reports by Axis | |
| 179 | 195 | ||
| 180 | 196 | ### Payments | |
| 181 | - | 0 CRITICAL, 1 SERIOUS, 0 MINOR, 2 NOTE | |
| 197 | + | 0 CRITICAL, 1 SERIOUS, 0 MINOR, 4 NOTE | |
| 182 | 198 | ||
| 183 | 199 | | # | Sev | Location | Description | | |
| 184 | 200 | |---|-----|----------|-------------| | |
| 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. | | |
| 201 | + | | P1 | **SERIOUS** | `db/cart.rs:106` | SQL references `t.user_id` instead of `t.buyer_id`. Column does not exist on `transactions` table. Runtime SQL error on cart toggle preflight check. | | |
| 202 | + | | P2 | NOTE | `payments/checkout.rs:33` | `CartLineItem.amount_cents` is `i64` while `CheckoutParams.amount_cents` is `Cents`. Inconsistency, not exploitable. | | |
| 203 | + | | P3 | NOTE | `formatting.rs:11` | `format_price` uses `cents as f64 / 100.0`. Safe for typical amounts but code smell in monetary code. | | |
| 204 | + | | P4 | NOTE | `db/transactions.rs:462` | `CreateProjectTransactionParams.amount_cents` is `i32` while item transactions use `Cents` (i64). | | |
| 205 | + | | P5 | NOTE | `pricing.rs:134` | `FixedPricing::validate_amount` has no upper cap (PWYW caps at $10K). Low impact -- Stripe session amount is server-side. | | |
| 188 | 206 | ||
| 189 | 207 | ### Storage | |
| 190 | - | 0 CRITICAL, 1 SERIOUS, 1 MINOR, 1 NOTE | |
| 208 | + | 0 CRITICAL, 1 SERIOUS, 3 MINOR, 2 NOTE | |
| 191 | 209 | ||
| 192 | 210 | | # | Sev | Location | Description | | |
| 193 | 211 | |---|-----|----------|-------------| | |
| 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. | | |
| 212 | + | | S1 | **SERIOUS** | `routes/api/projects.rs:270-282` | Project deletion CASCADEs DB records but orphans all S3 objects (audio, cover images, version downloads, video). No storage decrement. | | |
| 213 | + | | S2 | MINOR | `routes/storage/images.rs:369-371` | Item cover update: 3 separate UPDATEs for url, s3_key, file_size. Crash-unsafe. | | |
| 214 | + | | S3 | MINOR | `routes/storage/images.rs:173-185` | Project image replace: non-atomic decrement/increment. Should use `try_replace_storage`. | | |
| 215 | + | | S4 | MINOR | `routes/storage/images.rs:173-179` | Project image replace: old S3 object not enqueued to `pending_s3_deletions`. | | |
| 216 | + | | S5 | NOTE | `routes/api/content_insertions.rs:301-309` | Delete-before-decrement order. If decrement fails, storage counter overstated until weekly recalc. | | |
| 217 | + | | S6 | NOTE | `routes/storage/uploads.rs:250-253` | Dynamic SQL column names via `format!()` from internal enum. Not injection risk, but bypasses compile-time checking. | | |
| 197 | 218 | ||
| 198 | 219 | ### UX Wiring | |
| 199 | - | 0 SERIOUS, 1 MINOR, 3 NOTE | |
| 220 | + | 0 CRITICAL, 0 SERIOUS, 2 MINOR, 3 NOTE | |
| 200 | 221 | ||
| 201 | 222 | | # | Sev | Location | Description | | |
| 202 | 223 | |---|-----|----------|-------------| | |
| 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). | | |
| 224 | + | | U1 | MINOR | `templates/pages/purchase.html:142` | Cart add-to-cart JS `fetch()` does not check response status before redirecting to `/cart`. 4xx/5xx silently redirect. | | |
| 225 | + | | U2 | MINOR | `formatting.rs:8-9` | `format_price(-500)` produces `$-5.00` vs `format_revenue(-500)` = `-$5.00`. Inconsistent negative formatting. | | |
| 226 | + | | U3 | NOTE | `routes/pages/public/discover.rs:85` | No upper bound on page param. `.max(1)` clamp only. DB returns empty; no impact. | | |
| 227 | + | | U4 | NOTE | `routes/pages/public/join_wizard.rs:88-95` | Minimal email validation (checks `@` + `.`). Acceptable -- verification email is the real gate. | | |
| 228 | + | | U5 | NOTE | `routes/pages/public/content/item.rs:340` | `cdn_base` variable scope spans entire monolithic function. Refactoring hazard, not a bug. | | |
| 207 | 229 | ||
| 208 | 230 | ### Security | |
| 209 | - | 0 CRITICAL, 0 SERIOUS, 0 MINOR, 3 NOTE | |
| 231 | + | 0 CRITICAL, 0 SERIOUS, 0 MINOR, 7 NOTE | |
| 210 | 232 | ||
| 211 | 233 | | # | Sev | Location | Description | | |
| 212 | 234 | |---|-----|----------|-------------| | |
| 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. | | |
| 235 | + | | X1 | NOTE | `auth.rs:206` | `MaybeUser` skips session revocation check (documented, intentional). OAuth compensates with manual validation. | | |
| 236 | + | | X2 | NOTE | `routes/api/users/profile.rs:140-146` | Breached password change is advisory-only. Intentional UX decision. | | |
| 237 | + | | X3 | NOTE | `db/totp.rs:22` | TOTP secret stored as plaintext in DB. Best practice would encrypt at rest. | | |
| 238 | + | | X4 | NOTE | `synckit_auth.rs:57-58` | JWT `iat` claim not validated. Standard practice; `exp` is validated. | | |
| 239 | + | | X5 | NOTE | `scanning/archive.rs:137-157` | Nested archives counted but not recursively scanned. YARA + ClamAV still scan outer bytes. | | |
| 240 | + | | X6 | NOTE | `scanning/structural.rs:37-38` | `posix_spawn` and `dlopen` in suspicious symbols list may false-positive on legitimate macOS apps. | | |
| 241 | + | | X7 | NOTE | `main.rs:116-121` | Session cookie not prefixed with `__Host-`. Low risk -- no user-content subdomains. | | |
| 216 | 242 | ||
| 217 | 243 | ### Performance | |
| 218 | - | 0 CRITICAL, 0 SERIOUS, 2 MINOR, 1 NOTE | |
| 244 | + | 0 CRITICAL, 1 SERIOUS, 3 MINOR, 4 NOTE | |
| 219 | 245 | ||
| 220 | 246 | | # | Sev | Location | Description | | |
| 221 | 247 | |---|-----|----------|-------------| | |
| 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. | | |
| 248 | + | | F1 | **SERIOUS** | `routes/api/exports/content.rs:120-127` | Content export accumulates up to 2GB in an in-memory `Vec<u8>` ZIP buffer. OOM risk under concurrent exports. | | |
| 249 | + | | F2 | MINOR | `routes/api/exports/mod.rs:268-270` | N+1 query: collection items loaded per-collection in a loop (up to 50 queries). | | |
| 250 | + | | F3 | MINOR | `build_runner.rs:396-408` | Build artifact read entirely into memory via `tokio::fs::read` before S3 upload. Mitigated by single-build advisory lock. | | |
| 251 | + | | F4 | MINOR | `db/mod.rs:93` | `check_sandbox_cap` uses blocking `pg_advisory_lock` instead of `pg_try_advisory_lock`. Latency spike under burst. | | |
| 252 | + | | F5 | NOTE | `scanning/mod.rs:68` | Scan pipeline takes `&[u8]` requiring full file in memory. Semaphore limits to 4 x 100MB = 400MB worst case. | | |
| 253 | + | | F6 | NOTE | `lib.rs:238` | CSP header reads `S3_ENDPOINT` from `env::var` every request. Should use `state.config`. | | |
| 254 | + | | F7 | NOTE | `routes/synckit/subscribe.rs:41-58` | SseConnectionGuard Drop has TOCTOU race on DashMap counter. Worst case: stale entry (harmless). | | |
| 255 | + | | F8 | NOTE | `payments/checkout.rs:33` | `CartLineItem.amount_cents` is raw `i64` instead of `Cents` type. | | |
| 225 | 256 | ||
| 226 | 257 | ## Cross-Cutting Concerns | |
| 227 | 258 | ||
| 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. | |
| 259 | + | ### Project deletion missing S3 cleanup (Storage + Payments) | |
| 260 | + | Project deletion CASCADEs transactions, items, and versions in the DB but never touches S3 or storage counters. The `pending_s3_deletions` durable queue pattern exists and is used everywhere else (item deletion, version replacement, soft-delete purge). Its absence here is inconsistent and the most impactful finding of this audit. | |
| 261 | + | ||
| 262 | + | ### Content export memory pressure (Storage + Performance) | |
| 263 | + | The content export buffers an entire ZIP in memory (up to 2GB). This crosses both the storage axis (resource management) and performance axis (OOM risk). The route is rate-limited to 3 req/sec and requires authentication, limiting blast radius, but a single malicious creator could consume 2GB of server RAM. | |
| 230 | 264 | ||
| 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. | |
| 265 | + | ### Image upload atomicity gap (Storage + UX) | |
| 266 | + | Both project image and item cover image confirms have atomicity issues. The item path does 3 separate UPDATEs; the project path does non-atomic storage swap and skips S3 cleanup. The version upload path (`uploads.rs`) correctly uses `try_replace_storage` and `pending_s3_deletions` -- the image paths should follow the same pattern. | |
| 233 | 267 | ||
| 234 | 268 | ## Components Successfully Stress-Tested | |
| 235 | 269 | ||
| @@ -245,67 +279,80 @@ XSS via template injection, open redirect, user enumeration, markdown/HTML injec | |||
| 245 | 279 | ### Security (17 vectors survived) | |
| 246 | 280 | 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). | |
| 247 | 281 | ||
| 248 | - | ### Performance (10 vectors survived) | |
| 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). | |
| 282 | + | ### Performance (12 vectors survived) | |
| 283 | + | Connection pool exhaustion (bounded at 25), file scanning memory (semaphore of 4), SSE connection accumulation (bounded at 10/user), 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 uploads via presigned URLs), global lock contention (DashMap, no Mutex/RwLock), graceful shutdown (10s drain). | |
| 250 | 284 | ||
| 251 | 285 | ## Confidence Assessment | |
| 252 | 286 | ||
| 253 | 287 | | Axis | Confidence | Notes | | |
| 254 | 288 | |------|-----------|-------| | |
| 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. | | |
| 289 | + | | Payments | HIGH | One SQL column name bug in preflight. Core payment logic (webhooks, checkout, refunds) excellent. | | |
| 290 | + | | Storage | HIGH | Project deletion S3 orphan gap. All other paths use durable deletion queue correctly. | | |
| 291 | + | | UX Wiring | HIGH | CSRF excellent. Templates safe. Minor JS and formatting polish only. | | |
| 292 | + | | Security | HIGH | No auth bypasses. DUMMY_HASH on all login paths. Fail-closed scanning. Zero SERIOUS findings. | | |
| 293 | + | | Performance | HIGH (current scale) | Export memory buffering is the main concern. Pool, rate limiting, and concurrency are solid. | | |
| 260 | 294 | ||
| 261 | 295 | ## Metrics | |
| 262 | 296 | ||
| 263 | - | - Modules audited: 55+ | |
| 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 | |
| 297 | + | - Modules audited: 60+ | |
| 298 | + | - Total cold spots: 5 | |
| 299 | + | - Bugs by severity: 0 critical, 3 serious, 8 minor, 20 note | |
| 266 | 300 | - Axes at A or above: 5/5 | |
| 267 | 301 | ||
| 268 | 302 | ## Axis Summary Grades | |
| 269 | 303 | ||
| 270 | 304 | | Axis | Overall | Cold Spots | Mandatory Surprise | | |
| 271 | 305 | |------|---------|------------|-------------------| | |
| 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) | | |
| 306 | + | | Payments | A | db/cart.rs (B+) | Pending refund queue with FOR UPDATE SKIP LOCKED (good) | | |
| 307 | + | | Storage | A | projects.rs delete (B-), images.rs (B+) | Durable S3 deletion queue as transactional outbox (good) | | |
| 308 | + | | UX Wiring | A | purchase.html (B+) | json_escape with HTML entity protection (good) | | |
| 309 | + | | Security | A | None | Anti-timing dummy hash on ALL login paths (good) | | |
| 310 | + | | Performance | A | exports/content.rs (B+) | Discover page 5-way parallel facet queries (good) | | |
| 277 | 311 | ||
| 278 | 312 | ## Recommended Priority Order | |
| 279 | 313 | ||
| 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 | |
| 314 | + | 1. **[SERIOUS]** Fix `db/cart.rs:106` -- change `t.user_id` to `t.buyer_id` (1 line) | |
| 315 | + | 2. **[SERIOUS]** Add S3 cleanup to project deletion -- collect S3 keys, enqueue to `pending_s3_deletions`, decrement storage (moderate effort) | |
| 316 | + | 3. **[SERIOUS]** Stream content export ZIP to S3 via multipart upload instead of in-memory buffer (medium effort) | |
| 317 | + | 4. **[MINOR]** Consolidate item cover image update into single UPDATE (trivial) | |
| 318 | + | 5. **[MINOR]** Use `try_replace_storage` for project image replace (low effort) | |
| 319 | + | 6. **[MINOR]** Enqueue old project image to `pending_s3_deletions` (trivial) | |
| 320 | + | 7. **[MINOR]** Add error status check to purchase.html cart JS (trivial) | |
| 321 | + | 8. **[MINOR]** Fix `format_price` negative formatting to match `format_revenue` (trivial) | |
| 322 | + | 9. **[MINOR]** Batch-load collection items in export to eliminate N+1 (low effort) | |
| 323 | + | 10. **[MINOR]** Switch `check_sandbox_cap` to `pg_try_advisory_lock` (trivial) | |
| 324 | + | 11. **[DEFERRED]** Stream build artifacts to S3 via multipart upload | |
| 325 | + | 12. **[DEFERRED]** Extract shared `validate_promo_code()` helper to prevent checkout path divergence | |
| 286 | 326 | ||
| 287 | 327 | ## Action Items | |
| 288 | 328 | ||
| 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. | |
| 329 | + | ### Run 25 (2026-05-11) | |
| 330 | + | ||
| 331 | + | 103. **[SERIOUS]** Fix `db/cart.rs:106` -- change `t.user_id` to `t.buyer_id` | |
| 332 | + | 104. **[SERIOUS]** Add S3 cleanup + storage decrement to project deletion path | |
| 333 | + | 105. **[SERIOUS]** Stream content export ZIP to S3 instead of in-memory buffer | |
| 334 | + | 106. **[MINOR]** Consolidate item cover image into single UPDATE (`routes/storage/images.rs:369-371`) | |
| 335 | + | 107. **[MINOR]** Use `try_replace_storage` for project image replace (`routes/storage/images.rs:173-185`) | |
| 336 | + | 108. **[MINOR]** Enqueue old project image S3 key to `pending_s3_deletions` | |
| 337 | + | 109. **[MINOR]** Add HTTP error check to purchase.html cart add-to-cart JS | |
| 338 | + | 110. **[MINOR]** Fix `format_price` negative formatting to use `-$X.XX` | |
| 339 | + | 111. **[MINOR]** Batch-load collection items in export handler (`exports/mod.rs:268-270`) | |
| 340 | + | 112. **[MINOR]** Switch `check_sandbox_cap` to `pg_try_advisory_lock` | |
| 341 | + | 113. **[DEFERRED]** Stream build artifacts to S3 via multipart upload | |
| 342 | + | 114. **[DEFERRED]** Extract shared `validate_promo_code()` helper | |
| 343 | + | ||
| 344 | + | ### Run 24 (2026-05-09) -- All Fixed | |
| 345 | + | ||
| 346 | + | 97. ~~**[SERIOUS]** Add `starts_at` validation to guest checkout~~ -- **Fixed.** | |
| 347 | + | 98. ~~**[SERIOUS]** Fix version replace storage counter rollback~~ -- **Fixed.** | |
| 348 | + | 99. ~~**[MINOR]** Change pending_uploads ON CONFLICT to DO NOTHING~~ -- **Fixed.** | |
| 349 | + | 100. ~~**[MINOR]** Blog editor: preserve form input on validation error~~ -- **False positive.** | |
| 350 | + | 101. ~~**[MINOR]** Idempotency middleware: avoid double allocation~~ -- **Fixed.** | |
| 351 | + | 102. **[DEFERRED]** Extract shared `validate_promo_code()` helper -- carried to Run 25 item 114. | |
| 297 | 352 | ||
| 298 | 353 | ### Run 23 (2026-05-09) -- All Fixed | |
| 299 | 354 | ||
| 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.** | |
| 355 | + | 88-96. All 9 items verified fixed. See Run 24 verification table. | |
| 309 | 356 | ||
| 310 | 357 | ### Open (blocked on upstream) | |
| 311 | 358 | ||
| @@ -314,56 +361,62 @@ Connection pool exhaustion (bounded at 25), file scanning memory (semaphore of 4 | |||
| 314 | 361 | 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049) | |
| 315 | 362 | 33. bincode unmaintained (RUSTSEC-2025-0141) -- upstream via syntect/yara-x, warning only | |
| 316 | 363 | ||
| 317 | - | ## Previous Action Item Verification (Run 23) | |
| 364 | + | ## Previous Action Item Verification (Run 24) | |
| 318 | 365 | ||
| 319 | 366 | | # | Item | Status | | |
| 320 | 367 | |---|------|--------| | |
| 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. | |
| 368 | + | | 97 | Add `starts_at` validation to guest checkout | **Fixed** (verified) | | |
| 369 | + | | 98 | Fix version replace storage counter rollback | **Fixed** (verified: uses `try_replace_storage`) | | |
| 370 | + | | 99 | Change pending_uploads ON CONFLICT to DO NOTHING | **Fixed** (verified) | | |
| 371 | + | | 100 | Blog editor: preserve form input | **False positive** (JS fetch preserves state) | | |
| 372 | + | | 101 | Idempotency middleware double allocation | **Fixed** (verified) | | |
| 373 | + | | 102 | Extract shared validate_promo_code() | **Deferred** -- carried to Run 25 | | |
| 374 | + | ||
| 375 | + | 5 of 5 actionable Run 24 items verified fixed. 1 deferred item carried forward. | |
| 332 | 376 | ||
| 333 | 377 | ### Chronic Items (unfixed across 3+ consecutive runs) | |
| 334 | 378 | ||
| 335 | - | None. Both previous chronic items (README.md, oversized route files) resolved in Run 23. | |
| 379 | + | None. The `validate_promo_code()` extraction has been deferred for 2 consecutive runs (24, 25) -- will become chronic if still open at Run 26. | |
| 336 | 380 | ||
| 337 | - | ### False Positives Identified (Run 24) | |
| 381 | + | ### False Positives Identified (Run 25) | |
| 338 | 382 | ||
| 339 | 383 | 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. | |
| 351 | - | ||
| 352 | - | ### New Findings (not in Run 23) | |
| 353 | - | - Guest checkout missing `starts_at` promo validation (SERIOUS) -- new | |
| 354 | - | - Storage counter corruption on version replace failure (SERIOUS) -- new (distinct from Run 23's S3 key leak -- this is about counter rollback, not cleanup) | |
| 355 | - | - Pending uploads staleness attack via ON CONFLICT (MINOR) -- new | |
| 356 | - | - Blog editor input loss on error (MINOR) -- new | |
| 357 | - | - Idempotency middleware double allocation (MINOR) -- new | |
| 358 | - | ||
| 359 | - | ### Grade Changes (after remediation) | |
| 360 | - | - guest_checkout.rs: (new) B -> A (starts_at added) | |
| 361 | - | - versions.rs: A -> B- -> A (atomic try_replace_storage) | |
| 362 | - | - pending_uploads.rs: B+ -> A (ON CONFLICT DO NOTHING) | |
| 363 | - | - metrics.rs: A- -> A (double allocation removed) | |
| 364 | - | - Overall: A (held, strengthened) | |
| 365 | - | - Cold spots: 0 -> 2 -> 0 (all resolved) | |
| 366 | - | - SERIOUS findings: 0 -> 2 -> 0 (all fixed) | |
| 384 | + | - **Tip checkout missing minimum charge** -- Route handler at `routes/stripe/checkout/tips.rs:54-57` validates minimum $1.00 (above Stripe's 50-cent minimum) before calling the payment provider. | |
| 385 | + | - **FixedPricing no upper bound** -- Stripe session amount is set server-side. Client cannot override. The `validate_amount` function is for display/UX validation only. | |
| 386 | + | - **MaybeUser session revocation bypass** -- Documented, intentional design. Used only for read-only/public pages. The one write endpoint (OAuth authorize) compensates with manual session validation. | |
| 387 | + | - **Breached password advisory-only** -- Intentional UX decision. Server warns but doesn't block. | |
| 388 | + | - **TOTP secret plaintext** -- Standard practice for TOTP. Encryption at rest would require key management infrastructure beyond current scale. | |
| 389 | + | - **JWT iat not validated** -- Standard JWT behavior. exp is validated. No security impact. | |
| 390 | + | - **Session cookie __Host- prefix** -- No user-content subdomains. SameSite=Lax + Secure + HttpOnly is sufficient. | |
| 391 | + | - **Archive scanner no recursive scan** -- ClamAV and YARA scan the full outer bytes. Nested archive detection flags suspicious nesting (limit 3). | |
| 392 | + | - **Structural analysis dlopen/posix_spawn** -- These are one signal among many; no false-positive reports in production. | |
| 393 | + | ||
| 394 | + | ## Delta Since Run 24 | |
| 395 | + | ||
| 396 | + | ### Fixed (from Run 24) | |
| 397 | + | 5 of 5 actionable Run 24 items verified fixed. No regressions detected. 1 deferred item carried forward. | |
| 398 | + | ||
| 399 | + | ### New Findings (not in Run 24) | |
| 400 | + | - Cart preflight SQL column name bug (SERIOUS) -- new, latent since cart was added | |
| 401 | + | - Project deletion orphans S3 objects (SERIOUS) -- new finding (not previously audited at this depth) | |
| 402 | + | - Content export 2GB memory buffering (SERIOUS) -- new finding (export was previously fixed for streaming writes, but ZIP output itself still buffered) | |
| 403 | + | - Non-atomic item cover image 3-UPDATE (MINOR) -- new | |
| 404 | + | - Non-atomic project image storage swap (MINOR) -- new (related to previously-noted fragile URL extraction) | |
| 405 | + | - Project image missing S3 deletion enqueue (MINOR) -- new | |
| 406 | + | - Purchase.html JS error handling gap (MINOR) -- new | |
| 407 | + | - format_price negative formatting (MINOR) -- new | |
| 408 | + | - Collection export N+1 query (MINOR) -- new | |
| 409 | + | - check_sandbox_cap blocking advisory lock (MINOR) -- new | |
| 410 | + | ||
| 411 | + | ### Grade Changes | |
| 412 | + | - db/cart.rs: A -> B+ (SQL column bug found) | |
| 413 | + | - routes/api/projects.rs (delete): (new) B- (S3 orphan gap) | |
| 414 | + | - routes/api/exports/content.rs: A- -> B+ (memory buffering) | |
| 415 | + | - routes/storage/images.rs: A -> B+ (atomicity + cleanup gaps) | |
| 416 | + | - templates/purchase.html: (new) B+ (JS error handling) |
Lines truncated
| @@ -346,23 +346,23 @@ Feature map generated from full codebase walk. Every user-facing feature enumera | |||
| 346 | 346 | ||
| 347 | 347 | ### 14. SyncKit (E2E Encrypted Cloud Sync) | |
| 348 | 348 | ||
| 349 | - | - [ ] **SyncKit auth** — email/password + API key, or OAuth2 PKCE | |
| 350 | - | - [ ] **Push/pull sync** — bidirectional encrypted changelog | |
| 351 | - | - [ ] Push changes | |
| 352 | - | - [ ] Pull changes with cursor-based pagination | |
| 349 | + | - [x] **SyncKit auth** — OAuth2 PKCE flow tested with GO + AF on live server (2026-05-11) | |
| 350 | + | - [x] **Push/pull sync** — bidirectional encrypted changelog | |
| 351 | + | - [x] Push changes | |
| 352 | + | - [x] Pull changes with cursor-based pagination | |
| 353 | 353 | - [ ] Table name filter for selective pull | |
| 354 | 354 | - [ ] Idempotent push via batch_id | |
| 355 | 355 | - [ ] **Device management** — register, list, delete devices | |
| 356 | - | - [ ] **E2E key storage** — encrypted keys with optimistic concurrency | |
| 357 | - | - [ ] Store key | |
| 358 | - | - [ ] Retrieve key | |
| 356 | + | - [x] **E2E key storage** — encrypted keys with optimistic concurrency | |
| 357 | + | - [x] Store key | |
| 358 | + | - [x] Retrieve key | |
| 359 | 359 | - [ ] Version conflict returns 409 | |
| 360 | 360 | - [ ] **Blob storage** — encrypted blobs with hash dedup | |
| 361 | 361 | - [ ] Upload blob | |
| 362 | 362 | - [ ] Download blob | |
| 363 | 363 | - [ ] Duplicate hash skips re-upload | |
| 364 | - | - [ ] **App management** — create apps, generate API keys | |
| 365 | - | - [ ] Create sync app | |
| 364 | + | - [x] **App management** — create apps, generate API keys | |
| 365 | + | - [x] Create sync app (GO + AF apps created) | |
| 366 | 366 | - [ ] Regenerate API key | |
| 367 | 367 | - [ ] Link app to project/item | |
| 368 | 368 | - [ ] Set custom slug | |
| @@ -374,7 +374,7 @@ Feature map generated from full codebase walk. Every user-facing feature enumera | |||
| 374 | 374 | - [ ] Begin rotation, re-encrypt entries in batches, complete | |
| 375 | 375 | - [ ] Verify other device can pull mixed-key entries during rotation | |
| 376 | 376 | - [ ] Verify new device setup after rotation uses new key | |
| 377 | - | - [ ] **SyncKit production test** — test sync across 2+ GO instances on real server | |
| 377 | + | - [x] **SyncKit production test** — GO + AF sync tested on live server (2026-05-11). BB pending (synckit.toml needed). | |
| 378 | 378 | ||
| 379 | 379 | ### 15. OTA Updates | |
| 380 | 380 |
| @@ -0,0 +1,626 @@ | |||
| 1 | + | <!DOCTYPE html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="UTF-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + | <title>makenot.work — Creator Platform Overview</title> | |
| 7 | + | <style> | |
| 8 | + | @import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700&family=IBM+Plex+Mono:wght@400;600&display=swap'); | |
| 9 | + | ||
| 10 | + | :root { | |
| 11 | + | --beige: #ede8e1; | |
| 12 | + | --charcoal: #3d3530; | |
| 13 | + | --violet: #6c5ce7; | |
| 14 | + | --violet-light: #a29bfe; | |
| 15 | + | --warm-white: #f7f5f2; | |
| 16 | + | --border: #d4cec6; | |
| 17 | + | --green: #2d8a4e; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 21 | + | ||
| 22 | + | html { | |
| 23 | + | font-size: 15px; | |
| 24 | + | scroll-behavior: smooth; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | body { | |
| 28 | + | font-family: 'Lato', sans-serif; | |
| 29 | + | color: var(--charcoal); | |
| 30 | + | background: var(--warm-white); | |
| 31 | + | line-height: 1.55; | |
| 32 | + | -webkit-font-smoothing: antialiased; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | .page { | |
| 36 | + | max-width: 720px; | |
| 37 | + | margin: 0 auto; | |
| 38 | + | padding: 2.5rem 2rem; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /* ── Header ── */ | |
| 42 | + | .header { | |
| 43 | + | text-align: center; | |
| 44 | + | margin-bottom: 2.5rem; | |
| 45 | + | padding-bottom: 2rem; | |
| 46 | + | border-bottom: 2px solid var(--charcoal); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | .header h1 { | |
| 50 | + | font-family: 'Georgia', 'Times New Roman', serif; | |
| 51 | + | font-size: 2.2rem; | |
| 52 | + | font-weight: 400; | |
| 53 | + | letter-spacing: 0.02em; | |
| 54 | + | margin-bottom: 0.5rem; | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | .header .tagline { | |
| 58 | + | font-family: 'IBM Plex Mono', monospace; | |
| 59 | + | font-size: 0.95rem; | |
| 60 | + | color: var(--violet); | |
| 61 | + | font-weight: 600; | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /* ── Sections ── */ | |
| 65 | + | .section { | |
| 66 | + | margin-bottom: 2.2rem; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | .section-title { | |
| 70 | + | font-family: 'IBM Plex Mono', monospace; | |
| 71 | + | font-size: 0.8rem; | |
| 72 | + | font-weight: 600; | |
| 73 | + | text-transform: uppercase; | |
| 74 | + | letter-spacing: 0.12em; | |
| 75 | + | color: var(--violet); | |
| 76 | + | margin-bottom: 1rem; | |
| 77 | + | padding-bottom: 0.3rem; | |
| 78 | + | border-bottom: 1px solid var(--border); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | h2 { | |
| 82 | + | font-family: 'Georgia', 'Times New Roman', serif; | |
| 83 | + | font-size: 1.35rem; | |
| 84 | + | font-weight: 400; | |
| 85 | + | margin-bottom: 0.6rem; | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | p { margin-bottom: 0.7rem; } | |
| 89 | + | ||
| 90 | + | /* ── Pillars ── */ | |
| 91 | + | .pillars { | |
| 92 | + | display: grid; | |
| 93 | + | grid-template-columns: 1fr 1fr 1fr; | |
| 94 | + | gap: 1rem; | |
| 95 | + | margin: 1.2rem 0; | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | .pillar { | |
| 99 | + | background: var(--beige); | |
| 100 | + | padding: 1rem; | |
| 101 | + | border-left: 3px solid var(--violet); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | .pillar strong { | |
| 105 | + | display: block; | |
| 106 | + | font-family: 'IBM Plex Mono', monospace; | |
| 107 | + | font-size: 0.85rem; | |
| 108 | + | margin-bottom: 0.3rem; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | .pillar span { | |
| 112 | + | font-size: 0.88rem; | |
| 113 | + | line-height: 1.4; | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /* ── Callout ── */ | |
| 117 | + | .callout { | |
| 118 | + | background: var(--charcoal); | |
| 119 | + | color: var(--beige); | |
| 120 | + | padding: 1rem 1.2rem; | |
| 121 | + | margin: 1.2rem 0; | |
| 122 | + | font-family: 'IBM Plex Mono', monospace; | |
| 123 | + | font-size: 0.9rem; | |
| 124 | + | line-height: 1.5; | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | .callout strong { color: #fff; } | |
| 128 | + | ||
| 129 | + | .callout-honest { | |
| 130 | + | background: var(--beige); | |
| 131 | + | color: var(--charcoal); | |
| 132 | + | border-left: 3px solid var(--border); | |
| 133 | + | padding: 0.8rem 1rem; | |
| 134 | + | margin: 1rem 0; | |
| 135 | + | font-size: 0.88rem; | |
| 136 | + | font-style: italic; | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | /* ── Tables ── */ | |
| 140 | + | table { | |
| 141 | + | width: 100%; | |
| 142 | + | border-collapse: collapse; | |
| 143 | + | margin: 1rem 0; | |
| 144 | + | font-size: 0.88rem; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | th { | |
| 148 | + | font-family: 'IBM Plex Mono', monospace; | |
| 149 | + | font-size: 0.78rem; | |
| 150 | + | font-weight: 600; | |
| 151 | + | text-transform: uppercase; | |
| 152 | + | letter-spacing: 0.06em; | |
| 153 | + | text-align: left; | |
| 154 | + | padding: 0.5rem 0.6rem; | |
| 155 | + | border-bottom: 2px solid var(--charcoal); | |
| 156 | + | white-space: nowrap; | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | td { | |
| 160 | + | padding: 0.45rem 0.6rem; | |
| 161 | + | border-bottom: 1px solid var(--border); | |
| 162 | + | vertical-align: top; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | tr:last-child td { border-bottom: none; } | |
| 166 | + | ||
| 167 | + | .highlight-row { | |
| 168 | + | background: var(--beige); | |
| 169 | + | font-weight: 700; | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | .num { text-align: right; font-family: 'IBM Plex Mono', monospace; font-size: 0.85rem; } | |
| 173 | + | .check { text-align: center; } | |
| 174 | + | ||
| 175 | + | /* ── Feature Grid ── */ | |
| 176 | + | .features { | |
| 177 | + | display: grid; | |
| 178 | + | grid-template-columns: 1fr 1fr; | |
| 179 | + | gap: 1rem; | |
| 180 | + | margin: 1rem 0; | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | .feature { | |
| 184 | + | padding: 0.8rem 0; | |
| 185 | + | border-bottom: 1px solid var(--border); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | .feature strong { | |
| 189 | + | display: block; | |
| 190 | + | font-size: 0.92rem; | |
| 191 | + | margin-bottom: 0.2rem; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | .feature span { | |
| 195 | + | font-size: 0.85rem; | |
| 196 | + | color: #5a524a; | |
| 197 | + | line-height: 1.4; | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | /* ── Guarantees ── */ | |
| 201 | + | .guarantees { | |
| 202 | + | display: grid; | |
| 203 | + | grid-template-columns: 1fr 1fr; | |
| 204 | + | gap: 0.6rem 1rem; | |
| 205 | + | margin: 1rem 0; | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | .guarantee { | |
| 209 | + | padding: 0.5rem 0; | |
| 210 | + | font-size: 0.88rem; | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | .guarantee strong { | |
| 214 | + | font-family: 'IBM Plex Mono', monospace; | |
| 215 | + | font-size: 0.82rem; | |
| 216 | + | color: var(--violet); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /* ── Gaps ── */ | |
| 220 | + | .gaps { | |
| 221 | + | margin: 1rem 0; | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | .gap { | |
| 225 | + | display: flex; | |
| 226 | + | gap: 0.5rem; | |
| 227 | + | margin-bottom: 0.4rem; | |
| 228 | + | font-size: 0.88rem; | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | .gap-label { | |
| 232 | + | font-family: 'IBM Plex Mono', monospace; | |
| 233 | + | font-size: 0.75rem; | |
| 234 | + | color: #8a7f74; | |
| 235 | + | min-width: 2.5rem; | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | /* ── Divider ── */ | |
| 239 | + | .diamond { | |
| 240 | + | text-align: center; | |
| 241 | + | margin: 2rem 0; | |
| 242 | + | font-family: 'Georgia', 'Times New Roman', serif; | |
| 243 | + | font-size: 1.2rem; | |
| 244 | + | color: var(--violet); | |
| 245 | + | letter-spacing: 1rem; | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /* ── Footer ── */ | |
| 249 | + | .footer { | |
| 250 | + | text-align: center; | |
| 251 | + | padding-top: 1.5rem; | |
| 252 | + | border-top: 2px solid var(--charcoal); | |
| 253 | + | margin-top: 2rem; | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | .footer .url { | |
| 257 | + | font-family: 'IBM Plex Mono', monospace; | |
| 258 | + | font-size: 1.1rem; | |
| 259 | + | font-weight: 600; | |
| 260 | + | color: var(--violet); | |
| 261 | + | text-decoration: none; | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | .footer p { | |
| 265 | + | font-size: 0.85rem; | |
| 266 | + | color: #8a7f74; | |
| 267 | + | margin-top: 0.5rem; | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | .steps { | |
| 271 | + | display: flex; | |
| 272 | + | gap: 1rem; | |
| 273 | + | margin: 1rem 0; | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | .step { | |
| 277 | + | flex: 1; | |
| 278 | + | background: var(--beige); | |
| 279 | + | padding: 0.8rem; | |
| 280 | + | text-align: center; | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | .step-num { | |
| 284 | + | font-family: 'IBM Plex Mono', monospace; | |
| 285 | + | font-size: 1.4rem; | |
| 286 | + | font-weight: 600; | |
| 287 | + | color: var(--violet); | |
| 288 | + | display: block; | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | .step-label { | |
| 292 | + | font-size: 0.85rem; | |
| 293 | + | margin-top: 0.3rem; | |
| 294 | + | } | |
| 295 | + | ||
| 296 | + | /* ── Print / PDF ── */ | |
| 297 | + | @media print { | |
| 298 | + | html { font-size: 13px; } | |
| 299 | + | body { background: #fff; } | |
| 300 | + | .page { padding: 0; max-width: none; } | |
| 301 | + | .section { page-break-inside: avoid; } | |
| 302 | + | .callout { -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| 303 | + | .pillar, .highlight-row, .step { -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | @media (max-width: 600px) { | |
| 307 | + | .page { padding: 1.5rem 1rem; } | |
| 308 | + | .pillars { grid-template-columns: 1fr; } | |
| 309 | + | .features { grid-template-columns: 1fr; } | |
| 310 | + | .guarantees { grid-template-columns: 1fr; } | |
| 311 | + | .steps { flex-direction: column; } | |
| 312 | + | } | |
| 313 | + | </style> | |
| 314 | + | </head> | |
| 315 | + | <body> | |
| 316 | + | <div class="page"> | |
| 317 | + | ||
| 318 | + | <!-- Header --> | |
| 319 | + | <div class="header"> | |
| 320 | + | <h1>makenot.work</h1> | |
| 321 | + | <div class="tagline">0% platform fee. Your revenue is yours.</div> | |
| 322 | + | </div> | |
| 323 | + | ||
| 324 | + | <!-- Pitch --> | |
| 325 | + | <div class="section"> | |
| 326 | + | <p>Makenot.work is a platform for independent creators that charges a flat monthly fee instead of taking a percentage of your sales. You pay for platform access. Your fan revenue is untouched.</p> | |
| 327 | + | ||
| 328 | + | <div class="pillars"> | |
| 329 | + | <div class="pillar"> | |
| 330 | + | <strong>You keep everything</strong> | |
| 331 | + | <span>Only Stripe's ~3% processing fee. No platform percentage, no payout fees, no skimming.</span> | |
| 332 | + | </div> | |
| 333 | + | <div class="pillar"> | |
| 334 | + | <strong>No lock-in</strong> | |
| 335 | + | <span>Full data export anytime. Month-to-month. Cancel in one click. Your fans, your data.</span> | |
| 336 | + | </div> | |
| 337 | + | <div class="pillar"> | |
| 338 | + | <strong>Source available</strong> | |
| 339 | + | <span>Read every line of our server code. Verify every claim. Audit our privacy practices.</span> | |
| 340 | + | </div> | |
| 341 | + | </div> | |
| 342 | + | ||
| 343 | + | <div class="callout"> | |
| 344 | + | At <strong>$2,000/mo</strong> revenue, you keep <strong>~$1,850</strong> on Makenot.work.<br> | |
| 345 | + | On a 10% platform, you keep ~$1,600. On 15%, ~$1,400. The gap widens with every dollar. | |
| 346 | + | </div> | |
| 347 | + | </div> | |
| 348 | + | ||
| 349 | + | <div class="diamond">.</div> | |
| 350 | + | ||
| 351 | + | <!-- Pricing --> | |
| 352 | + | <div class="section"> | |
| 353 | + | <div class="section-title">Pricing</div> | |
| 354 | + | ||
| 355 | + | <table> | |
| 356 | + | <thead> | |
| 357 | + | <tr> | |
| 358 | + | <th>Tier</th> | |
| 359 | + | <th>Monthly</th> | |
| 360 | + | <th>For</th> | |
| 361 | + | <th>Per-file limit</th> | |
| 362 | + | <th>Storage</th> | |
| 363 | + | </tr> | |
| 364 | + | </thead> | |
| 365 | + | <tbody> | |
| 366 | + | <tr> | |
| 367 | + | <td><strong>Basic</strong></td> | |
| 368 | + | <td class="num">$10</td> | |
| 369 | + | <td>Text, blogs, newsletters</td> | |
| 370 | + | <td class="num">10 MB</td> | |
| 371 | + | <td class="num">50 GB</td> | |
| 372 | + | </tr> | |
| 373 | + | <tr> | |
| 374 | + | <td><strong>Small Files</strong></td> | |
| 375 | + | <td class="num">$20</td> | |
| 376 | + | <td>Audio, plugins, small software</td> | |
| 377 | + | <td class="num">500 MB</td> | |
| 378 | + | <td class="num">250 GB</td> | |
| 379 | + | </tr> | |
| 380 | + | <tr> | |
| 381 | + | <td><strong>Big Files</strong></td> | |
| 382 | + | <td class="num">$30</td> | |
| 383 | + | <td>Video, games, large software</td> | |
| 384 | + | <td class="num">20 GB</td> | |
| 385 | + | <td class="num">500 GB</td> | |
| 386 | + | </tr> | |
| 387 | + | <tr> | |
| 388 | + | <td><strong>Everything</strong></td> | |
| 389 | + | <td class="num">$60</td> | |
| 390 | + | <td>All features, current and future</td> | |
| 391 | + | <td class="num">20 GB</td> | |
| 392 | + | <td class="num">500 GB</td> | |
| 393 | + | </tr> | |
| 394 | + | </tbody> | |
| 395 | + | </table> | |
| 396 | + | ||
| 397 | + | <p style="font-size: 0.85rem; color: #5a524a;">All tiers include: unlimited downloads, custom profile, project storefronts, memberships, analytics, data export, RSS, 2FA/passkeys, custom domains. Tiers differ by file size and storage, not by features.</p> | |
| 398 | + | ||
| 399 | + | <div class="callout-honest">If you earn less than roughly $67/month, a percentage-cut platform costs less. We'd rather be honest about that than hide the math.</div> | |
| 400 | + | </div> | |
| 401 | + | ||
| 402 | + | <!-- Comparison --> | |
| 403 | + | <div class="section"> | |
| 404 | + | <div class="section-title">What you keep at different revenue levels</div> | |
| 405 | + | ||
| 406 | + | <table> | |
| 407 | + | <thead> | |
| 408 | + | <tr> | |
| 409 | + | <th>Monthly revenue</th> | |
| 410 | + | <th>Makenot.work</th> | |
| 411 | + | <th>10% platform</th> | |
| 412 | + | <th>15% platform</th> | |
| 413 | + | <th>Savings</th> | |
| 414 | + | </tr> | |
| 415 | + | </thead> | |
| 416 | + | <tbody> | |
| 417 | + | <tr> | |
| 418 | + | <td>$500</td> | |
| 419 | + | <td class="num highlight-row">$410–470</td> | |
| 420 | + | <td class="num">$420</td> | |
| 421 | + | <td class="num">$395</td> | |
| 422 | + | <td class="num">$15–75</td> | |
| 423 | + | </tr> | |
| 424 | + | <tr> | |
| 425 | + | <td>$1,000</td> | |
| 426 | + | <td class="num highlight-row">$881–941</td> | |
| 427 | + | <td class="num">$870</td> | |
| 428 | + | <td class="num">$820</td> | |
| 429 | + | <td class="num">$61–121</td> | |
| 430 | + | </tr> | |
| 431 | + | <tr> | |
| 432 | + | <td>$2,000</td> | |
| 433 | + | <td class="num highlight-row">$1,822–1,872</td> | |
| 434 | + | <td class="num">$1,682</td> | |
| 435 | + | <td class="num">$1,582</td> | |
| 436 | + | <td class="num">$140–290</td> | |
| 437 | + | </tr> | |
| 438 | + | <tr> | |
| 439 | + | <td>$5,000</td> | |
| 440 | + | <td class="num highlight-row">$4,795–4,845</td> | |
| 441 | + | <td class="num">$4,355</td> | |
| 442 | + | <td class="num">$3,855</td> | |
| 443 | + | <td class="num">$440–990</td> | |
| 444 | + | </tr> | |
| 445 | + | <tr> | |
| 446 | + | <td>$10,000</td> | |
| 447 | + | <td class="num highlight-row">$9,350–9,400</td> | |
| 448 | + | <td class="num">$8,610</td> | |
| 449 | + | <td class="num">$7,910</td> | |
| 450 | + | <td class="num">$740–1,490</td> | |
| 451 | + | </tr> | |
| 452 | + | </tbody> | |
| 453 | + | </table> | |
| 454 | + | ||
| 455 | + | <p style="font-size: 0.82rem; color: #5a524a;">Makenot.work range reflects $10–$60 tier fee. Competitor columns include Stripe processing (~3%). All figures assume $10 average sale price.</p> | |
| 456 | + | </div> | |
| 457 | + | ||
| 458 | + | <div class="diamond">.</div> | |
| 459 | + | ||
| 460 | + | <!-- Features --> | |
| 461 | + | <div class="section"> | |
| 462 | + | <div class="section-title">What you get</div> | |
| 463 | + | ||
| 464 | + | <div class="features"> | |
| 465 | + | <div class="feature"> | |
| 466 | + | <strong>Sell your work</strong> | |
| 467 | + | <span>One-time purchases, pay-what-you-want, subscriptions, bundles, promo codes, license keys. Guest checkout—fans don't need an account.</span> | |
| 468 | + | </div> | |
| 469 | + | <div class="feature"> | |
| 470 | + | <strong>Host your files</strong> | |
| 471 | + | <span>Audio and video streaming with chapters, file versioning with changelogs, malware scanning on every upload.</span> | |
| 472 | + | </div> | |
| 473 | + | <div class="feature"> | |
| 474 | + | <strong>Build your audience</strong> | |
| 475 | + | <span>Project storefronts, blog publishing, mailing lists with broadcasts, RSS feeds, follower system, curated collections.</span> | |
| 476 | + | </div> | |
| 477 | + | <div class="feature"> | |
| 478 | + | <strong>Own your brand</strong> | |
| 479 | + | <span>Custom domains with automatic TLS. Embeddable widgets (button, card, player) for your existing site.</span> | |
| 480 | + | </div> | |
| 481 | + | <div class="feature"> | |
| 482 | + | <strong>Understand your business</strong> | |
| 483 | + | <span>Analytics at user, project, and item level. Full data export: projects (JSON), sales (CSV), content (ZIP).</span> | |
| 484 | + | </div> | |
| 485 | + | <div class="feature"> | |
| 486 | + | <strong>Host source code</strong> | |
| 487 | + | <span>Git repos with web browser, syntax highlighting, blame view, email-based issues, and patch submission.</span> | |
| 488 | + | </div> | |
| 489 | + | <div class="feature"> | |
| 490 | + | <strong>Moderate your community</strong> | |
| 491 | + | <span>Integrated forums per project via Multithreaded. Invite-only or open. Threaded discussions.</span> | |
| 492 | + | </div> | |
| 493 | + | <div class="feature"> | |
| 494 | + | <strong>Get discovered</strong> | |
| 495 | + | <span>Discover page with search, filters by type/tag/price/AI-tier, sorting, and personalized feeds for fans.</span> | |
| 496 | + | </div> | |
| 497 | + | </div> | |
| 498 | + | </div> | |
| 499 | + | ||
| 500 | + | <!-- Feature Comparison --> |
Lines truncated
| @@ -0,0 +1,272 @@ | |||
| 1 | + | \documentclass[11pt]{article} | |
| 2 | + | \usepackage[margin=0.7in]{geometry} | |
| 3 | + | \usepackage{fontspec} | |
| 4 | + | \usepackage{xcolor} | |
| 5 | + | \usepackage{booktabs} | |
| 6 | + | \usepackage{enumitem} | |
| 7 | + | \usepackage{titlesec} | |
| 8 | + | \usepackage{fancyhdr} | |
| 9 | + | \usepackage{multicol} | |
| 10 | + | \usepackage{array} | |
| 11 | + | \usepackage{tcolorbox} | |
| 12 | + | \usepackage{hyperref} | |
| 13 | + | \usepackage{needspace} | |
| 14 | + | ||
| 15 | + | \setmainfont{Georgia} | |
| 16 | + | \setsansfont{Helvetica Neue} | |
| 17 | + | \setmonofont{Menlo} | |
| 18 | + | ||
| 19 | + | \definecolor{violet}{HTML}{6c5ce7} | |
| 20 | + | \definecolor{charcoal}{HTML}{3d3530} | |
| 21 | + | \definecolor{beige}{HTML}{ede8e1} | |
| 22 | + | \definecolor{warmwhite}{HTML}{f7f5f2} | |
| 23 | + | \definecolor{muted}{HTML}{8a7f74} | |
| 24 | + | \definecolor{border}{HTML}{d4cec6} | |
| 25 | + | ||
| 26 | + | \color{charcoal} | |
| 27 | + | ||
| 28 | + | \hypersetup{colorlinks=true, urlcolor=violet, linkcolor=violet} | |
| 29 | + | ||
| 30 | + | \pagestyle{fancy} | |
| 31 | + | \fancyhf{} | |
| 32 | + | \renewcommand{\headrulewidth}{0pt} | |
| 33 | + | \fancyfoot[C]{\textcolor{muted}{\footnotesize makenot.work \enspace · \enspace support@makenot.work \enspace · \enspace Make Creative, LLC}} | |
| 34 | + | ||
| 35 | + | \titleformat{\section}{\sffamily\normalsize\bfseries\color{violet}}{}{0em}{}[\vspace{-0.3em}{\color{border}\rule{\linewidth}{0.4pt}}] | |
| 36 | + | \titleformat{\subsection}{\normalfont\normalsize\bfseries}{}{0em}{} | |
| 37 | + | \titlespacing{\section}{0pt}{1em}{0.5em} | |
| 38 | + | \titlespacing{\subsection}{0pt}{0.4em}{0.2em} | |
| 39 | + | ||
| 40 | + | \setlist[itemize]{nosep, left=0pt, itemsep=2pt} | |
| 41 | + | ||
| 42 | + | \tcbuselibrary{skins} | |
| 43 | + | \newtcolorbox{calloutbox}{colback=charcoal, coltext=beige, colframe=charcoal, boxrule=0pt, arc=0pt, left=8pt, right=8pt, top=6pt, bottom=6pt} | |
| 44 | + | \newtcolorbox{honestbox}{colback=warmwhite, coltext=charcoal, colframe=beige, boxrule=0pt, borderline west={2pt}{0pt}{border}, arc=0pt, left=8pt, right=8pt, top=4pt, bottom=4pt} | |
| 45 | + | \newtcolorbox{pillarbox}{colback=beige, coltext=charcoal, colframe=violet, boxrule=0pt, borderline west={2pt}{0pt}{violet}, arc=0pt, left=8pt, right=8pt, top=4pt, bottom=4pt} | |
| 46 | + | \newtcolorbox{stepbox}[1][]{colback=beige, coltext=charcoal, colframe=beige, boxrule=0pt, arc=0pt, left=6pt, right=6pt, top=6pt, bottom=6pt, #1} | |
| 47 | + | ||
| 48 | + | \setlength{\parindent}{0pt} | |
| 49 | + | \setlength{\parskip}{0.4em} | |
| 50 | + | ||
| 51 | + | \begin{document} | |
| 52 | + | ||
| 53 | + | % ── Header ── | |
| 54 | + | \begin{center} | |
| 55 | + | {\fontsize{28}{34}\selectfont\textbf{makenot.work}}\\[6pt] | |
| 56 | + | {\sffamily\color{violet}\textbf{0\% platform fee. Your revenue is yours.}} | |
| 57 | + | \end{center} | |
| 58 | + | ||
| 59 | + | \vspace{0.2em} | |
| 60 | + | \rule{\linewidth}{1.5pt} | |
| 61 | + | \vspace{0.4em} | |
| 62 | + | ||
| 63 | + | Makenot.work is a platform for independent creators that charges a flat monthly fee instead of taking a percentage of your sales. You pay for platform access. Your fan revenue is untouched. | |
| 64 | + | ||
| 65 | + | \vspace{0.4em} | |
| 66 | + | ||
| 67 | + | \begin{pillarbox} | |
| 68 | + | \textbf{\sffamily You keep everything} \enspace {\small Only Stripe's \textasciitilde3\% processing fee. No platform percentage, no payout fees, no skimming.} | |
| 69 | + | \end{pillarbox} | |
| 70 | + | \vspace{0.15em} | |
| 71 | + | \begin{pillarbox} | |
| 72 | + | \textbf{\sffamily No lock-in} \enspace {\small Full data export anytime. Month-to-month. Cancel in one click. Your fans, your data.} | |
| 73 | + | \end{pillarbox} | |
| 74 | + | \vspace{0.15em} | |
| 75 | + | \begin{pillarbox} | |
| 76 | + | \textbf{\sffamily Source available} \enspace {\small Read every line of our server code. Verify every claim. Audit our privacy practices.} | |
| 77 | + | \end{pillarbox} | |
| 78 | + | ||
| 79 | + | \vspace{0.3em} | |
| 80 | + | ||
| 81 | + | \begin{calloutbox} | |
| 82 | + | At \textbf{\$2,000/mo} revenue, you keep \textbf{\textasciitilde\$1,850} on Makenot.work. | |
| 83 | + | On a 10\% platform, you keep \textasciitilde\$1,600. On 15\%, \textasciitilde\$1,400. The gap widens with every dollar. | |
| 84 | + | \end{calloutbox} | |
| 85 | + | ||
| 86 | + | \begin{center}\textcolor{violet}{\Large .}\end{center} | |
| 87 | + | ||
| 88 | + | % ── Pricing ── | |
| 89 | + | \section{Pricing} | |
| 90 | + | ||
| 91 | + | \begin{tabular}{@{} l r l r r @{}} | |
| 92 | + | \toprule | |
| 93 | + | \textbf{Tier} & \textbf{Monthly} & \textbf{For} & \textbf{Per-file} & \textbf{Storage} \\ | |
| 94 | + | \midrule | |
| 95 | + | Basic & \$10 & Text, blogs, newsletters & 10 MB & 50 GB \\ | |
| 96 | + | Small Files & \$20 & Audio, plugins, small software & 500 MB & 250 GB \\ | |
| 97 | + | Big Files & \$30 & Video, games, large software & 20 GB & 500 GB \\ | |
| 98 | + | Everything & \$60 & All features, current and future & 20 GB & 500 GB \\ | |
| 99 | + | \bottomrule | |
| 100 | + | \end{tabular} | |
| 101 | + | ||
| 102 | + | \smallskip | |
| 103 | + | {\small\textcolor{muted}{All tiers include: unlimited downloads, custom profile, project storefronts, memberships, analytics, data export, RSS, 2FA/passkeys, custom domains. Tiers differ by file size and storage, not features.}} | |
| 104 | + | ||
| 105 | + | \begin{honestbox} | |
| 106 | + | {\small\textit{If you earn less than roughly \$67/month, a percentage-cut platform costs less. We'd rather be honest about that than hide the math.}} | |
| 107 | + | \end{honestbox} | |
| 108 | + | ||
| 109 | + | % ── Revenue Comparison ── | |
| 110 | + | \section{What you keep at different revenue levels} | |
| 111 | + | ||
| 112 | + | \begin{tabular}{@{} l r r r r @{}} | |
| 113 | + | \toprule | |
| 114 | + | \textbf{Monthly revenue} & \textbf{Makenot.work} & \textbf{10\% platform} & \textbf{15\% platform} & \textbf{You save} \\ | |
| 115 | + | \midrule | |
| 116 | + | \$500 & \textbf{\$410--470} & \$420 & \$395 & \$15--75 \\ | |
| 117 | + | \$1,000 & \textbf{\$881--941} & \$870 & \$820 & \$61--121 \\ | |
| 118 | + | \$2,000 & \textbf{\$1,822--1,872} & \$1,682 & \$1,582 & \$140--290 \\ | |
| 119 | + | \$5,000 & \textbf{\$4,795--4,845} & \$4,355 & \$3,855 & \$440--990 \\ | |
| 120 | + | \$10,000 & \textbf{\$9,350--9,400} & \$8,610 & \$7,910 & \$740--1,490 \\ | |
| 121 | + | \bottomrule | |
| 122 | + | \end{tabular} | |
| 123 | + | ||
| 124 | + | \smallskip | |
| 125 | + | {\footnotesize\textcolor{muted}{Range reflects \$10--\$60 tier fee. Competitor columns include Stripe processing (\textasciitilde3\%). Assumes \$10 average sale price.}} | |
| 126 | + | ||
| 127 | + | \begin{center}\textcolor{violet}{\Large .}\end{center} | |
| 128 | + | ||
| 129 | + | % ── Features ── | |
| 130 | + | \section{What you get} | |
| 131 | + | ||
| 132 | + | \begin{multicols}{2} | |
| 133 | + | ||
| 134 | + | \subsection{Sell your work} | |
| 135 | + | {\small One-time purchases, pay-what-you-want, subscriptions, bundles, promo codes, license keys. Guest checkout---fans don't need an account.} | |
| 136 | + | ||
| 137 | + | \subsection{Host your files} | |
| 138 | + | {\small Audio and video streaming with chapters, file versioning with changelogs, malware scanning on every upload.} | |
| 139 | + | ||
| 140 | + | \subsection{Build your audience} | |
| 141 | + | {\small Project storefronts, blog publishing, mailing lists with broadcasts, RSS feeds, follower system, curated collections.} | |
| 142 | + | ||
| 143 | + | \subsection{Own your brand} | |
| 144 | + | {\small Custom domains with automatic TLS. Embeddable widgets (button, card, player) for your site.} | |
| 145 | + | ||
| 146 | + | \columnbreak | |
| 147 | + | ||
| 148 | + | \subsection{Understand your business} | |
| 149 | + | {\small Analytics at user, project, and item level. Full data export: projects (JSON), sales (CSV), content (ZIP).} | |
| 150 | + | ||
| 151 | + | \subsection{Host source code} | |
| 152 | + | {\small Git repos with web browser, syntax highlighting, blame view, email-based issues, and patch submission.} | |
| 153 | + | ||
| 154 | + | \subsection{Community forums} | |
| 155 | + | {\small Integrated forums per project via Multithreaded. Invite-only or open. Threaded discussions.} | |
| 156 | + | ||
| 157 | + | \subsection{Get discovered} | |
| 158 | + | {\small Discover page with search, filters by type, tag, price, AI tier. Sorting, pagination, personalized feeds.} | |
| 159 | + | ||
| 160 | + | \end{multicols} | |
| 161 | + | ||
| 162 | + | % ── Feature Comparison ── | |
| 163 | + | \section{Feature comparison} | |
| 164 | + | ||
| 165 | + | \begin{tabular}{@{} l c c c c @{}} | |
| 166 | + | \toprule | |
| 167 | + | \textbf{Feature} & \textbf{MNW} & \textbf{Patreon} & \textbf{Bandcamp} & \textbf{Gumroad} \\ | |
| 168 | + | \midrule | |
| 169 | + | 0\% platform fee & Yes & --- & --- & --- \\ | |
| 170 | + | Audio hosting + player & Yes & Yes & Yes & --- \\ | |
| 171 | + | Video hosting + player & Yes & Yes & --- & --- \\ | |
| 172 | + | Software versioning & Yes & --- & --- & Yes \\ | |
| 173 | + | License keys & Yes & --- & --- & Yes \\ | |
| 174 | + | Git hosting & Yes & --- & --- & --- \\ | |
| 175 | + | Custom domains & Yes & --- & --- & Yes \\ | |
| 176 | + | Full data export & Yes & Part & Part & Part \\ | |
| 177 | + | Source-available code & Yes & --- & --- & --- \\ | |
| 178 | + | Embed widgets & Yes & --- & Yes & Yes \\ | |
| 179 | + | \bottomrule | |
| 180 | + | \end{tabular} | |
| 181 | + | ||
| 182 | + | \begin{center}\textcolor{violet}{\Large .}\end{center} | |
| 183 | + | ||
| 184 | + | % ── Guarantees ── | |
| 185 | + | \section{Written guarantees} | |
| 186 | + | ||
| 187 | + | {\small These are binding commitments published at \href{https://makenot.work/docs/guarantees}{makenot.work/docs/guarantees} and verifiable in the source code.} | |
| 188 | + | ||
| 189 | + | \vspace{-0.2em} | |
| 190 | + | \begin{multicols}{2} | |
| 191 | + | ||
| 192 | + | \subsection{0\% platform fee} | |
| 193 | + | {\small No platform percentage, ever. Your tier fee covers access. Fan revenue goes to you minus Stripe's \textasciitilde3\%.} | |
| 194 | + | ||
| 195 | + | \subsection{Full data export} | |
| 196 | + | {\small All content, metadata, fan contacts (when shared), transaction history. JSON, CSV, ZIP. Available anytime.} | |
| 197 | + | ||
| 198 | + | \subsection{Price stability} | |
| 199 | + | {\small 90 days notice before any change. Existing creators grandfathered at current rate for 12+ months.} | |
| 200 | + | ||
| 201 | + | \columnbreak | |
| 202 | + | ||
| 203 | + | \subsection{Shutdown protocol} | |
| 204 | + | {\small 90-day advance notice. Full export maintained. Fan payments go to your Stripe---nothing to settle.} | |
| 205 | + | ||
| 206 | + | \subsection{No ads, no tracking} | |
| 207 | + | {\small No behavioral profiling, no data sales, no injected advertising. Verifiable in source code.} | |
| 208 | + | ||
| 209 | + | \subsection{99.5\% uptime target} | |
| 210 | + | {\small Live status at /health. Dual independent monitoring. Daily backups with point-in-time recovery.} | |
| 211 | + | ||
| 212 | + | \end{multicols} | |
| 213 | + | ||
| 214 | + | \vspace{-0.3em} | |
| 215 | + | ||
| 216 | + | % ── Why This Works ── | |
| 217 | + | \section{Why this works} | |
| 218 | + | ||
| 219 | + | \textbf{The model is simple.} We charge a flat fee for platform access. We don't take a cut of sales. Our incentive is to keep you, not to extract more from your revenue. The better you do, the more likely you stay. Those align. | |
| 220 | + | ||
| 221 | + | \textbf{Self-funded, no investors.} No board, no pressure to raise fees. Operating costs are roughly \$600/month. We break even at about 30 creators. One person built this, one person runs it. | |
| 222 | + | ||
| 223 | + | \textbf{The code is public.} Server source is available under PolyForm Noncommercial. Every privacy claim, every fee calculation, every guarantee is auditable. | |
| 224 | + | ||
| 225 | + | \vspace{-0.2em} | |
| 226 | + | ||
| 227 | + | % ── Honest Gaps ── | |
| 228 | + | \section{What we don't do} | |
| 229 | + | ||
| 230 | + | \vspace{-0.3em} | |
| 231 | + | \begin{itemize} | |
| 232 | + | \item We won't find you an audience. Discovery is search, tags, filters, feeds---driven by fan intent, not algorithms. Your audience comes with you; we help you serve them. | |
| 233 | + | \item No physical products. Digital only: audio, video, text, software, plugins, games. | |
| 234 | + | \item We're new and small. One operator, early access. Fast iteration and direct support, but a smaller network than established platforms. | |
| 235 | + | \end{itemize} | |
| 236 | + | ||
| 237 | + | \vspace{-0.2em} | |
| 238 | + | ||
| 239 | + | % ── Getting Started ── | |
| 240 | + | \section{Getting started} | |
| 241 | + | ||
| 242 | + | \vspace{-0.2em} | |
| 243 | + | \begin{center} | |
| 244 | + | \begin{minipage}[t]{0.28\linewidth} | |
| 245 | + | \begin{stepbox} | |
| 246 | + | \centering | |
| 247 | + | {\sffamily\Large\color{violet}\textbf{1}}\\[2pt] | |
| 248 | + | {\small Sign up with your invite code} | |
| 249 | + | \end{stepbox} | |
| 250 | + | \end{minipage} | |
| 251 | + | \hfill | |
| 252 | + | \begin{minipage}[t]{0.28\linewidth} | |
| 253 | + | \begin{stepbox} | |
| 254 | + | \centering | |
| 255 | + | {\sffamily\Large\color{violet}\textbf{2}}\\[2pt] | |
| 256 | + | {\small Connect Stripe to receive payments} | |
| 257 | + | \end{stepbox} | |
| 258 | + | \end{minipage} | |
| 259 | + | \hfill | |
| 260 | + | \begin{minipage}[t]{0.28\linewidth} | |
| 261 | + | \begin{stepbox} | |
| 262 | + | \centering | |
| 263 | + | {\sffamily\Large\color{violet}\textbf{3}}\\[2pt] | |
| 264 | + | {\small Create a project, upload your first item} | |
| 265 | + | \end{stepbox} | |
| 266 | + | \end{minipage} | |
| 267 | + | \end{center} | |
| 268 | + | ||
| 269 | + | \smallskip | |
| 270 | + | {\small You're receiving this because we think your work is a good fit. This is a private alpha---invite-only, hand-picked creators. We're looking for honest feedback: what works, what's missing, what's confusing.} | |
| 271 | + | ||
| 272 | + | \end{document} |
| @@ -64,7 +64,7 @@ Partner with a white-label distribution provider. Our creators upload to makenot | |||
| 64 | 64 | | DistroKid for Labels | $79.99–$1,199.99/yr (5–100 artists) | Not white-label; creators would know it's DistroKid | | |
| 65 | 65 | | TuneCore for Labels | $49.99/yr base + $14.99/artist | Same limitation | | |
| 66 | 66 | ||
| 67 | - | **Recommendation:** Start with DistroKid for Labels ($79.99/yr for 5 artists) as a v1 to validate demand with minimal cost. Migrate to Audicient ($59/month) or Revelator once we hit 20+ DSP creators to amortize the platform fee. | |
| 67 | + | **Recommendation:** Pursue LabelGrid as the primary Phase 1 white-label partner. Spotify Preferred Provider status, Merlin Network integration, 55+ DSPs, and bootstrapped/indie-aligned values make it the strongest fit. API plan starts at $119/month; break-even at ~12 DSP creators. Audicient ($59/month) as fallback if LabelGrid can't meet 100% royalty pass-through requirement. See `labelgrid-evaluation.md` for full analysis. | |
| 68 | 68 | ||
| 69 | 69 | ### Per-Creator Cost Breakdown | |
| 70 | 70 | ||
| @@ -315,7 +315,7 @@ Decisions made during planning that should be revisited as conditions change: | |||
| 315 | 315 | ||
| 316 | 316 | ## Open Questions | |
| 317 | 317 | ||
| 318 | - | - Which white-label provider to contact first? (Recommendation: email Audicient, Revelator, and LabelGrid simultaneously asking about minimums and pricing for a small platform.) | |
| 318 | + | - ~~Which white-label provider to contact first?~~ **Answered:** LabelGrid first (see `labelgrid-evaluation.md`). Audicient as fallback. Key questions to resolve: 100% royalty pass-through, per-track limits on API plan, wind-down/portability terms. | |
| 319 | 319 | - Should the DSP add-on be available on all tiers or only Small Files? (Recommendation: Small Files only to start — text/video creators don't need DSP distribution.) | |
| 320 | 320 | - How to handle Dolby Atmos / spatial audio requests? (Recommendation: pass through to white-label partner if supported; don't build custom tooling for this.) | |
| 321 | 321 | - Should we offer mastering as an upsell? (Outside scope of this document, but worth considering as a future revenue stream that increases catalog quality.) |
| @@ -0,0 +1,98 @@ | |||
| 1 | + | # LabelGrid -- DSP Partner Evaluation | |
| 2 | + | ||
| 3 | + | Evaluated 2026-05-11 as a Phase 1 white-label backend for DSP distribution. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## What LabelGrid Is | |
| 8 | + | ||
| 9 | + | Music distribution and label management platform. Aggregator/distributor between labels/artists and DSPs. Also offers white-label distribution infrastructure. Founded 2017, Denver CO, ~5 people. Bootstrapped, founded by label owners. Not VC-backed. | |
| 10 | + | ||
| 11 | + | ## Alignment with MNW | |
| 12 | + | ||
| 13 | + | | Dimension | Assessment | | |
| 14 | + | |-----------|-----------| | |
| 15 | + | | Values | Bootstrapped, indie-focused, founded by label owners. No VC. Closest to MNW's ethos of any distributor in the space. | | |
| 16 | + | | Scale match | Small company (~$55K rev in 2020, 5 people). We'd be peers, not a rounding error. | | |
| 17 | + | | API | Full REST API with interactive docs (`api.labelgrid.com/docs`), sandbox, webhooks, DDEX import. Real infrastructure. | | |
| 18 | + | | DSP coverage | 55+ DSPs including all Tier 1 targets. Spotify Preferred Provider (rare -- indicates high metadata quality). | | |
| 19 | + | | White-label | Explicitly offered and marketed. Creators see MNW branding, not LabelGrid. | | |
| 20 | + | ||
| 21 | + | ## Pricing | |
| 22 | + | ||
| 23 | + | Annual subscription model with tiers: | |
| 24 | + | ||
| 25 | + | | Plan | Price | Tracks | Labels | Royalty Retention | | |
| 26 | + | |------|-------|--------|--------|-------------------| | |
| 27 | + | | Solo | $99/yr | 100 | 1 | 85% | | |
| 28 | + | | Basic | $199/yr | 200 | 3 | 85% | | |
| 29 | + | | Pro | $499/yr | 500 | 5 | 90% | | |
| 30 | + | | Custom | from $849/yr | 2,000+ | 50+ | 95-100% | | |
| 31 | + | ||
| 32 | + | White-label/API plans start at **$119/month** (annual commitment = ~$1,428/year). | |
| 33 | + | ||
| 34 | + | ## Economics for MNW | |
| 35 | + | ||
| 36 | + | At the planned $10/month distribution add-on: | |
| 37 | + | ||
| 38 | + | | Creators | MNW Revenue | LabelGrid Cost | Margin | | |
| 39 | + | |----------|-------------|-----------------|--------| | |
| 40 | + | | 10 | $100/mo | ~$119/mo | -$19/mo (subsidy) | | |
| 41 | + | | 15 | $150/mo | ~$119/mo | +$31/mo | | |
| 42 | + | | 50 | $500/mo | ~$119-200/mo | +$300-381/mo | | |
| 43 | + | ||
| 44 | + | Break-even at ~12 creators on the distribution add-on. Per-creator amortized cost drops fast and fits within the $0.50-5.00/month target at moderate scale. | |
| 45 | + | ||
| 46 | + | ## Strengths | |
| 47 | + | ||
| 48 | + | - **Spotify Preferred Provider** -- one of a small number worldwide. Reduces risk of delivery/metadata rejections. | |
| 49 | + | - **Merlin Network integration** -- technical delivery partner for the global indie rights agency (~15% of global recorded music market). If MNW creators are Merlin members, they can use Merlin's negotiated rates through LabelGrid. | |
| 50 | + | - **Hi-res and Dolby Atmos support** -- future-proofs against quality tier requirements (Apple Music HD, Tidal HiFi). | |
| 51 | + | - **Bootstrapped small company** -- more likely to give real support and negotiate custom terms. We're not ticket #47,291. | |
| 52 | + | - **55+ DSPs** -- Spotify, Apple Music, Amazon Music, YouTube Music, Deezer, Tidal, TikTok, Beatport, Bandcamp, Anghami, Boomplay, JioSaavn, KKBOX, and more. | |
| 53 | + | ||
| 54 | + | ## Risks | |
| 55 | + | ||
| 56 | + | | Risk | Severity | Mitigation | | |
| 57 | + | |------|----------|------------| | |
| 58 | + | | Small company risk (5 people, modest revenue, could fold or get acquired) | Medium | Phase 2 plan already builds our own DDEX infrastructure. LabelGrid is a bridge, not a dependency. | | |
| 59 | + | | API pricing opacity (white-label is "contact us" beyond $119/mo) | Low-Medium | Negotiate upfront volume pricing and caps before signing. Get it in writing. | | |
| 60 | + | | Track limits on lower tiers (Solo is 100 tracks/year, API plan limits unclear) | Low | Clarify whether API/white-label plans have per-track limits. | | |
| 61 | + | | Standard plans retain 5-15% of royalties | Medium | 100% pass-through is non-negotiable. Custom tier ($849+/yr) offers 95-100%. Must negotiate 100% as a hard requirement. | | |
| 62 | + | ||
| 63 | + | ## Comparison with Other Candidates | |
| 64 | + | ||
| 65 | + | | | LabelGrid | Audicient | Revelator | | |
| 66 | + | |--|-----------|-----------|-----------| | |
| 67 | + | | Price | $119/mo (API) | $59/mo | Custom | | |
| 68 | + | | API quality | Strong, documented | REST API | Best-documented | | |
| 69 | + | | DSP count | 55+ | ~40 | ~50 | | |
| 70 | + | | Spotify Preferred | Yes | No | No | | |
| 71 | + | | White-label | Explicit | Yes | Yes | | |
| 72 | + | | Company size | Small/bootstrapped | Small | VC-backed | | |
| 73 | + | | Values fit | High | Unknown | Lower (VC) | | |
| 74 | + | ||
| 75 | + | LabelGrid costs more than Audicient but brings Spotify Preferred status and Merlin integration. Revelator has better docs but is VC-backed and custom-priced (likely more expensive). | |
| 76 | + | ||
| 77 | + | ## Timing | |
| 78 | + | ||
| 79 | + | This is a later-game move. We need a decent creator userbase before pursuing DSP distribution -- the 0% platform fee promise carries more weight (and is more sustainable) when backed by real scale. Distribution is not an early differentiator; it's a feature we add once the core platform has proven itself. | |
| 80 | + | ||
| 81 | + | ## Revenue Model for DSP Distribution | |
| 82 | + | ||
| 83 | + | DSP distribution is one area where a greater-than-0% take is defensible, similar to how Stripe's ~3% processing fee is an external cost creators already accept. The key difference from platform fees: this is pass-through infrastructure cost, not MNW extracting value. | |
| 84 | + | ||
| 85 | + | The ideal negotiating position: MNW acts as a collective, trading percentage-based per-creator rates for a higher flat fee that the platform absorbs across its userbase. If we negotiate like a union on behalf of creators, we can convert what would be individual percentage cuts into a predictable platform cost -- keeping per-creator economics transparent and aligned with the 0% ethos even though the underlying infrastructure has real costs. | |
| 86 | + | ||
| 87 | + | This means the $10/month add-on price may need to flex based on what flat-fee terms we can negotiate. The goal is: creators pay a flat add-on, MNW absorbs the distribution cost at scale, and no percentage is taken from royalties. | |
| 88 | + | ||
| 89 | + | ## Recommendation | |
| 90 | + | ||
| 91 | + | **Pursue LabelGrid as the primary Phase 1 partner**, with Audicient as fallback. But not until the platform has enough active creators to justify the operational overhead and negotiate from strength. | |
| 92 | + | ||
| 93 | + | ## Next Steps (If Pursuing) | |
| 94 | + | ||
| 95 | + | 1. **Contact LabelGrid about white-label/API terms** -- ask about per-track limits, 100% royalty pass-through, volume pricing, and whether we can brand the delivery chain as MNW. | |
| 96 | + | 2. **Test the sandbox API** -- `api.labelgrid.com/docs` has a public sandbox. Validate that the API covers required workflows (upload, metadata, submit, status, royalty report) before committing. | |
| 97 | + | 3. **Clarify royalty retention** -- 100% pass-through is non-negotiable. If they won't do 100%, that's a dealbreaker. | |
| 98 | + | 4. **Get contract terms on wind-down** -- what happens to catalog if LabelGrid folds? Can we export DDEX metadata and re-deliver through another provider? |
| @@ -13,7 +13,12 @@ Priority order. See `human_todo.md` for the full manual testing feature map. | |||
| 13 | 13 | ||
| 14 | 14 | 1. ~~**Deploy**~~ — Done (v0.5.14, 2026-05-11). Run 24 fixes + scheduler SQL fixes + robots.txt + Prometheus auth + ALERT_EMAIL + tag taxonomy overhaul (migration 111). | |
| 15 | 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 | |
| 16 | + | - SyncKit parity fixes shipped for AF + BB + GO (2026-05-11): OAuth auto-poll, CORS, CSP, synckit.toml, callback auto-complete | |
| 17 | + | - AF sync tested on live server — largely working (2026-05-11) | |
| 18 | + | - GO sync tested on live server — working (2026-05-11) | |
| 19 | + | - BB sync: synckit.toml still needed (API key pending) | |
| 20 | + | - Remaining: Stripe checkout e2e for all 3 apps, license key flow, promo codes | |
| 21 | + | 3. ~~**Content seeding**~~ — Done: AF 0.4.0 + GO 0.3.1 published on discover page. BB deferred (needs more plugins). | |
| 17 | 22 | 4. **Invite testers** — generate invite codes, send hand-written emails per `docs/internal/outreach/tiers.md` | |
| 18 | 23 | 5. ~~**Document undocumented features**~~ — Done: shopping cart, wishlist, creator pause all documented | |
| 19 | 24 | ||
| @@ -38,9 +43,23 @@ Priority order. See `human_todo.md` for the full manual testing feature map. | |||
| 38 | 43 | ||
| 39 | 44 | --- | |
| 40 | 45 | ||
| 41 | - | ## Ultra Fuzz Run 24 (2026-05-09) | |
| 42 | - | ||
| 43 | - | - [ ] DEFERRED: Extract shared `validate_promo_code()` helper to prevent checkout path divergence | |
| 46 | + | ## Ultra Fuzz Run 25 (2026-05-11) | |
| 47 | + | ||
| 48 | + | ### Current Phase | |
| 49 | + | - [x] **[SERIOUS]** Fix `db/cart.rs:106` -- change `t.user_id` to `t.buyer_id` (1 line) | |
| 50 | + | - [x] **[SERIOUS]** Add S3 cleanup + storage decrement to project deletion path (`routes/api/projects.rs:270-282`) | |
| 51 | + | - [x] **[SERIOUS]** Stream content export ZIP to S3 instead of in-memory buffer (`routes/api/exports/content.rs`) | |
| 52 | + | - [ ] **[MINOR]** Consolidate item cover image into single UPDATE (`routes/storage/images.rs:369-371`) | |
| 53 | + | - [ ] **[MINOR]** Use `try_replace_storage` for project image replace (`routes/storage/images.rs:173-185`) | |
| 54 | + | - [ ] **[MINOR]** Enqueue old project image S3 key to `pending_s3_deletions` | |
| 55 | + | - [ ] **[MINOR]** Add HTTP error check to purchase.html cart add-to-cart JS | |
| 56 | + | - [ ] **[MINOR]** Fix `format_price` negative formatting to use `-$X.XX` | |
| 57 | + | - [ ] **[MINOR]** Batch-load collection items in export handler (`exports/mod.rs:268-270`) | |
| 58 | + | - [ ] **[MINOR]** Switch `check_sandbox_cap` to `pg_try_advisory_lock` | |
| 59 | + | ||
| 60 | + | ### Deferred | |
| 61 | + | - [ ] DEFERRED: Stream build artifacts to S3 via multipart upload | |
| 62 | + | - [ ] DEFERRED: Extract shared `validate_promo_code()` helper to prevent checkout path divergence (carried from Run 24) | |
| 44 | 63 | ||
| 45 | 64 | --- | |
| 46 | 65 |
| @@ -0,0 +1,217 @@ | |||
| 1 | + | -- Overhaul tag taxonomy to enforced 3-level hierarchy: type.category.value. | |
| 2 | + | -- | |
| 3 | + | -- All tags now follow the pattern: | |
| 4 | + | -- Level 1 (type): audio, software, writing, video, visual, education | |
| 5 | + | -- Level 2 (category): genre, format, language, platform, medium, topic, ... | |
| 6 | + | -- Level 3+ (value): electronic, rust, essay, photography, ... | |
| 7 | + | -- | |
| 8 | + | -- Items may only be tagged with depth-3+ leaf tags. Levels 1-2 exist for | |
| 9 | + | -- navigation (tag tree browser, breadcrumbs) but are not assignable. | |
| 10 | + | -- | |
| 11 | + | -- slug = path = full dot-notation (e.g. "audio.genre.electronic"). | |
| 12 | + | ||
| 13 | + | -- ============================================================================ | |
| 14 | + | -- 1. Clear existing tag associations and tags | |
| 15 | + | -- ============================================================================ | |
| 16 | + | ||
| 17 | + | DELETE FROM item_tags; | |
| 18 | + | DELETE FROM tags; | |
| 19 | + | ||
| 20 | + | -- ============================================================================ | |
| 21 | + | -- 2. Type-level tags (depth 1) — not assignable to items | |
| 22 | + | -- ============================================================================ | |
| 23 | + | ||
| 24 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 25 | + | ('Audio', 'audio', NULL, 0, 'audio'), | |
| 26 | + | ('Software', 'software', NULL, 1, 'software'), | |
| 27 | + | ('Writing', 'writing', NULL, 2, 'writing'), | |
| 28 | + | ('Video', 'video', NULL, 3, 'video'), | |
| 29 | + | ('Visual', 'visual', NULL, 4, 'visual'), | |
| 30 | + | ('Education', 'education', NULL, 5, 'education'); | |
| 31 | + | ||
| 32 | + | -- ============================================================================ | |
| 33 | + | -- 3. Category-level tags (depth 2) — not assignable to items | |
| 34 | + | -- ============================================================================ | |
| 35 | + | ||
| 36 | + | -- Audio categories | |
| 37 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 38 | + | ('Genre', 'audio.genre', (SELECT id FROM tags WHERE slug = 'audio'), 0, 'audio.genre'), | |
| 39 | + | ('Format', 'audio.format', (SELECT id FROM tags WHERE slug = 'audio'), 1, 'audio.format'), | |
| 40 | + | ('Instrument', 'audio.instrument', (SELECT id FROM tags WHERE slug = 'audio'), 2, 'audio.instrument'), | |
| 41 | + | ('Technique', 'audio.technique', (SELECT id FROM tags WHERE slug = 'audio'), 3, 'audio.technique'); | |
| 42 | + | ||
| 43 | + | -- Software categories | |
| 44 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 45 | + | ('Language', 'software.language', (SELECT id FROM tags WHERE slug = 'software'), 0, 'software.language'), | |
| 46 | + | ('Platform', 'software.platform', (SELECT id FROM tags WHERE slug = 'software'), 1, 'software.platform'), | |
| 47 | + | ('Format', 'software.format', (SELECT id FROM tags WHERE slug = 'software'), 2, 'software.format'); | |
| 48 | + | ||
| 49 | + | -- Writing categories | |
| 50 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 51 | + | ('Format', 'writing.format', (SELECT id FROM tags WHERE slug = 'writing'), 0, 'writing.format'), | |
| 52 | + | ('Genre', 'writing.genre', (SELECT id FROM tags WHERE slug = 'writing'), 1, 'writing.genre'), | |
| 53 | + | ('Topic', 'writing.topic', (SELECT id FROM tags WHERE slug = 'writing'), 2, 'writing.topic'); | |
| 54 | + | ||
| 55 | + | -- Video categories | |
| 56 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 57 | + | ('Genre', 'video.genre', (SELECT id FROM tags WHERE slug = 'video'), 0, 'video.genre'), | |
| 58 | + | ('Format', 'video.format', (SELECT id FROM tags WHERE slug = 'video'), 1, 'video.format'); | |
| 59 | + | ||
| 60 | + | -- Visual categories | |
| 61 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 62 | + | ('Medium', 'visual.medium', (SELECT id FROM tags WHERE slug = 'visual'), 0, 'visual.medium'), | |
| 63 | + | ('Style', 'visual.style', (SELECT id FROM tags WHERE slug = 'visual'), 1, 'visual.style'); | |
| 64 | + | ||
| 65 | + | -- Education categories | |
| 66 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 67 | + | ('Topic', 'education.topic', (SELECT id FROM tags WHERE slug = 'education'), 0, 'education.topic'), | |
| 68 | + | ('Format', 'education.format', (SELECT id FROM tags WHERE slug = 'education'), 1, 'education.format'); | |
| 69 | + | ||
| 70 | + | -- ============================================================================ | |
| 71 | + | -- 4. Value-level tags (depth 3) — these are assignable to items | |
| 72 | + | -- ============================================================================ | |
| 73 | + | ||
| 74 | + | -- audio.genre.* | |
| 75 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 76 | + | ('Electronic', 'audio.genre.electronic', (SELECT id FROM tags WHERE slug = 'audio.genre'), 0, 'audio.genre.electronic'), | |
| 77 | + | ('Ambient', 'audio.genre.ambient', (SELECT id FROM tags WHERE slug = 'audio.genre'), 1, 'audio.genre.ambient'), | |
| 78 | + | ('Hip Hop', 'audio.genre.hip-hop', (SELECT id FROM tags WHERE slug = 'audio.genre'), 2, 'audio.genre.hip-hop'), | |
| 79 | + | ('Rock', 'audio.genre.rock', (SELECT id FROM tags WHERE slug = 'audio.genre'), 3, 'audio.genre.rock'), | |
| 80 | + | ('Jazz', 'audio.genre.jazz', (SELECT id FROM tags WHERE slug = 'audio.genre'), 4, 'audio.genre.jazz'), | |
| 81 | + | ('Classical', 'audio.genre.classical', (SELECT id FROM tags WHERE slug = 'audio.genre'), 5, 'audio.genre.classical'), | |
| 82 | + | ('Folk', 'audio.genre.folk', (SELECT id FROM tags WHERE slug = 'audio.genre'), 6, 'audio.genre.folk'), | |
| 83 | + | ('Experimental', 'audio.genre.experimental', (SELECT id FROM tags WHERE slug = 'audio.genre'), 7, 'audio.genre.experimental'), | |
| 84 | + | ('Pop', 'audio.genre.pop', (SELECT id FROM tags WHERE slug = 'audio.genre'), 8, 'audio.genre.pop'), | |
| 85 | + | ('Metal', 'audio.genre.metal', (SELECT id FROM tags WHERE slug = 'audio.genre'), 9, 'audio.genre.metal'), | |
| 86 | + | ('Indie', 'audio.genre.indie', (SELECT id FROM tags WHERE slug = 'audio.genre'), 10, 'audio.genre.indie'); | |
| 87 | + | ||
| 88 | + | -- audio.format.* | |
| 89 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 90 | + | ('Podcast', 'audio.format.podcast', (SELECT id FROM tags WHERE slug = 'audio.format'), 0, 'audio.format.podcast'), | |
| 91 | + | ('Samples', 'audio.format.samples', (SELECT id FROM tags WHERE slug = 'audio.format'), 1, 'audio.format.samples'), | |
| 92 | + | ('Music', 'audio.format.music', (SELECT id FROM tags WHERE slug = 'audio.format'), 2, 'audio.format.music'), | |
| 93 | + | ('Interview', 'audio.format.interview', (SELECT id FROM tags WHERE slug = 'audio.format'), 3, 'audio.format.interview'), | |
| 94 | + | ('Audiobook', 'audio.format.audiobook', (SELECT id FROM tags WHERE slug = 'audio.format'), 4, 'audio.format.audiobook'), | |
| 95 | + | ('Field Recording', 'audio.format.field-recording', (SELECT id FROM tags WHERE slug = 'audio.format'), 5, 'audio.format.field-recording'), | |
| 96 | + | ('Sound Design', 'audio.format.sound-design', (SELECT id FROM tags WHERE slug = 'audio.format'), 6, 'audio.format.sound-design'), | |
| 97 | + | ('Live Performance', 'audio.format.live-performance',(SELECT id FROM tags WHERE slug = 'audio.format'), 7, 'audio.format.live-performance'); | |
| 98 | + | ||
| 99 | + | -- audio.instrument.* | |
| 100 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 101 | + | ('Synth', 'audio.instrument.synth', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 0, 'audio.instrument.synth'), | |
| 102 | + | ('Guitar', 'audio.instrument.guitar', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 1, 'audio.instrument.guitar'), | |
| 103 | + | ('Drums', 'audio.instrument.drums', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 2, 'audio.instrument.drums'), | |
| 104 | + | ('Bass', 'audio.instrument.bass', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 3, 'audio.instrument.bass'), | |
| 105 | + | ('Piano', 'audio.instrument.piano', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 4, 'audio.instrument.piano'), | |
| 106 | + | ('Vocals', 'audio.instrument.vocals', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 5, 'audio.instrument.vocals'), | |
| 107 | + | ('Strings', 'audio.instrument.strings', (SELECT id FROM tags WHERE slug = 'audio.instrument'), 6, 'audio.instrument.strings'); | |
| 108 | + | ||
| 109 | + | -- audio.technique.* | |
| 110 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 111 | + | ('Mixing', 'audio.technique.mixing', (SELECT id FROM tags WHERE slug = 'audio.technique'), 0, 'audio.technique.mixing'), | |
| 112 | + | ('Mastering', 'audio.technique.mastering', (SELECT id FROM tags WHERE slug = 'audio.technique'), 1, 'audio.technique.mastering'), | |
| 113 | + | ('Sampling', 'audio.technique.sampling', (SELECT id FROM tags WHERE slug = 'audio.technique'), 2, 'audio.technique.sampling'), | |
| 114 | + | ('Production', 'audio.technique.production', (SELECT id FROM tags WHERE slug = 'audio.technique'), 3, 'audio.technique.production'); | |
| 115 | + | ||
| 116 | + | -- software.language.* | |
| 117 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 118 | + | ('Rust', 'software.language.rust', (SELECT id FROM tags WHERE slug = 'software.language'), 0, 'software.language.rust'), | |
| 119 | + | ('Go', 'software.language.go', (SELECT id FROM tags WHERE slug = 'software.language'), 1, 'software.language.go'), | |
| 120 | + | ('TypeScript', 'software.language.typescript', (SELECT id FROM tags WHERE slug = 'software.language'), 2, 'software.language.typescript'), | |
| 121 | + | ('Python', 'software.language.python', (SELECT id FROM tags WHERE slug = 'software.language'), 3, 'software.language.python'), | |
| 122 | + | ('C', 'software.language.c', (SELECT id FROM tags WHERE slug = 'software.language'), 4, 'software.language.c'), | |
| 123 | + | ('C++', 'software.language.cpp', (SELECT id FROM tags WHERE slug = 'software.language'), 5, 'software.language.cpp'), | |
| 124 | + | ('Zig', 'software.language.zig', (SELECT id FROM tags WHERE slug = 'software.language'), 6, 'software.language.zig'); | |
| 125 | + | ||
| 126 | + | -- software.platform.* | |
| 127 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 128 | + | ('Linux', 'software.platform.linux', (SELECT id FROM tags WHERE slug = 'software.platform'), 0, 'software.platform.linux'), | |
| 129 | + | ('macOS', 'software.platform.macos', (SELECT id FROM tags WHERE slug = 'software.platform'), 1, 'software.platform.macos'), | |
| 130 | + | ('Windows', 'software.platform.windows', (SELECT id FROM tags WHERE slug = 'software.platform'), 2, 'software.platform.windows'), | |
| 131 | + | ('Web', 'software.platform.web', (SELECT id FROM tags WHERE slug = 'software.platform'), 3, 'software.platform.web'), | |
| 132 | + | ('DAW', 'software.platform.daw', (SELECT id FROM tags WHERE slug = 'software.platform'), 4, 'software.platform.daw'), | |
| 133 | + | ('Mobile', 'software.platform.mobile', (SELECT id FROM tags WHERE slug = 'software.platform'), 5, 'software.platform.mobile'), | |
| 134 | + | ('Cross-Platform', 'software.platform.cross-platform', (SELECT id FROM tags WHERE slug = 'software.platform'), 6, 'software.platform.cross-platform'); | |
| 135 | + | ||
| 136 | + | -- software.format.* | |
| 137 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 138 | + | ('Plugin', 'software.format.plugin', (SELECT id FROM tags WHERE slug = 'software.format'), 0, 'software.format.plugin'), | |
| 139 | + | ('App', 'software.format.app', (SELECT id FROM tags WHERE slug = 'software.format'), 1, 'software.format.app'), | |
| 140 | + | ('Game', 'software.format.game', (SELECT id FROM tags WHERE slug = 'software.format'), 2, 'software.format.game'), | |
| 141 | + | ('CLI', 'software.format.cli', (SELECT id FROM tags WHERE slug = 'software.format'), 3, 'software.format.cli'), | |
| 142 | + | ('Library', 'software.format.library', (SELECT id FROM tags WHERE slug = 'software.format'), 4, 'software.format.library'), | |
| 143 | + | ('Desktop', 'software.format.desktop', (SELECT id FROM tags WHERE slug = 'software.format'), 5, 'software.format.desktop'), | |
| 144 | + | ('VST3', 'software.format.vst3', (SELECT id FROM tags WHERE slug = 'software.format'), 6, 'software.format.vst3'), | |
| 145 | + | ('CLAP', 'software.format.clap', (SELECT id FROM tags WHERE slug = 'software.format'), 7, 'software.format.clap'); | |
| 146 | + | ||
| 147 | + | -- writing.format.* | |
| 148 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 149 | + | ('Essay', 'writing.format.essay', (SELECT id FROM tags WHERE slug = 'writing.format'), 0, 'writing.format.essay'), | |
| 150 | + | ('Book', 'writing.format.book', (SELECT id FROM tags WHERE slug = 'writing.format'), 1, 'writing.format.book'), | |
| 151 | + | ('Newsletter', 'writing.format.newsletter', (SELECT id FROM tags WHERE slug = 'writing.format'), 2, 'writing.format.newsletter'), | |
| 152 | + | ('Poetry', 'writing.format.poetry', (SELECT id FROM tags WHERE slug = 'writing.format'), 3, 'writing.format.poetry'), | |
| 153 | + | ('Tutorial', 'writing.format.tutorial', (SELECT id FROM tags WHERE slug = 'writing.format'), 4, 'writing.format.tutorial'), | |
| 154 | + | ('Preview', 'writing.format.preview', (SELECT id FROM tags WHERE slug = 'writing.format'), 5, 'writing.format.preview'), | |
| 155 | + | ('Article', 'writing.format.article', (SELECT id FROM tags WHERE slug = 'writing.format'), 6, 'writing.format.article'), | |
| 156 | + | ('Blog', 'writing.format.blog', (SELECT id FROM tags WHERE slug = 'writing.format'), 7, 'writing.format.blog'); | |
| 157 | + | ||
| 158 | + | -- writing.genre.* | |
| 159 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 160 | + | ('Fiction', 'writing.genre.fiction', (SELECT id FROM tags WHERE slug = 'writing.genre'), 0, 'writing.genre.fiction'), | |
| 161 | + | ('Non-Fiction', 'writing.genre.non-fiction', (SELECT id FROM tags WHERE slug = 'writing.genre'), 1, 'writing.genre.non-fiction'), | |
| 162 | + | ('Memoir', 'writing.genre.memoir', (SELECT id FROM tags WHERE slug = 'writing.genre'), 2, 'writing.genre.memoir'), | |
| 163 | + | ('Sci-Fi', 'writing.genre.sci-fi', (SELECT id FROM tags WHERE slug = 'writing.genre'), 3, 'writing.genre.sci-fi'), | |
| 164 | + | ('Fantasy', 'writing.genre.fantasy', (SELECT id FROM tags WHERE slug = 'writing.genre'), 4, 'writing.genre.fantasy'); | |
| 165 | + | ||
| 166 | + | -- writing.topic.* | |
| 167 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 168 | + | ('Philosophy', 'writing.topic.philosophy', (SELECT id FROM tags WHERE slug = 'writing.topic'), 0, 'writing.topic.philosophy'), | |
| 169 | + | ('Creativity', 'writing.topic.creativity', (SELECT id FROM tags WHERE slug = 'writing.topic'), 1, 'writing.topic.creativity'), | |
| 170 | + | ('Productivity', 'writing.topic.productivity', (SELECT id FROM tags WHERE slug = 'writing.topic'), 2, 'writing.topic.productivity'), | |
| 171 | + | ('Technology', 'writing.topic.technology', (SELECT id FROM tags WHERE slug = 'writing.topic'), 3, 'writing.topic.technology'), | |
| 172 | + | ('Culture', 'writing.topic.culture', (SELECT id FROM tags WHERE slug = 'writing.topic'), 4, 'writing.topic.culture'), | |
| 173 | + | ('Politics', 'writing.topic.politics', (SELECT id FROM tags WHERE slug = 'writing.topic'), 5, 'writing.topic.politics'); | |
| 174 | + | ||
| 175 | + | -- video.genre.* | |
| 176 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 177 | + | ('Documentary', 'video.genre.documentary', (SELECT id FROM tags WHERE slug = 'video.genre'), 0, 'video.genre.documentary'), | |
| 178 | + | ('Short Film', 'video.genre.short-film', (SELECT id FROM tags WHERE slug = 'video.genre'), 1, 'video.genre.short-film'), | |
| 179 | + | ('Music Video', 'video.genre.music-video', (SELECT id FROM tags WHERE slug = 'video.genre'), 2, 'video.genre.music-video'), | |
| 180 | + | ('Animation', 'video.genre.animation', (SELECT id FROM tags WHERE slug = 'video.genre'), 3, 'video.genre.animation'); | |
| 181 | + | ||
| 182 | + | -- video.format.* | |
| 183 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 184 | + | ('Tutorial', 'video.format.tutorial', (SELECT id FROM tags WHERE slug = 'video.format'), 0, 'video.format.tutorial'), | |
| 185 | + | ('Course', 'video.format.course', (SELECT id FROM tags WHERE slug = 'video.format'), 1, 'video.format.course'), | |
| 186 | + | ('Live Stream', 'video.format.live-stream', (SELECT id FROM tags WHERE slug = 'video.format'), 2, 'video.format.live-stream'), | |
| 187 | + | ('Vlog', 'video.format.vlog', (SELECT id FROM tags WHERE slug = 'video.format'), 3, 'video.format.vlog'); | |
| 188 | + | ||
| 189 | + | -- visual.medium.* | |
| 190 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 191 | + | ('Photography', 'visual.medium.photography', (SELECT id FROM tags WHERE slug = 'visual.medium'), 0, 'visual.medium.photography'), | |
| 192 | + | ('Illustration', 'visual.medium.illustration', (SELECT id FROM tags WHERE slug = 'visual.medium'), 1, 'visual.medium.illustration'), | |
| 193 | + | ('Design', 'visual.medium.design', (SELECT id FROM tags WHERE slug = 'visual.medium'), 2, 'visual.medium.design'), | |
| 194 | + | ('Comics', 'visual.medium.comics', (SELECT id FROM tags WHERE slug = 'visual.medium'), 3, 'visual.medium.comics'), | |
| 195 | + | ('3D', 'visual.medium.3d', (SELECT id FROM tags WHERE slug = 'visual.medium'), 4, 'visual.medium.3d'), | |
| 196 | + | ('Pixel Art', 'visual.medium.pixel-art', (SELECT id FROM tags WHERE slug = 'visual.medium'), 5, 'visual.medium.pixel-art'); | |
| 197 | + | ||
| 198 | + | -- visual.style.* | |
| 199 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 200 | + | ('Minimal', 'visual.style.minimal', (SELECT id FROM tags WHERE slug = 'visual.style'), 0, 'visual.style.minimal'), | |
| 201 | + | ('Abstract', 'visual.style.abstract', (SELECT id FROM tags WHERE slug = 'visual.style'), 1, 'visual.style.abstract'), | |
| 202 | + | ('Realist', 'visual.style.realist', (SELECT id FROM tags WHERE slug = 'visual.style'), 2, 'visual.style.realist'); | |
| 203 | + | ||
| 204 | + | -- education.topic.* | |
| 205 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 206 | + | ('Programming', 'education.topic.programming', (SELECT id FROM tags WHERE slug = 'education.topic'), 0, 'education.topic.programming'), | |
| 207 | + | ('Music Production', 'education.topic.music-production', (SELECT id FROM tags WHERE slug = 'education.topic'), 1, 'education.topic.music-production'), | |
| 208 | + | ('Design', 'education.topic.design', (SELECT id FROM tags WHERE slug = 'education.topic'), 2, 'education.topic.design'), | |
| 209 | + | ('Writing', 'education.topic.writing', (SELECT id FROM tags WHERE slug = 'education.topic'), 3, 'education.topic.writing'), | |
| 210 | + | ('Business', 'education.topic.business', (SELECT id FROM tags WHERE slug = 'education.topic'), 4, 'education.topic.business'); | |
| 211 | + | ||
| 212 | + | -- education.format.* | |
| 213 | + | INSERT INTO tags (name, slug, parent_id, sort_order, path) VALUES | |
| 214 | + | ('Course', 'education.format.course', (SELECT id FROM tags WHERE slug = 'education.format'), 0, 'education.format.course'), | |
| 215 | + | ('Workshop', 'education.format.workshop', (SELECT id FROM tags WHERE slug = 'education.format'), 1, 'education.format.workshop'), | |
| 216 | + | ('Reference', 'education.format.reference', (SELECT id FROM tags WHERE slug = 'education.format'), 2, 'education.format.reference'), | |
| 217 | + | ('Guide', 'education.format.guide', (SELECT id FROM tags WHERE slug = 'education.format'), 3, 'education.format.guide'); |
| @@ -37,7 +37,7 @@ Exports include: | |||
| 37 | 37 | - Cancellation is always as easy or easier than signing up. | |
| 38 | 38 | - No exit fees, no retention flows, no dark patterns. | |
| 39 | 39 | - Export and download remain available through the end of your billing period. | |
| 40 | - | - After cancellation, your existing content stays published for 30 days. After that, items are hidden (not deleted). Resubscribe anytime to restore everything. | |
| 40 | + | - After cancellation, your existing content stays published for 30 days. After that, items are hidden (not deleted). Resubscribing to any tier automatically restores all hidden items. | |
| 41 | 41 | ||
| 42 | 42 | --- | |
| 43 | 43 |
| @@ -149,6 +149,7 @@ Artists, musicians, writers, developers, and makers who: | |||
| 149 | 149 | ## See Also | |
| 150 | 150 | ||
| 151 | 151 | - [Platform Economics](./economics.md): What it costs to run, where the money goes | |
| 152 | + | - [You're the Merchant of Record](./merchant-of-record.md): What it means, pros and cons, tax tools | |
| 152 | 153 | - [Generative AI Policy](./generative-ai.md): Content tiers and disclosure requirements | |
| 153 | 154 | - [What We Guarantee](./guarantees.md): Binding commitments, in writing | |
| 154 | 155 | - [FAQ](../support/faq.md): Quick answers |
| @@ -0,0 +1,135 @@ | |||
| 1 | + | # You're the Merchant of Record | |
| 2 | + | ||
| 3 | + | On Makenot.work, you are the merchant of record for your sales. This page explains what that means, why we chose this model, and what it asks of you. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## What "Merchant of Record" Means | |
| 8 | + | ||
| 9 | + | When a fan buys your work, the transaction is between them and you. Stripe processes the payment into your connected account. We host the storefront and deliver the files, but legally and financially, you are the seller. | |
| 10 | + | ||
| 11 | + | On platforms like Gumroad, the platform is the merchant of record. Fans pay Gumroad, and Gumroad pays you. On Makenot.work, fans pay you directly. | |
| 12 | + | ||
| 13 | + | This is the same model used by Bandcamp, itch.io, and Shopify. It is the standard for platforms that don't take a revenue cut. | |
| 14 | + | ||
| 15 | + | --- | |
| 16 | + | ||
| 17 | + | ## Why We Chose This | |
| 18 | + | ||
| 19 | + | Being the merchant of record means taking on financial risk: chargebacks, refund liability, fraud losses, tax obligations. Platforms that absorb those risks price them into their fees, typically 10-30% of every sale. | |
| 20 | + | ||
| 21 | + | We chose the direct model because it keeps your revenue in your hands and your business decisions under your control. We think that's worth the tradeoff, but it is a tradeoff, and you should understand it before signing up. | |
| 22 | + | ||
| 23 | + | --- | |
| 24 | + | ||
| 25 | + | ## What You Control | |
| 26 | + | ||
| 27 | + | As merchant of record, you make the decisions that would otherwise be made for you: | |
| 28 | + | ||
| 29 | + | - **Pricing.** You set prices, minimums, and pay-what-you-want ranges. No platform-imposed floors or ceilings. | |
| 30 | + | - **Refunds.** You decide your refund policy and process refunds yourself. No platform override. | |
| 31 | + | - **Billing descriptor.** Your Stripe account controls how charges appear on fan bank statements. | |
| 32 | + | - **Payout schedule.** You choose when money moves from Stripe to your bank: daily, weekly, monthly, or instant. | |
| 33 | + | - **Tax configuration.** You decide whether to enable Stripe's automatic tax collection based on your obligations. See [Tax Tools](#tax-tools-available-to-you) below. | |
| 34 | + | - **Your Stripe account.** It belongs to you. If you leave Makenot.work, it stays with you, with your full transaction history, payout records, and tax documents. | |
| 35 | + | ||
| 36 | + | On a platform that is the merchant of record, the platform makes most of these decisions. That can be simpler, but it means the platform controls your relationship with your customers, your cash flow timing, and your pricing flexibility. | |
| 37 | + | ||
| 38 | + | --- | |
| 39 | + | ||
| 40 | + | ## What You're Responsible For | |
| 41 | + | ||
| 42 | + | ### Chargebacks | |
| 43 | + | ||
| 44 | + | If a fan disputes a charge with their bank, the disputed amount and Stripe's ~$15 dispute fee come from your Stripe balance. You submit evidence through your Stripe dashboard. If you win, everything is returned. If you lose, you absorb the cost. | |
| 45 | + | ||
| 46 | + | Chargebacks are rare on digital content platforms (well below 0.5% industry average for curated platforms), and most are preventable with clear product descriptions, visible refund policies, and prompt communication. A $5 refund is always cheaper than a disputed charge. | |
| 47 | + | ||
| 48 | + | See [Payments & Refunds](../legal/payments.md#chargebacks) for the full process. | |
| 49 | + | ||
| 50 | + | ### Refunds | |
| 51 | + | ||
| 52 | + | You process refunds through your Stripe dashboard. We don't intervene. This means you can be as generous or as strict as you want, but it also means fans may dispute charges with their bank if they can't resolve things with you directly. | |
| 53 | + | ||
| 54 | + | We recommend stating a clear refund policy on your profile or project pages. | |
| 55 | + | ||
| 56 | + | ### Taxes | |
| 57 | + | ||
| 58 | + | Since fans are paying you (not us), you are responsible for your tax obligations: | |
| 59 | + | ||
| 60 | + | - **Income tax.** Report earnings from fan payments. Stripe issues 1099-K forms for US creators above IRS reporting thresholds. | |
| 61 | + | - **Sales tax, VAT, and GST.** If you sell digital goods to fans in jurisdictions that tax digital sales (EU, UK, Australia, Canada, and others), you may be required to collect and remit those taxes even if you are outside those jurisdictions. | |
| 62 | + | - **Record-keeping.** Your dashboard and Stripe both provide transaction history and CSV exports for tax preparation. | |
| 63 | + | ||
| 64 | + | We do not collect, calculate, withhold, or remit taxes on your behalf. This is not tax advice. See [Tax Tools](#tax-tools-available-to-you) below for what's available to help. | |
| 65 | + | ||
| 66 | + | --- | |
| 67 | + | ||
| 68 | + | ## Tax Tools Available to You | |
| 69 | + | ||
| 70 | + | Tax complexity is the most common concern with the merchant-of-record model. These tools exist today to help: | |
| 71 | + | ||
| 72 | + | ### Stripe Tax (Built In) | |
| 73 | + | ||
| 74 | + | Your Makenot.work account includes a toggle to enable [Stripe Tax](https://stripe.com/tax), which automatically calculates and collects sales tax, VAT, and GST on your transactions based on the buyer's location. Enable it from your payment settings. | |
| 75 | + | ||
| 76 | + | Stripe Tax supports 50+ countries and all US states. It handles rate calculation, collection at checkout, and reporting. You are still responsible for registration and remittance in each jurisdiction, but Stripe handles the hardest part: knowing what rate to charge whom. | |
| 77 | + | ||
| 78 | + | ### Transaction Exports | |
| 79 | + | ||
| 80 | + | Export your full sales history as CSV from your dashboard at any time. Stripe also provides detailed tax reports, broken down by region, through the [Stripe Tax dashboard](https://dashboard.stripe.com/tax). | |
| 81 | + | ||
| 82 | + | ### Stripe 1099-K (US Creators) | |
| 83 | + | ||
| 84 | + | Stripe automatically issues 1099-K forms if your gross payments exceed IRS [reporting thresholds](https://support.stripe.com/topics/1099-tax-forms). These are delivered through your Stripe dashboard. | |
| 85 | + | ||
| 86 | + | ### External Tax Services | |
| 87 | + | ||
| 88 | + | If you sell across multiple jurisdictions and need help with registration, filing, and remittance, services like [Quaderno](https://quaderno.io), [TaxJar](https://www.taxjar.com), and [Lemon Squeezy](https://www.lemonsqueezy.com) specialize in digital goods tax compliance. These are third-party services; we have no affiliation with them. | |
| 89 | + | ||
| 90 | + | ### When to Pay Attention | |
| 91 | + | ||
| 92 | + | Most small creators selling domestically don't need to think about VAT or GST. The complexity scales with your business: | |
| 93 | + | ||
| 94 | + | - **Selling only in your home country**: Standard income tax rules apply. Stripe Tax can handle local sales tax if needed. | |
| 95 | + | - **Selling internationally under your country's threshold**: Monitor your cross-border sales volume. Stripe Tax reports help with this. | |
| 96 | + | - **Selling internationally above threshold**: You likely need to register for VAT OSS (EU), or equivalent programs in other regions. A tax professional or automated service is worth the cost at this point. | |
| 97 | + | ||
| 98 | + | Your dashboard's transaction history and Stripe's tax reports give you the data to know where you stand. | |
| 99 | + | ||
| 100 | + | --- | |
| 101 | + | ||
| 102 | + | ## Comparison | |
| 103 | + | ||
| 104 | + | | | You are MOR (Makenot.work, Bandcamp, itch.io, Shopify) | Platform is MOR (Gumroad, Patreon) | | |
| 105 | + | |---|---|---| | |
| 106 | + | | **Who fans pay** | You (via Stripe) | The platform | | |
| 107 | + | | **Platform fee** | 0% (Makenot.work) | 5-20% typically | | |
| 108 | + | | **Refund decisions** | Yours | Platform's (may override you) | | |
| 109 | + | | **Chargeback liability** | Yours | Platform's (priced into fees) | | |
| 110 | + | | **Tax collection** | Your responsibility (Stripe Tax available) | Platform may handle some | | |
| 111 | + | | **Pricing control** | Full | May have restrictions | | |
| 112 | + | | **Payout timing** | You choose | Platform schedule | | |
| 113 | + | | **Stripe account ownership** | Yours permanently | Platform-controlled | | |
| 114 | + | | **Portability** | Take your payment history and go | Start over elsewhere | | |
| 115 | + | ||
| 116 | + | Neither model is universally better. The merchant-of-record model gives you more control and more revenue at the cost of more responsibility. Platforms that act as merchant of record offer more convenience at the cost of higher fees and less autonomy. | |
| 117 | + | ||
| 118 | + | --- | |
| 119 | + | ||
| 120 | + | ## The Short Version | |
| 121 | + | ||
| 122 | + | You run a small business. We provide the storefront, hosting, and delivery. Stripe provides the payment infrastructure. You are the seller, and your customers are your customers. | |
| 123 | + | ||
| 124 | + | That means you make the decisions about pricing, refunds, and how you handle your taxes. It also means you keep your revenue, your Stripe account, your transaction history, and your customer relationships whether you stay on Makenot.work or not. | |
| 125 | + | ||
| 126 | + | --- | |
| 127 | + | ||
| 128 | + | ## See Also | |
| 129 | + | ||
| 130 | + | - [Stripe: What You Need to Know](../guide/stripe.md): Fees by country, currency conversion, Stripe Tax, chargeback protection | |
| 131 | + | - [Payments & Refunds](../legal/payments.md): Full payment flow, chargeback process, refund handling | |
| 132 | + | - [Receiving Payouts](../guide/payouts.md): Payout schedules, international payments, tax information | |
| 133 | + | - [Pricing Tiers](../guide/tiers.md): What each tier costs and includes | |
| 134 | + | - [What We Guarantee](./guarantees.md): Binding commitments on revenue, data, and pricing | |
| 135 | + | - [Platform Economics](./economics.md): What it costs to run, where the money goes |
| @@ -72,7 +72,7 @@ All payments go through the payment processor: | |||
| 72 | 72 | Fan pays → Payment processor → Creator's connected account | |
| 73 | 73 | ``` | |
| 74 | 74 | ||
| 75 | - | We take 0% platform fee. Only the payment processing fee (~3%) applies. | |
| 75 | + | We take 0% platform fee. Only the payment processing fee (~3% + $0.30 per transaction) applies. See [Pricing Tiers](./tiers.md#understanding-stripes-per-transaction-fee) for how this affects small transactions. | |
| 76 | 76 | ||
| 77 | 77 | ## Refunds | |
| 78 | 78 |
| @@ -16,7 +16,7 @@ Fan+ will never gate access to any creator's content. It is separate from per-pr | |||
| 16 | 16 | ||
| 17 | 17 | ### Joining | |
| 18 | 18 | ||
| 19 | - | Fans join Fan+ from their account settings. Pricing is shown at checkout. Payment is handled through Stripe on a monthly billing cycle. | |
| 19 | + | Fans join Fan+ from their account settings. Fan+ costs $8/month. Payment is handled through Stripe on a monthly billing cycle. | |
| 20 | 20 | ||
| 21 | 21 | ### Managing the Membership | |
| 22 | 22 |
| @@ -107,7 +107,7 @@ Projects organize your work -- albums, podcast feeds, or product lines. | |||
| 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 | ||
| 110 | - | **The membership costs money whether you sell or not.** An [earn-back credit program](../about/roadmap.md#earn-back-credit-program) is on the roadmap. Use the [pricing calculator](/pricing) to compare what you'd keep here versus percentage-cut platforms. | |
| 110 | + | **The membership costs money whether you sell or not.** An [earn-back credit program](../about/roadmap.md#earn-back-credit-program) is on the roadmap (expected 2027). Use the [pricing calculator](/pricing) to compare what you'd keep here versus percentage-cut platforms. | |
| 111 | 111 | ||
| 112 | 112 | **Moderation appeals are reviewed by the founder.** Independent review is planned once the team grows. See [Appeals](../legal/appeals.md) for the full process. | |
| 113 | 113 |
| @@ -13,12 +13,12 @@ We use a direct-deposit payment model. When you sign up: | |||
| 13 | 13 | ||
| 14 | 14 | You control this in your [Stripe dashboard](https://dashboard.stripe.com): | |
| 15 | 15 | ||
| 16 | - | - **Instant**: Available immediately (small fee) | |
| 16 | + | - **Instant**: Available immediately (1% fee, charged by Stripe) | |
| 17 | 17 | - **Daily**: Next business day | |
| 18 | 18 | - **Weekly**: Every week on your chosen day | |
| 19 | 19 | - **Monthly**: First of the month | |
| 20 | 20 | ||
| 21 | - | Default is standard payouts (2-3 business days). | |
| 21 | + | Default is standard payouts (2-3 business days). New Stripe accounts typically have an initial 7-14 day hold period on first payouts while Stripe verifies your account. | |
| 22 | 22 | ||
| 23 | 23 | ## Minimum Payout | |
| 24 | 24 | ||
| @@ -100,7 +100,9 @@ Clear product descriptions, prompt communication, and a visible refund policy re | |||
| 100 | 100 | ||
| 101 | 101 | ## See Also | |
| 102 | 102 | ||
| 103 | + | - [You're the Merchant of Record](../about/merchant-of-record.md): What it means, pros and cons, tax tools | |
| 103 | 104 | - [Analytics & Dashboard](./analytics.md): Revenue charts and transaction history | |
| 104 | 105 | - [Pricing & Monetization](./03-selling.md): Setting prices and payment flow | |
| 106 | + | - [Stripe: What You Need to Know](./stripe.md): Fees by country, currency conversion, Stripe Tax, chargeback protection | |
| 105 | 107 | - [Stripe Global](https://stripe.com/global): Supported countries | |
| 106 | 108 | - [Stripe Payouts](https://docs.stripe.com/payouts): Payout schedules and timing |
| @@ -0,0 +1,146 @@ | |||
| 1 | + | # Stripe: What You Need to Know | |
| 2 | + | ||
| 3 | + | Makenot.work uses [Stripe](https://stripe.com) as its payment processor. This page collects the Stripe-specific details that affect your costs, payouts, and operations as a creator. | |
| 4 | + | ||
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | ## Processing Fees | |
| 8 | + | ||
| 9 | + | Stripe charges a per-transaction fee on every fan payment. The exact rate depends on your country: | |
| 10 | + | ||
| 11 | + | | Region | Rate | | |
| 12 | + | |--------|------| | |
| 13 | + | | United States | 2.9% + $0.30 | | |
| 14 | + | | Canada | 2.9% + C$0.30 | | |
| 15 | + | | United Kingdom | 1.5% + 20p (domestic), 3.25% + 20p (EU) | | |
| 16 | + | | EU (most countries) | 1.5% + €0.25 (domestic), 3.25% + €0.25 (cross-border) | | |
| 17 | + | | Australia | 1.75% + A$0.30 (domestic), 3.5% + A$0.30 (international) | | |
| 18 | + | | Japan | 3.6% | | |
| 19 | + | ||
| 20 | + | These rates change. Check [stripe.com/pricing](https://stripe.com/pricing) for your country's current rates. | |
| 21 | + | ||
| 22 | + | ### The Flat Fee Matters on Small Transactions | |
| 23 | + | ||
| 24 | + | The per-transaction flat fee ($0.30 in the US) has a bigger impact on small sales: | |
| 25 | + | ||
| 26 | + | | Sale price | Stripe fee (US) | Effective rate | You keep | | |
| 27 | + | |---:|---:|---:|---:| | |
| 28 | + | | $1 | $0.33 | 33% | $0.67 | | |
| 29 | + | | $5 | $0.45 | 9% | $4.55 | | |
| 30 | + | | $10 | $0.59 | 5.9% | $9.41 | | |
| 31 | + | | $25 | $1.03 | 4.1% | $23.98 | | |
| 32 | + | | $50 | $1.75 | 3.5% | $48.25 | | |
| 33 | + | ||
| 34 | + | Bundling items (albums, packs) reduces the per-item impact of the flat fee. | |
| 35 | + | ||
| 36 | + | --- | |
| 37 | + | ||
| 38 | + | ## Payout Timing | |
| 39 | + | ||
| 40 | + | You choose your payout schedule in your [Stripe dashboard](https://dashboard.stripe.com/settings/payouts): | |
| 41 | + | ||
| 42 | + | | Schedule | When funds arrive | | |
| 43 | + | |----------|-------------------| | |
| 44 | + | | Standard | 2-3 business days | | |
| 45 | + | | Daily | Next business day | | |
| 46 | + | | Weekly | Your chosen day of the week | | |
| 47 | + | | Monthly | First of the month | | |
| 48 | + | | Instant | Immediately (1% fee, charged by Stripe) | | |
| 49 | + | ||
| 50 | + | ### First Payout | |
| 51 | + | ||
| 52 | + | New Stripe accounts typically have a 7-14 day hold on first payouts while Stripe verifies your account. After the initial hold clears, your chosen schedule applies. | |
| 53 | + | ||
| 54 | + | ### Minimum Payout | |
| 55 | + | ||
| 56 | + | Stripe requires a minimum balance before initiating a payout. In most regions this is $1 (or equivalent). You can set a higher threshold in your Stripe dashboard if you prefer larger, less frequent payouts. | |
| 57 | + | ||
| 58 | + | --- | |
| 59 | + | ||
| 60 | + | ## Currency Conversion | |
| 61 | + | ||
| 62 | + | If your fans pay in a different currency than your Stripe account's settlement currency, Stripe converts automatically. Conversion fees are typically 1-2% on top of the transaction fee. The exact rate depends on the currency pair. | |
| 63 | + | ||
| 64 | + | You can see conversion details on individual transactions in your Stripe dashboard. | |
| 65 | + | ||
| 66 | + | ### Multi-Currency Pricing | |
| 67 | + | ||
| 68 | + | Stripe supports [135+ currencies](https://docs.stripe.com/currencies) for payment. Fans are charged in their local currency when available. | |
| 69 | + | ||
| 70 | + | --- | |
| 71 | + | ||
| 72 | + | ## Stripe Tax | |
| 73 | + | ||
| 74 | + | Stripe offers automatic tax calculation and collection. When enabled, Stripe determines the correct sales tax, VAT, or GST rate based on the buyer's location and adds it at checkout. | |
| 75 | + | ||
| 76 | + | ### Enabling Stripe Tax | |
| 77 | + | ||
| 78 | + | Toggle Stripe Tax from your payment settings on Makenot.work. Once enabled, it applies to all your checkout sessions (purchases, memberships, tips). | |
| 79 | + | ||
| 80 | + | ### What Stripe Tax Handles | |
| 81 | + | ||
| 82 | + | - Rate calculation based on buyer location (50+ countries, all US states) | |
| 83 | + | - Collection at checkout | |
| 84 | + | - Tax reporting through your [Stripe Tax dashboard](https://dashboard.stripe.com/tax) | |
| 85 | + | ||
| 86 | + | ### What You Still Handle | |
| 87 | + | ||
| 88 | + | - **Registration**: You must register for tax collection in each jurisdiction where you have obligations. Stripe Tax tells you where you owe, but you register yourself. | |
| 89 | + | - **Remittance**: You file and pay collected taxes to the relevant authorities. | |
| 90 | + | - **Thresholds**: Monitor your sales volume by jurisdiction. Stripe Tax reports help with this. | |
| 91 | + | ||
| 92 | + | See [You're the Merchant of Record](../about/merchant-of-record.md#tax-tools-available-to-you) for guidance on when to pay attention to cross-border tax obligations. | |
| 93 | + | ||
| 94 | + | --- | |
| 95 | + | ||
| 96 | + | ## Chargeback Protection | |
| 97 | + | ||
| 98 | + | Stripe offers its own [Chargeback Protection](https://stripe.com/radar/chargeback-protection) product at 0.4% of transaction volume. It covers fraud-related chargebacks (stolen cards, unauthorized use). It does not cover "product not as described" or "unrecognized" disputes. | |
| 99 | + | ||
| 100 | + | You can enable it from your Stripe dashboard under Radar settings. This is a Stripe product, not a Makenot.work feature. | |
| 101 | + | ||
| 102 | + | --- | |
| 103 | + | ||
| 104 | + | ## Stripe Account Ownership | |
| 105 | + | ||
| 106 | + | Your Stripe connected account belongs to you. It is not controlled by Makenot.work. | |
| 107 | + | ||
| 108 | + | - If you leave Makenot.work, your Stripe account stays with you | |
| 109 | + | - Your transaction history, payout records, and tax documents remain in your Stripe dashboard | |
| 110 | + | - You can use the same Stripe account with other platforms or your own website | |
| 111 | + | ||
| 112 | + | --- | |
| 113 | + | ||
| 114 | + | ## Country Availability | |
| 115 | + | ||
| 116 | + | Stripe supports creators in [46+ countries](https://stripe.com/global). If your country is not supported, you cannot receive payouts through Makenot.work. We do not currently offer an alternative payment processor. | |
| 117 | + | ||
| 118 | + | If Stripe availability affects you, contact us at support@makenot.work. | |
| 119 | + | ||
| 120 | + | --- | |
| 121 | + | ||
| 122 | + | ## Instant Payouts | |
| 123 | + | ||
| 124 | + | Available in [supported countries](https://docs.stripe.com/payouts/instant-payouts). Stripe charges 1% of the payout amount (minimum $0.50). Funds arrive in your bank account within minutes. | |
| 125 | + | ||
| 126 | + | Availability depends on your bank and region. Check your Stripe dashboard to see if instant payouts are available for your account. | |
| 127 | + | ||
| 128 | + | --- | |
| 129 | + | ||
| 130 | + | ## Useful Stripe Links | |
| 131 | + | ||
| 132 | + | - [Stripe Dashboard](https://dashboard.stripe.com): Manage payouts, view transactions, configure settings | |
| 133 | + | - [Stripe Pricing](https://stripe.com/pricing): Current fees for your country | |
| 134 | + | - [Stripe Tax](https://dashboard.stripe.com/tax): Tax collection settings and reports | |
| 135 | + | - [Stripe Payouts](https://docs.stripe.com/payouts): Payout schedules, timing, instant payouts | |
| 136 | + | - [Stripe Global](https://stripe.com/global): Supported countries | |
| 137 | + | - [Stripe Support](https://support.stripe.com): Account issues, verification, disputes | |
| 138 | + | ||
| 139 | + | --- | |
| 140 | + | ||
| 141 | + | ## See Also | |
| 142 | + | ||
| 143 | + | - [You're the Merchant of Record](../about/merchant-of-record.md): What being the seller means for you | |
| 144 | + | - [Receiving Payouts](./payouts.md): Full payout guide including tax information | |
| 145 | + | - [Payments & Refunds](../legal/payments.md): Payment flow, chargebacks, refund handling | |
| 146 | + | - [Pricing Tiers](./tiers.md): What each tier costs and includes |
| @@ -99,6 +99,8 @@ For creators who want every feature the platform offers, now and in the future. | |||
| 99 | 99 | ||
| 100 | 100 | As the platform grows, this tier always includes the full feature set. You won't need to upgrade again. | |
| 101 | 101 | ||
| 102 | + | Our goal is to move features *down* into lower tiers over time, not gate more behind Everything. The economics of running this platform are a problem to be solved, not a goal to be maximized. If infrastructure costs drop or the creator base grows enough, we'd rather lower prices or expand what each tier includes than keep the margin. | |
| 103 | + | ||
| 102 | 104 | ### Storage | |
| 103 | 105 | ||
| 104 | 106 | - **500GB total** for primary content (same as Big Files) | |
| @@ -108,7 +110,7 @@ As the platform grows, this tier always includes the full feature set. You won't | |||
| 108 | 110 | ||
| 109 | 111 | ## What Creators Keep: Example Earnings | |
| 110 | 112 | ||
| 111 | - | These examples use approximate US Stripe fees (2.9% + $0.30 per transaction) and 0% platform fee. All numbers are monthly. | |
| 113 | + | These examples use approximate US Stripe fees (2.9% + $0.30 per transaction) and 0% platform fee. Stripe fees vary by country (typically 2.2%-3.5%); check [stripe.com/pricing](https://stripe.com/pricing) for your region. All numbers are monthly. | |
| 112 | 114 | ||
| 113 | 115 | ### Understanding Stripe's Per-Transaction Fee | |
| 114 | 116 | ||
| @@ -222,7 +224,7 @@ Upgrades take effect immediately and are prorated. If you're at 240GB on Small F | |||
| 222 | 224 | - **Upgrade** anytime. All existing content, members, and settings carry over. Prorated for the current billing period. | |
| 223 | 225 | - **Downgrade** anytime. Existing files stay. New uploads are subject to the lower tier's limits. If you're over the new cap, you can't upload until you're under the limit. | |
| 224 | 226 | - **Missed payment**: If a payment fails, Stripe retries automatically (typically 3 attempts over ~3 weeks). During this period your existing content stays published, but new uploads are disabled. If all retries fail, the membership cancels and the cancellation grace period begins. | |
| 225 | - | - **Cancellation**: 30-day grace period applies. Uploads are disabled, but existing items remain accessible to past buyers. After 30 days, items become hidden. Rejoin to restore everything. | |
| 227 | + | - **Cancellation**: 30-day grace period applies. Uploads are disabled, but existing items remain accessible to past buyers. After 30 days, items become hidden (not deleted). Resubscribing to any tier automatically restores all hidden items. | |
| 226 | 228 | ||
| 227 | 229 | --- | |
| 228 | 230 |