Skip to main content

max / makenotwork

v0.5.18: bump version, plans, scaling doc, proptest regression seed Server version bump for the Fan+ self-service / oauth perks / migration 115 release. Docs: - scaling.md: capacity planning + cost-curve projection from current topology through 100k creators - plans/custom-pages.md: MySpace-style user-editable HTML + CSS pages (subdomain-isolated, ammonia + lightningcss sanitization) - plans/small-creator-onramp.md: creator-to-creator referrals, charter pricing lock, earnings-funded subscription - todo.md: DIY tier, small-creator on-ramp, infra/scaling, code assessment blind spots, Fan+ Done items - human_todo.md: Stripe Customer Portal activation row, DIY tier pre-implementation decisions - architecture.md: cross-ref to scaling.md - platform-overview.html: brand polish (Young Serif headers, beige bg instead of warm-white, single muted-text variable) Cargo: wiremock 0.6 as dev-dep (matches MT's addition); Cargo.lock follows. proptest-regressions/validation/mod.txt: failure seed committed by convention so the regression is reproducible across runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-15 17:23 UTC
Commit: 634d41652f8f5720c17618bca2463e68bb45a7fd
Parent: ec941f2
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&ndash;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&ndash;65</td>
423 418 </tr>
424 419 <tr>
425 420 <td>$1,000</td>
426 421 <td class="num highlight-row">$881&ndash;931</td>
427 - <td class="num">$870</td>
428 - <td class="num">$820</td>
429 - <td class="num">$61&ndash;111</td>
422 + <td class="num">$841</td>
423 + <td class="num">$791</td>
424 + <td class="num">$90&ndash;140</td>
430 425 </tr>
431 426 <tr>
432 427 <td>$2,000</td>
433 428 <td class="num highlight-row">$1,822&ndash;1,872</td>
434 429 <td class="num">$1,682</td>
435 430 <td class="num">$1,582</td>
436 - <td class="num">$140&ndash;290</td>
431 + <td class="num">$240&ndash;290</td>
437 432 </tr>
438 433 <tr>
439 434 <td>$5,000</td>
440 - <td class="num highlight-row">$4,795&ndash;4,845</td>
441 - <td class="num">$4,355</td>
442 - <td class="num">$3,855</td>
443 - <td class="num">$440&ndash;990</td>
435 + <td class="num highlight-row">$4,645&ndash;4,695</td>
436 + <td class="num">$4,205</td>
437 + <td class="num">$3,955</td>
438 + <td class="num">$690&ndash;740</td>
444 439 </tr>
445 440 <tr>
446 441 <td>$10,000</td>
447 442 <td class="num highlight-row">$9,350&ndash;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&ndash;1,490</td>
445 + <td class="num">$1,440&ndash;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&ndash;$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&ndash;$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 = "--"