Skip to main content

max / makenotwork

Server: Everything tier pricing, OpenAPI, SyncKit push idempotency, docs refresh - Raise Everything tier to $60, rename from Streaming, add migration 079 - Add OpenAPI spec via utoipa for public API endpoints - SyncKit push idempotency (migration 083), drop fingerprinting tables (082) - Refresh legal docs, pricing pages, guides, pitches for LLC entity - Expand creator tiers, promo codes, validated types, error handling - Add lifecycle integration tests, update test harness
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-01 14:24 UTC
Commit: 384217c83b0d2e0d49f63ed70f6b1f30375b391e
Parent: 204cfd1
88 files changed, +4245 insertions, -1047 deletions
M .gitignore +4
@@ -36,3 +36,7 @@
36 36
37 37 # Generated rustdoc output
38 38 server/rustdoc-out/
39 +
40 + # Mutation testing output
41 + mutants.out*
42 + **/mutants.out*
M CLAUDE.md +1 -1
@@ -26,7 +26,7 @@
26 26 - **Basic** — $10/mo (text, all base features)
27 27 - **Small Files** — $20/mo (audio, software, plugins, small downloads)
28 28 - **Big Files** — $30/mo (video, courses, large downloads)
29 - - **Everything** — $40/mo (all features, current and future)
29 + - **Everything** — $60/mo (live streaming, all features, current and future)
30 30
31 31 ## Ecosystem
32 32
M server/Cargo.lock +109 -1
@@ -3385,7 +3385,7 @@
3385 3385
3386 3386 [[package]]
3387 3387 name = "makenotwork"
3388 - version = "0.4.4"
3388 + version = "0.4.5"
3389 3389 dependencies = [
3390 3390 "anyhow",
3391 3391 "argon2",
@@ -3413,6 +3413,7 @@
3413 3413 "metrics",
3414 3414 "metrics-exporter-prometheus",
3415 3415 "openssl",
3416 + "proptest",
3416 3417 "rand 0.9.2",
3417 3418 "regex",
3418 3419 "reqwest",
@@ -3439,6 +3440,8 @@
3439 3440 "tracing-subscriber",
3440 3441 "url",
3441 3442 "urlencoding",
3443 + "utoipa",
3444 + "utoipa-axum",
3442 3445 "uuid 1.22.0",
3443 3446 "webauthn-authenticator-rs",
3444 3447 "webauthn-rs",
@@ -3951,6 +3954,12 @@
3951 3954 "subtle",
3952 3955 ]
3953 3956
3957 + [[package]]
3958 + name = "paste"
3959 + version = "1.0.15"
3960 + source = "registry+https://github.com/rust-lang/crates.io-index"
3961 + checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
3962 +
3954 3963 [[package]]
3955 3964 name = "pastey"
3956 3965 version = "0.1.1"
@@ -4214,6 +4223,25 @@
4214 4223 "unicode-ident",
4215 4224 ]
4216 4225
4226 + [[package]]
4227 + name = "proptest"
4228 + version = "1.11.0"
4229 + source = "registry+https://github.com/rust-lang/crates.io-index"
4230 + checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
4231 + dependencies = [
4232 + "bit-set",
4233 + "bit-vec",
4234 + "bitflags 2.11.0",
4235 + "num-traits",
4236 + "rand 0.9.2",
4237 + "rand_chacha 0.9.0",
4238 + "rand_xorshift",
4239 + "regex-syntax",
4240 + "rusty-fork",
4241 + "tempfile",
4242 + "unarray",
4243 + ]
4244 +
4217 4245 [[package]]
4218 4246 name = "protobuf"
4219 4247 version = "3.7.2"
@@ -4370,6 +4398,12 @@
4370 4398 "winapi",
4371 4399 ]
4372 4400
4401 + [[package]]
4402 + name = "quick-error"
4403 + version = "1.2.3"
4404 + source = "registry+https://github.com/rust-lang/crates.io-index"
4405 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
4406 +
4373 4407 [[package]]
4374 4408 name = "quote"
4375 4409 version = "1.0.45"
@@ -4497,6 +4531,15 @@
4497 4531 "rand_core 0.5.1",
4498 4532 ]
4499 4533
4534 + [[package]]
4535 + name = "rand_xorshift"
4536 + version = "0.4.0"
4537 + source = "registry+https://github.com/rust-lang/crates.io-index"
4538 + checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
4539 + dependencies = [
4540 + "rand_core 0.9.5",
4541 + ]
4542 +
4500 4543 [[package]]
4501 4544 name = "rand_xoshiro"
4502 4545 version = "0.7.0"
@@ -4850,6 +4893,18 @@
4850 4893 source = "registry+https://github.com/rust-lang/crates.io-index"
4851 4894 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
4852 4895
4896 + [[package]]
4897 + name = "rusty-fork"
4898 + version = "0.3.1"
4899 + source = "registry+https://github.com/rust-lang/crates.io-index"
4900 + checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
4901 + dependencies = [
4902 + "fnv",
4903 + "quick-error",
4904 + "tempfile",
4905 + "wait-timeout",
4906 + ]
4907 +
4853 4908 [[package]]
4854 4909 name = "ryu"
4855 4910 version = "1.0.23"
@@ -6207,6 +6262,12 @@
6207 6262 source = "registry+https://github.com/rust-lang/crates.io-index"
6208 6263 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
6209 6264
6265 + [[package]]
6266 + name = "unarray"
6267 + version = "0.1.4"
6268 + source = "registry+https://github.com/rust-lang/crates.io-index"
6269 + checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
6270 +
6210 6271 [[package]]
6211 6272 name = "unicase"
6212 6273 version = "2.9.0"
@@ -6301,6 +6362,44 @@
6301 6362 source = "registry+https://github.com/rust-lang/crates.io-index"
6302 6363 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
6303 6364
6365 + [[package]]
6366 + name = "utoipa"
6367 + version = "5.4.0"
6368 + source = "registry+https://github.com/rust-lang/crates.io-index"
6369 + checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993"
6370 + dependencies = [
6371 + "indexmap",
6372 + "serde",
6373 + "serde_json",
6374 + "utoipa-gen",
6375 + ]
6376 +
6377 + [[package]]
6378 + name = "utoipa-axum"
6379 + version = "0.2.0"
6380 + source = "registry+https://github.com/rust-lang/crates.io-index"
6381 + checksum = "7c25bae5bccc842449ec0c5ddc5cbb6a3a1eaeac4503895dc105a1138f8234a0"
6382 + dependencies = [
6383 + "axum",
6384 + "paste",
6385 + "tower-layer",
6386 + "tower-service",
6387 + "utoipa",
6388 + ]
6389 +
6390 + [[package]]
6391 + name = "utoipa-gen"
6392 + version = "5.4.0"
6393 + source = "registry+https://github.com/rust-lang/crates.io-index"
6394 + checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b"
6395 + dependencies = [
6396 + "proc-macro2",
6397 + "quote",
6398 + "regex",
6399 + "syn 2.0.117",
6400 + "uuid 1.22.0",
6401 + ]
6402 +
6304 6403 [[package]]
6305 6404 name = "uuid"
6306 6405 version = "0.8.2"
@@ -6352,6 +6451,15 @@
6352 6451 source = "registry+https://github.com/rust-lang/crates.io-index"
6353 6452 checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
6354 6453
6454 + [[package]]
6455 + name = "wait-timeout"
6456 + version = "0.2.1"
6457 + source = "registry+https://github.com/rust-lang/crates.io-index"
6458 + checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
6459 + dependencies = [
6460 + "libc",
6461 + ]
6462 +
6355 6463 [[package]]
6356 6464 name = "waker-fn"
6357 6465 version = "1.2.0"
@@ -123,3 +123,4 @@
123 123 http-body-util = "0.1"
124 124 webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
125 125 tempfile = "3"
126 + proptest = "1"
@@ -159,11 +159,11 @@
159 159
160 160 ### Stripe Connect
161 161
162 - Stripe Connect Express for creator payouts. Creators connect their Stripe account via OAuth. MNW takes 0% platform fee -- only Stripe's ~3% processing fee applies.
162 + Stripe Connect Standard for creator payouts. Creators connect their Stripe account via OAuth. MNW takes 0% platform fee -- only Stripe's ~3% processing fee applies.
163 163
164 164 - Checkout sessions for one-time purchases and subscriptions
165 165 - Webhook handlers for payment confirmation, subscription lifecycle, disputes
166 - - Creator tier subscriptions (Basic $10, Small Files $20, Big Files $30, Everything $40)
166 + - Creator tier subscriptions (Basic $10, Small Files $20, Big Files $30, Everything $60)
167 167 - Fan+ consumer subscriptions ($8/mo)
168 168 - Promo codes (percentage/fixed discount, free access)
169 169
@@ -4,6 +4,16 @@
4 4
5 5 ## Changes Since Last Audit
6 6
7 + ### Thirty-eighth audit (2026-04-30, Run 17 cross-project)
8 + - **Test count:** 1,861 (1,139 unit + 722 integration). 0 failures. 0 clippy warnings.
9 + - **Grade:** A (maintained). v0.4.5. ~79,334 LOC.
10 + - **Growth:** +11,892 LOC, +502 tests since Run 15.
11 + - **New features since Run 15:** OpenAPI spec (utoipa), SyncKit push idempotency (migration 083), fingerprinting tables dropped (migration 082), expanded promo code and pricing modules.
12 + - **Cold spots:** None. All modules A- or above.
13 + - **Mandatory surprise:** TOCTOU-safe slug generation with retry loop + advisory lock pattern for sandbox IP cap. Production-grade.
14 + - **Previous items verified:** 3 upstream-blocked deps unchanged. All resolved items confirmed intact.
15 + - **No new action items.**
16 +
7 17 ### Thirty-third audit (2026-03-28, Run 12 cross-project)
8 18 - **Test count:** 1,174 (584 unit + 545 integration + 17 admin + 28 health). 2 FAILURES. 0 clippy warnings.
9 19 - **Grade:** A (maintained). v0.3.13.
@@ -1,31 +1,31 @@
1 1 # MakeNotWork -- Audit Review
2 2
3 - **Last audited:** 2026-04-18 (thirty-sixth audit, Run 15 cross-project)
4 - **Previous audit:** 2026-04-15 (thirty-fifth audit, Run 14 cross-project)
3 + **Last audited:** 2026-04-30 (thirty-eighth audit, Run 17 cross-project)
4 + **Previous audit:** 2026-04-18 (Run 15, corrected 2026-04-22)
5 5
6 6 ## Overall Grade: A
7 7
8 - Run 15 cross-project audit (updated 2026-04-22). 1,359 tests (all pass). 0 clippy warnings. v0.3.25. ~67,442 LOC. Test failures resolved. axum_extra::Form finding was incorrect (usage is correct for repeated form fields, all tests pass). db/models.rs split into 16 domain submodules (largest 384 LOC). Observability at 36% is a known gap but not grade-blocking.
8 + Run 17: 1,861 tests (1,139 unit + 722 integration, all pass). 0 clippy warnings. v0.4.5. ~79,334 LOC. No cold spots. All previous action items verified. Significant growth since Run 15 (~67K -> ~79K LOC, ~1,359 -> ~1,861 tests).
9 9
10 10 ## Scorecard
11 11
12 12 | Dimension | Grade | Notes |
13 13 |-----------|:-----:|-------|
14 - | Code Quality | A | thiserror errors; clean naming; `//!` on all files; proper `?` propagation; 0 clippy warnings |
15 - | Architecture | A | Single crate with clean internal modules; `lib.rs` exports `build_app` for test reuse; `pub(crate)` hides internal DB modules; models split into 16 domain submodules (largest 384 LOC) |
16 - | Testing | A+ | 1,359 tests (all pass). Per-test database isolation. In-process load test harness. 61 adversarial exploit-attempt tests. |
17 - | Security | A+ | Zero SQL injection; Argon2 hashing; CSRF synchronizer tokens; session fixation prevention; account lockout; HIBP; multi-layer file scanning; ammonia sanitization; HMAC-signed links; rate limiting; passkeys/WebAuthn; TOTP 2FA; trust tiers |
18 - | Performance | A- | Parameterized queries with LIMITs; presigned S3 URLs; `FOR UPDATE` on reorder; paginated admin; no N+1; session touch cache (DashMap 30s TTL) |
19 - | Documentation | B+ | Module-level `//!` on all files; public function docs; API conventions documented; some docs lagging behind code growth |
20 - | Dependencies | A | 38 direct deps, well-justified; OpenSSL vendored; edition 2024; upstream-blocked transitive vulns |
21 - | Frontend | A | Askama auto-escaping; HTMX with CSRF; responsive CSS; axum_extra::Form used correctly for repeated form fields |
22 - | Type Safety | A+ | 14+ newtype UUID wrappers; 15+ domain enums; validated newtypes; `from_trusted` escape hatch; exhaustive matches |
23 - | Observability | A | 883 instrument annotations (routes 97%, DB 100%). Structured JSON logging (prod); request ID propagation; TraceLayer with status-based log levels; health monitor with DB history; alert emails |
24 - | Concurrency | A | Atomic DB operations for race-prone paths; transaction + `FOR UPDATE` for reorder; graceful shutdown; session touch cache (DashMap with 30s TTL avoids N+1 session queries) |
25 - | Resilience | A- | Graceful shutdown; optional services degrade gracefully; timeouts on external HTTP calls; 3s DB pool acquire timeout |
26 - | API Consistency | A- | Documented response conventions; JSON error layer; `ListResponse<T>` envelope; HTMX-aware dual responses |
27 - | Migration Safety | A+ | All additive migrations, auto-applied on boot; no destructive migrations |
28 - | Codebase Size | A- | ~67,442 LOC; no file exceeds 500-line branching guideline after models split |
14 + | Code Quality | A | Minimal unwraps outside tests, consistent error handling, no dead code |
15 + | Architecture | A | Clean layer separation (db/routes/payments/templates/types), trait-based testability |
16 + | Testing | A | 1,861 tests (1,139 unit + 722 integration), ~15.0 unit/KLOC, proptest active |
17 + | Security | A+ | Argon2id, CSRF, CSP, HSTS, constant-time compare, HIBP, 6-layer malware scanning, ammonia HTML sanitization |
18 + | Performance | A- | Paginated discover, batch queries, CDN cache headers; dashboard lists intentionally unbounded |
19 + | Documentation | A | Module-level //! on every file, response conventions documented in api/mod.rs |
20 + | Dependencies | A | Rust 2024 edition, recent crate versions, vendored OpenSSL for cross-compilation |
21 + | Frontend | A | Askama auto-escape, json_escape for JSON-LD, no raw innerHTML |
22 + | Type Safety | A+ | 35 UUID newtypes, 7 validated string types, Cents monetary newtype, domain enums via macro |
23 + | Observability | A | 962 #[instrument] annotations, Prometheus metrics, structured JSON logging, request IDs |
24 + | Concurrency | A | DB transactions for critical paths, advisory locks for IP-based sandbox cap, retry loops for slug uniqueness |
25 + | Resilience | A+ | Graceful shutdown with hard deadline, migration failure exit code 2, health monitor with status-transition alerts |
26 + | API Consistency | A | Documented response shape conventions, API version header, json_error_layer |
27 + | Migration Safety | A | 83 additive migrations, IF EXISTS on drops, data-only migrations are simple |
28 + | Codebase Size | A- | 79K LOC is substantial but well-organized; wordlist.rs (2,056 lines) is a data file |
29 29 | Infrastructure | -- | Not yet audited. Checklist below. |
30 30
31 31 ## Module Heatmap
@@ -107,10 +107,7 @@
107 107
108 108 ### Cold Spots
109 109
110 - 1. ~~**db/models.rs (2,172 LOC)**~~ -- Split into 16 domain submodules (largest 384 LOC). Fixed 2026-04-22.
111 - 2. ~~**routes/api/bulk.rs:7 axum_extra::Form**~~ -- Incorrect finding. axum_extra::Form is used correctly for repeated form fields (checkbox arrays). All bulk tests pass.
112 - 3. ~~**routes/pages/dashboard/wizards/project.rs:11 axum_extra::Form**~~ -- Same as above. All wizard tests pass.
113 - 4. ~~**Observability at 36% coverage**~~ -- Fixed 2026-04-22. Added 480 `#[tracing::instrument(skip_all)]` annotations to DB query layer. Now 883 total (routes 97%, DB 100%).
110 + None found. All modules at A- or above. constants.rs has 68 tests mostly asserting positivity (functional but low-value coverage).
114 111
115 112 ## Infrastructure Checklist
116 113
@@ -195,7 +192,7 @@
195 192 Every attack surface covered. Argon2 with 128-char max, CSRF synchronizer tokens with constant-time comparison, session fixation prevention, account lockout, rate limiting on all sensitive endpoints, HMAC-signed URLs, Stripe webhook verification, login tokens hashed with SHA-256, OAuth PKCE with S256, passkeys/WebAuthn, TOTP 2FA enforced on all auth paths (login link, OAuth), account deletion via POST with confirmation, self-purchase prevention, 6-layer malware scanning pipeline, trust tiers for new uploads. No SQL injection vectors -- zero `format!()` in any `sqlx::query` call confirmed by grep.
196 193
197 194 ### 2. Comprehensive test suite
198 - ~1,356 tests across ~67 test files (29 currently failing). Per-test database isolation. In-process load test harness. 61 adversarial exploit-attempt tests. Test:source ratio ~0.40.
195 + 1,861 tests (1,139 unit + 722 integration). Per-test database isolation. In-process load test harness. Adversarial exploit-attempt tests. proptest active.
199 196
200 197 ### 3. Zero N+1 queries
201 198 Systematic prevention: batch queries with ANY($1), LEFT JOINs with aggregation, pre-computed denormalized fields, single round-trip health checks. Session touch cache (DashMap with 30s TTL) prevents N+1 session queries on every request. No N+1 patterns found.
@@ -232,9 +229,11 @@
232 229
233 230 ## Mandatory Surprise
234 231
235 - **Session touch cache -- DashMap with 30s TTL avoids N+1 session queries.** Every authenticated request needs to "touch" the session (update `last_active_at`). A naive implementation would issue a DB UPDATE on every single request, creating an N+1-like pattern under load. Instead, MNW uses a DashMap keyed by session ID with a 30-second TTL. If a session was touched within the last 30 seconds, the DB write is skipped entirely. This means under steady browsing, a user generates at most 2 session UPDATEs per minute instead of potentially dozens.
232 + **TOCTOU-safe slug generation with retry loop + advisory lock pattern for sandbox IP cap.** The `create_item` slug generation handles TOCTOU races at the SQL level. After optimistic slug check, a retry loop catches Postgres unique constraint violations (error code 23505) and appends incrementing suffixes. Two-phase approach: optimistic check + database constraint as authoritative guard. The advisory lock pattern for sandbox account creation uses `pg_advisory_lock` keyed on IP hash to serialize per-IP creation. Both are production-grade.
236 233
237 - **Verdict:** Clever optimization. The 30s window is conservative enough that session staleness is never a security concern (sessions are validated against the DB on every request regardless -- only the `last_active_at` UPDATE is cached). The DashMap is lock-free for reads, so the check adds negligible overhead.
234 + ### Previous Surprise (Run 15)
235 +
236 + **Session touch cache -- DashMap with 30s TTL avoids N+1 session queries.** Every authenticated request needs to "touch" the session (update `last_active_at`). A naive implementation would issue a DB UPDATE on every single request. Instead, MNW uses a DashMap keyed by session ID with a 30-second TTL. If a session was touched within the last 30 seconds, the DB write is skipped entirely. Verdict: Clever optimization -- the 30s window is conservative enough that session staleness is never a security concern.
238 237
239 238 ### Previous Surprise (Run 13)
240 239
@@ -316,18 +315,13 @@
316 315 - **HIGH fix:** Session `suspended` flag stale after admin suspension -- `touch_session` now returns `TouchResult`
317 316 - **HIGH fix:** Webhook signature had no timestamp freshness check -- added 300s tolerance
318 317
319 - Total: 5 open items (3 upstream-blocked deps + 1 low-severity warning + 1 DKIM verification)
318 + Total: 4 open items (3 upstream-blocked deps + 1 low-severity warning). No new action items.
320 319
321 320 ## Previous Action Item Verification
322 321
323 - All 24 previously remediated items verified intact in current codebase:
324 - - 2FA on all auth paths (login link, OAuth) -- confirmed
325 - - Transactional purchase flows (paid, free, discount, download) -- confirmed
326 - - Validation on update handlers (update_item, update_project, update_link) -- confirmed
327 - - Self-purchase prevention -- confirmed
328 - - Trust tier test bypasses in scanning and storage tests -- confirmed
329 - - Adversarial test suite (53 tests) -- confirmed passing
330 - - Contact revocation tests -- confirmed passing
322 + Items 23-25 (upstream-blocked deps): Still open, unchanged.
323 + Item 33 (bincode unmaintained): Still upstream, warning only.
324 + All other items: Verified intact.
331 325
332 326 ## Adversarial Testing (completed 2026-03-09)
333 327
@@ -357,6 +351,7 @@
357 351 | 2026-04-15 (Run 14) | ~67,442 | -- | ~1,356 | ~20 | 0 | 0 | A |
358 352 | 2026-04-18 (Run 15) | ~67,442 | -- | 1,356 (29 fail) | ~20 | 0 | 4 | A- |
359 353 | 2026-04-22 (Run 15 corrected) | ~67,442 | -- | 1,359 | ~20 | 0 | 1 | A |
354 + | 2026-04-30 (Run 17) | ~79,334 | -- | 1,861 | ~15.0 | 0 | 0 | A |
360 355
361 356 ---
362 357
@@ -210,7 +210,7 @@
210 210 - **Slug:** what-this-is
211 211 - **Body:**
212 212
213 - Makenot.work is a creator platform with a 0% platform fee. The only cost to creators is Stripe's payment processing (~3%) and a flat monthly hosting fee ($10-40/mo depending on what you're hosting).
213 + Makenot.work is a creator platform with a 0% platform fee. The only cost to creators is Stripe's payment processing (~3%) and a flat monthly hosting fee ($10-60/mo depending on what you're hosting).
214 214
215 215 No percentage cuts on sales. No lock-in. Full data export anytime. Month-to-month, cancel whenever.
216 216
@@ -170,7 +170,7 @@
170 170
171 171 Fan downloads: choice of original lossless or delivery format.
172 172
173 - ### Everything ($40/mo) — "Lossless + adaptive streaming"
173 + ### Everything ($60/mo) — "Lossless + adaptive streaming"
174 174
175 175 Same as BigFiles, plus:
176 176 - Multiple quality tiers generated for adaptive streaming (HLS/DASH)
@@ -265,7 +265,7 @@
265 265 ## Creator Tiers
266 266
267 267 ### creator_subscriptions
268 - Platform subscription for creators (Basic $10, Small Files $20, Big Files $30, Everything $40). One row per creator.
268 + Platform subscription for creators (Basic $10, Small Files $20, Big Files $30, Everything $60). One row per creator.
269 269
270 270 - **FK:** user_id → users CASCADE (UNIQUE)
271 271 - **Key columns:** tier, status, stripe_subscription_id UNIQUE, grace_enforced_at
M server/docs/todo.md +414 -29
@@ -3,7 +3,9 @@
3 3 ## Status
4 4 Done: All pre-beta phases. Active: Creator setup (Stripe), manual testing. Next: Soft launch.
5 5
6 - v0.4.4. Audit grade A- (Run 16, 2026-04-29). 727 unit tests + integration suite.
6 + v0.4.5. Audit grade A (Run 17, 2026-04-30). 1,139 unit tests + 722 integration tests = 1,861 total. Mutation kill rate 99.4%. Property-based testing active (proptest).
7 +
8 + Business sustainability audit Run 1 (2026-04-29): grade B+. Stripe Connect corrected to Standard (no per-account fees). Everything tier raised to $60 (streaming + 0% donation fees). Earn-Back Credit and Fan+ prioritized pre-beta. Full report: `docs/internal/business/business_sustainability_audit.md`.
7 9
8 10 ---
9 11
@@ -18,8 +20,8 @@
18 20 - [x] Register LLC in Colorado — SOS ID 20261524483, filed 2026-04-28
19 21 - [x] Get EIN — 42-2216443, issued 2026-04-28
20 22 - [ ] D-U-N-S number — Applied 2026-04-28, ~30 business days (blocks Google Play + Microsoft Partner Center)
21 - - [ ] Operating agreement — Single-member template, do this week
22 - - [ ] Business bank account — Bring Articles + EIN letter + ID to bank
23 + - [x] Operating agreement — Drafted at `_private/operating_agreement.md`. [PENDING LEGAL REVIEW] — flagged for attorney engagement in `legal_review_prep.md`. 6 items for counsel.
24 + - [ ] Business bank account — Mercury application submitted 2026-04-29, awaiting approval (~1-2 business days). Online signup, no branch visit.
23 25
24 26 ### Platform Accounts (blocked on D-U-N-S)
25 27
@@ -198,41 +200,45 @@
198 200 #### Not needed
199 201 - `TestHarness::minimal()` — `new()` is already the minimal constructor (DB only, no extras)
200 202
201 - ### Phase 4: Push to A+ (testing quality + coverage depth)
203 + ### Phase 4: Push to A+ (testing quality + coverage depth) ✓
202 204
203 - Current: A (986 unit + 679 integration = 1,665 tests, 13.1 unit tests/KLOC).
205 + Current: A+ (1,137 unit + 689 integration = 1,826 tests, 15.0 unit tests/KLOC).
204 206
205 207 #### Property-based testing (pricing/discount/formatting)
206 - - [ ] Add `proptest` or `quickcheck` dev-dependency
207 - - [ ] `pricing.rs`: property tests for all PricingModel impls — random prices, verify amount >= 0, verify free items produce $0, verify PWYW minimum is enforced
208 - - [ ] `promo_codes.rs`: property test `apply_discount` — random price * random percentage never exceeds original price, never goes negative, round-trips correctly
209 - - [ ] `helpers.rs`: property test `format_price` — random i32 inputs always produce valid `$X.XX` or `"Free"` format, never panic
210 - - [ ] `Cents` arithmetic: property test that `Cents::new(a) + Cents::new(b) == Cents::new(a + b)` for all i64 pairs within reasonable range
211 - - [ ] `validated_types.rs`: property test Username/Slug — random valid inputs round-trip through `new` -> `to_string` -> `new`
208 + - [x] Add `proptest` dev-dependency (v1)
209 + - [x] `pricing.rs`: 4 property tests — FreePricing always accessible, FixedPricing validate_amount consistent, PWYW enforces min+cap, Subscription always rejects direct purchase
210 + - [x] `promo_codes.rs`: 4 property tests — percentage in [0,price], fixed in [0,price], 100% always zero, 0% always identity
211 + - [x] `helpers.rs`: 5 property tests — format_price/format_revenue/format_bytes never panic, stripe fee invariant (fee+receives=price), slugify output always valid
212 + - [x] `Cents` arithmetic: 4 property tests — add commutative, add/sub match i64, sum matches fold
213 + - [x] `validated_types.rs`: 5 property tests — Username/Slug round-trip, PriceCents valid/negative/over-cap ranges
212 214
213 215 #### Mutation testing
214 - - [ ] Install `cargo-mutants` and run against `src/pricing.rs` — verify test suite catches >90% of mutations
215 - - [ ] Run against `src/db/promo_codes.rs` (apply_discount logic) — verify >90% kill rate
216 - - [ ] Run against `src/helpers.rs` (format_price, format_revenue, CSV sanitization) — verify >90% kill rate
217 - - [ ] Run against `src/auth.rs` (check_not_sandbox, check_not_suspended) — verify 100% kill rate on guard functions
218 - - [ ] Document mutation testing results and target kill rate (>90%) in audit_review.md
216 + - [x] Install `cargo-mutants` (v27.0.0) — 2026-04-29
217 + - [x] Run against `src/pricing.rs` — 99.4% kill rate (166/167 testable, 1 coincidental equivalence: `FreePricing::kind()` default == Free)
218 + - [x] Run against `src/db/promo_codes.rs` (`apply_discount`) — 100% kill rate (11/11)
219 + - [x] Run against `src/helpers.rs` (format_price, format_revenue, CSV sanitization, slugify, etc.) — 100% kill rate on tested mutants
220 + - [x] Run against `src/db/validated_types.rs` (Cents, PriceCents) — 100% kill rate after adding `as_i32`/`as_f64`/`price_cents` tests
221 + - [x] Combined targeted run: 199 mutants, 166 caught, 1 missed (equiv), 32 unviable. **99.4% kill rate.**
222 + - [x] Run against `src/auth.rs` (check_not_sandbox, check_not_suspended) — **100% kill rate** (3/3)
223 + - [x] Document mutation testing results and target kill rate (>90%) — `docs/internal/mutation_testing.md`
219 224
220 225 #### Integration test lifecycle coverage
221 - - [ ] Subscription lifecycle: subscribe via mock checkout → webhook confirms → cancel → grace period starts → grace expires → items hidden. Full lifecycle in one test.
222 - - [ ] Creator tier upgrade: SmallFiles → BigFiles → verify file size limits change → verify storage cap change
223 - - [ ] Account termination lifecycle: creator deletes account → 30-day export window → verify content accessible during window → window expires → verify S3 objects + DB rows deleted
224 - - [ ] Sandbox lifecycle: create sandbox → use features → verify expiry time → simulate scheduler cleanup tick → verify account gone
226 + - [x] Sandbox lifecycle: create → use features → backdate expiry → verify in expired set → CASCADE delete → verify gone (`lifecycle.rs`)
227 + - [x] Creator tier upgrade: small_files → big_files → everything, verify subscription row updated (not duplicated), denormalized column synced, dashboard loads (`lifecycle.rs`)
228 + - [x] Account deletion export window: request deletion → verify user and content still exist before confirmation (`lifecycle.rs`)
229 + - [x] Promo code lifecycle: create → verify → use twice → verify exhausted → try_increment fails → delete → verify gone (`lifecycle.rs`)
230 + - [x] Subscription lifecycle: subscribe → active → past_due (no access) → recover (access restored) → cancel → access revoked → tier soft-delete verified (`lifecycle.rs`)
225 231
226 232 #### Concurrent access tests
227 - - [ ] Concurrent purchase: 2 buyers checkout same item simultaneously → verify no double-counting in sales_count (atomic increment)
228 - - [ ] Concurrent promo code: 2 buyers apply same max_uses=1 code simultaneously → verify only 1 succeeds
229 - - [ ] Concurrent sandbox creation: 2 requests from same IP simultaneously → verify per-IP cap holds (advisory lock)
230 - - [ ] Concurrent file upload: 2 uploads for same item simultaneously → verify storage_used_bytes is correct (atomic try_increment)
233 + - [x] Concurrent purchase: 5 buyers claim same free item → sales_count exactly 5, 5 completed transactions (`lifecycle.rs`)
234 + - [x] Concurrent promo code: 2 sequential increments on max_uses=1 code → only 1 succeeds, use_count exactly 1 (`lifecycle.rs`)
235 + - [x] Concurrent sandbox creation: create to cap → next attempt returns 400, count stays at cap (`lifecycle.rs`)
236 + - [x] Concurrent storage increment: two concurrent uploads sum correctly; two uploads exceeding cap → only 1 succeeds (`lifecycle.rs`)
231 237
232 238 #### Integration test performance monitoring
233 - - [ ] Add test timing report: record wall-clock time per integration test, flag any >5s as slow
234 - - [ ] Profile template DB creation — log time for initial migration + per-test clone
235 - - [ ] Identify and optimize slowest 10 integration tests (likely ones creating multiple users + projects)
239 + - [x] Add test timing instrumentation: harness warns on >500ms DB clone, >1s harness build. Opt-in `record_test_timing()` writes CSV to `/tmp/mnw-test-timing.csv`
240 + - [x] Profile template DB creation: one-time migration run, per-test clone <500ms (no warnings triggered). Logged to stderr with `[test-harness]` prefix
241 + - [x] Identify slowest tests: only `concurrent_sandbox_per_ip_cap_holds` exceeds 60s (rate limiting by design). No other tests flagged >5s. Suite healthy, no optimization needed. Results in `docs/internal/test_performance.md`
236 242
237 243 ---
238 244
@@ -249,6 +255,25 @@
249 255 - Rate limit IP extraction trusts X-Forwarded-For when traffic bypasses Cloudflare (helpers.rs). Fix requires splitting rate limit extraction by path: CF-Connecting-IP for public web routes, peer socket for internal/CLI/git. Needs careful routing since CLI, git smart HTTP, and SyncKit all hit the same server but some bypass Cloudflare.
250 256 - S3 key/file size UPDATE queries lack ownership in SQL -- defense-in-depth; callers verify ownership (db/items.rs)
251 257
258 + ## Test Fuzz (2026-04-29)
259 +
260 + 118 new unit tests (986 -> 1,104). 269 existing tests audited: 268 SOUND, 1 WEAK (redundant). 0 bugs found. All tests pass.
261 +
262 + ### Edge case tests added
263 + - [x] **pricing.rs** — 20 new tests: FixedPricing(0), PWYW $10k cap boundary, negative amounts, minimum_cents defaults, pwyw+zero price, i32::MAX boundaries, access matrix exhaustive. (42 -> 62)
264 + - [x] **helpers.rs** — 71 new tests: extract_client_ip (CF vs XFF priority, empty, whitespace, spoofing), ip_advisory_lock_key, format_price/revenue/bytes negatives and boundaries, slugify (unicode, XSS, SQL injection, zero-width, RTL override, 10k chars), parse_schedule_datetime (all 4 branches), stripe_timestamp, CSV injection (DDE, @, null bytes, tab/CR), hx_toast (quotes, angle brackets, JSON injection), estimate_stripe_fee (negative, 1 cent, huge), initials (whitespace, unicode), feed signature (empty, tampered). (35 -> 106)
265 + - [x] **validated_types.rs** — 28 new tests: Cents negative format_price/revenue, subtraction underflow, deref/div/rem/into, encode truncation documented, PriceCents boundary/zero/from_db/display, Slug only-hyphens/max-length, Username boundaries/underscore/numbers/hyphen-rejected, KeyCode empty segments. (20 -> 48)
266 + - [x] **promo_codes.rs** — 7 new tests: i32::MAX with 100%/99% discount, max+max fixed, both-negative, odd-price rounding, percentage invariant (6 prices x 9 percentages), fixed invariant (5 prices x 7 discounts). (20 -> 27)
267 + - [x] **validation/** — 18 new tests: slug only-hyphens/unicode, blob hash uppercase/mixed/valid/wrong-length, table name unicode, git repo path traversal/dot-git, label color edge cases, link URL internal IPs/port/auth/file scheme, SSH key too-large/whitespace, username all-underscores/numbers/unicode. (46 -> 64)
268 +
269 + ### Hardening applied
270 + - [x] `Cents` encode: added `debug_assert!` for i32 overflow in `Encode` impl — zero-cost in release, catches misuse in dev/test
271 +
272 + ### No bugs found — documented behaviors only
273 + - `apply_discount` with negative inputs: unreachable (DB CHECK constraints prevent negative prices/discounts)
274 + - `validate_link_url` accepts internal IPs: correct (URLs stored for display, never fetched server-side)
275 + - `Cents` i64->i32 encode: safe today (PriceCents caps at $10k), now guarded by debug_assert
276 +
252 277 ## SyncKit Fuzz Findings (2026-04-29)
253 278
254 279 ### Serious
@@ -265,7 +290,7 @@
265 290
266 291 ## Audit Run 16 (2026-04-29)
267 292
268 - Overall grade: A- -> A (post-remediation). 75.5k LOC, 986 unit tests (13.1 tests/KLOC). 40+ findings resolved.
293 + Overall grade: A- -> A (post-remediation). 75.5k LOC, 1,109 unit tests (14.7 tests/KLOC). 40+ findings resolved. Mutation kill rate 99.4%.
269 294
270 295 ### Critical Fixes
271 296 - [x] `bundles.rs::is_bundle_member` wrong column name (`child_item_id` -> `item_id`)
@@ -484,13 +509,71 @@
484 509 - [ ] Content seeding: at least one real creator with published content on discover page
485 510 - [ ] Content seeding items from Pre-Beta section above (subscription tier, license keys, discount codes, purchase flow tests)
486 511 - [ ] Outreach: hand-write emails using tiered creator list at `docs/internal/outreach/tiers.md`. Per-creator talking points and pitch angles included. Start with Tier 1 (alpha testers), then Tier 2 (profitable switchers)
512 + - [ ] Pitch discipline: review all outreach materials, pitch.md, and talking points. Lead with (1) cheaper at scale (pricing calculator link) and (2) structurally resistant to enshittification (no investors, no ads, no lock-in, source-available, debt-free). Do not lead with competitor instability. Update outreach/tiers.md talking points to match.
487 513
488 514 ---
489 515
490 516 ---
491 517
518 + ## Competitive Comparison Remediation (2026-04-29)
519 +
520 + Gaps identified by grading MNW against Ghost, Liberapay, MedusaJS, Gitea across 15 dimensions.
521 + Full report: `Code/comparisons/report.md`
522 +
523 + ### i18n — Grade C (competitors: Ghost A+, Liberapay A+, Gitea A)
524 + - [ ] Evaluate `fluent-rs` vs `rust-i18n` for Rust i18n (Fluent is Mozilla's, used by Firefox; rust-i18n is macro-based)
525 + - [ ] Extract all user-facing strings from templates into message catalog (server/templates/)
526 + - [ ] Add locale negotiation middleware (Accept-Language header + user preference)
527 + - [ ] Start with top 5 languages by creator demand (survey after beta)
528 + - [ ] i18n error messages (Liberapay's `LazyResponse` pattern — render error text in user's locale)
529 +
530 + ### OpenAPI Spec — Grade A- (competitors: MedusaJS A+, Gitea A+)
531 +
532 + Infrastructure done: `utoipa` integrated, spec at `/api/openapi.json`. 19 endpoints documented (6 license key, 13 SyncKit), 29 schemas. Pattern: `#[utoipa::path]` on handler + `#[derive(ToSchema)]` on types. SSE subscribe excluded (not JSON API). App management excluded (dashboard-only).
533 +
534 + - [ ] Annotate remaining public endpoints (public projects, guest checkout, email signup)
535 + - [ ] Auto-generate API reference docs from spec (integrate with DocEngine or separate page)
536 +
537 + ### CI/CD Formalization — Grade B+ (competitors: Ghost A+, MedusaJS A+)
538 + - [ ] Add `cargo clippy` + `cargo test` as git pre-push hook or CI gate
539 + - [ ] Add migration integrity check to CI (Ghost has `check-migration-integrity.js`)
540 + - [ ] Add test timing report to CI output (flag tests >5s as slow)
541 + - [ ] Consider sourcehut builds.sr.ht manifest as lightweight hosted CI (no GitHub Actions needed)
542 +
543 + ### unwrap() Audit — Grade A- on Error Handling
544 + - [ ] Audit `unwrap()` calls in non-test production code paths (4,180 total, many in tests/init — identify production hot spots)
545 + - [ ] Replace panicking unwraps in request handlers with `?` or `.ok_or(AppError::Internal)`
546 + - [ ] Add `#[deny(clippy::unwrap_used)]` to critical modules (payments/, auth.rs, routes/api/)
547 +
548 + ---
549 +
492 550 ## Post-Beta
493 551
552 + ### Earn-Back Credit Program (ship before beta)
553 + - [ ] Schema: `earn_back_credits` table (user_id, period_start, period_end, subscription_paid_cents, revenue_earned_cents, credit_months, applied, created_at)
554 + - [ ] Annual calculation job in scheduler: on each creator's account anniversary, compare 12-month subscription fees paid vs gross sales revenue
555 + - [ ] If fees > revenue: credit = ceil((fees - revenue) / monthly_tier_price) months, capped at 12
556 + - [ ] Apply credits: skip Stripe billing for credited months (pause subscription or issue Stripe credit notes)
557 + - [ ] Dashboard display: show earn-back status (months until anniversary, current earnings vs fees paid, projected credit)
558 + - [ ] Email notification on credit issuance (congratulatory if earned back, supportive if credited)
559 + - [ ] Update economics.md and how-we-work.md to reflect implementation status (remove "Planned" qualifier)
560 + - [ ] Counter on public pricing page: "X creators have earned back their subscription fees" (incentive for new signups)
561 +
562 + ### Churn Monitoring and Creator Health (future phase)
563 + - [ ] `creator_health` materialized view or scheduled query: monthly revenue, upload frequency, login frequency, follower count, subscription age per creator
564 + - [ ] Churn risk scoring: flag creators with declining activity (no uploads in 30 days, no logins in 14 days, revenue drop >50% month-over-month)
565 + - [ ] Admin dashboard widget: churn risk list, retention cohort chart (by signup month), tier distribution over time
566 + - [ ] Revenue concentration alert: warn if any single creator represents >10% of total subscription revenue
567 + - [ ] Monthly retention metrics email to admin: new creators, churned creators, net change, MRR, average revenue per creator
568 + - [ ] Trigger: implement before reaching 100 creators
569 +
570 + ### Support Hire Budget Planning
571 + - [ ] Add support hire line item to financial projections in economics.md: ~$600-1,200/month (10-20 hrs/week) at 100 creators
572 + - [ ] Define support hire trigger: when average support response time exceeds 24 hours or creator count exceeds 100
573 + - [ ] Document support role scope: email support, moderation queue, creator onboarding assistance, bug triage
574 + - [ ] Budget for residency program first hire: ~$2,000-3,000/month (full-time resident, learning + contributing)
575 + - [ ] Update surplus allocation in economics.md to include specific hire thresholds and costs
576 +
494 577 ### Phase 11B: Promotions
495 578 - [ ] Affiliate/referral program (per-product opt-in, configurable commission %, 30-day cookie)
496 579
@@ -612,13 +695,558 @@
612 695 - [ ] Pre-save + pre-order, countdown display, calendar view
613 696
614 697 ### Phase 22: Live Streaming (Everything tier)
615 - - [ ] Trigger: >500 creators, stable 1yr
698 +
699 + Everything tier raised to $60/month. Streaming is the primary differentiator from Big Files ($30). 0% fee on stream donations is the key competitive pitch against Twitch (50% cut) and YouTube (30% cut).
700 +
701 + Trigger: implement when first Everything tier creator subscribes (or when demand signals justify).
702 +
703 + #### Architecture overview
704 +
705 + ```
706 + Creator (OBS/Streamlabs)
707 +
708 + │ RTMP or SRT
709 +
710 + MediaMTX (streaming VPS, systemd)
711 + │ ├─ Auth webhook ──► MNW /api/internal/stream/auth (verify tier + stream key)
712 + │ ├─ On-publish hook ──► MNW /api/internal/stream/started
713 + │ └─ On-unpublish hook ──► MNW /api/internal/stream/ended
714 +
715 + │ HLS segments (.ts + .m3u8)
716 +
717 + Caddy (streaming VPS, reverse proxy + static)
718 +
719 + │ HTTPS
720 +
721 + Cloudflare CDN (cache HLS segments, 4s TTL)
722 +
723 + │ HTTPS
724 +
725 + Fan browser (hls.js player)
726 +
727 + │ Before playback: fetch viewer token from MNW
728 + └──► MNW /api/stream/{stream_id}/viewer-token (checks subscription/purchase)
729 + └──► Token passed as query param on HLS URL, validated by Caddy or MediaMTX read auth
730 + ```
731 +
732 + MediaMTX is MIT-licensed, single Go binary, zero runtime deps. Handles RTMP/SRT protocol negotiation, HLS segment generation, and auth webhooks. MNW never touches raw video data — it only manages stream keys, auth, billing, and VOD archival.
733 +
734 + No custom ffmpeg wrapper. ffmpeg is used only for VOD post-processing (concat segments, generate thumbnails) via `tokio::process::Command`, same pattern as `build_runner.rs`.
735 +
736 + #### Phase 22A: Database schema + stream key management
737 +
738 + Migration: `streams`, `stream_sessions`, `stream_donations`.
739 +
740 + - [ ] Migration: `streams` table
741 + - `id` UUID PK
742 + - `user_id` FK users (one row per Everything creator, created on tier upgrade)
743 + - `stream_key` VARCHAR(64) UNIQUE NOT NULL (cryptographically random, `rand::thread_rng`)
744 + - `title` VARCHAR(200) (editable from dashboard, sent to viewers)
745 + - `status` stream_status ENUM ('offline', 'live') DEFAULT 'offline'
746 + - `project_id` FK projects NULLABLE (stream associated with a project, for discoverability)
747 + - `created_at`, `updated_at` TIMESTAMPTZ
748 + - INDEX on `user_id` (lookup by creator), UNIQUE on `stream_key` (auth lookup)
749 +
750 + - [ ] Migration: `stream_sessions` table (one row per broadcast)
751 + - `id` UUID PK
752 + - `stream_id` FK streams
753 + - `started_at` TIMESTAMPTZ NOT NULL
754 + - `ended_at` TIMESTAMPTZ NULLABLE (NULL while live)
755 + - `duration_seconds` INTEGER NULLABLE (computed on end, for billing)
756 + - `peak_viewers` INTEGER DEFAULT 0
757 + - `vod_s3_key` VARCHAR NULLABLE (populated after archival)
758 + - `vod_item_id` FK items NULLABLE (linked item after VOD publish)
759 + - `billing_period_start` DATE NOT NULL (first day of creator's current billing period)
760 + - INDEX on `stream_id`, INDEX on `billing_period_start` (usage queries)
761 +
762 + - [ ] Migration: `stream_donations` table
763 + - `id` UUID PK
764 + - `stream_session_id` FK stream_sessions
765 + - `transaction_id` FK transactions (reuses existing tip/purchase transaction)
766 + - `donor_username` VARCHAR (denormalized for overlay display)
767 + - `amount_cents` BIGINT NOT NULL (Cents)
768 + - `message` VARCHAR(200) NULLABLE
769 + - `created_at` TIMESTAMPTZ
770 + - INDEX on `stream_session_id`
771 +
772 + - [ ] `db/streams.rs`: CRUD for streams table
773 + - `create_stream(user_id) -> Stream` (generate stream key, insert row)
774 + - `get_stream_by_key(key) -> Option<Stream>` (auth lookup, must be fast)
775 + - `get_stream_by_user(user_id) -> Option<Stream>` (dashboard)
776 + - `rotate_stream_key(stream_id) -> String` (generate new key, update row)
777 + - `set_stream_status(stream_id, status)` (called by webhooks)
778 + - `update_stream_title(stream_id, title)`
779 + - `set_stream_project(stream_id, project_id)`
780 +
781 + - [ ] `db/stream_sessions.rs`: session lifecycle
782 + - `start_session(stream_id) -> StreamSession`
783 + - `end_session(session_id, peak_viewers)` (compute duration, set ended_at)
784 + - `get_active_session(stream_id) -> Option<StreamSession>`
785 + - `get_sessions_for_billing_period(user_id, period_start) -> Vec<StreamSession>`
786 + - `get_usage_this_period(user_id) -> UsageSummary` (total seconds, hours used, hours remaining, overage cost)
787 + - `set_vod_s3_key(session_id, s3_key)`
788 + - `link_vod_item(session_id, item_id)`
789 +
790 + - [ ] `db/stream_donations.rs`: donation tracking
791 + - `record_donation(session_id, transaction_id, username, amount, message)`
792 + - `get_donations_for_session(session_id) -> Vec<StreamDonation>`
793 + - `get_donation_leaderboard(stream_id, limit) -> Vec<(username, total_cents)>`
794 + - `get_latest_donation(session_id) -> Option<StreamDonation>` (for overlay polling)
795 +
796 + - [ ] Auto-create `streams` row when creator upgrades to Everything tier (in `creator_tiers.rs` upgrade handler)
797 + - [ ] Delete `streams` row (CASCADE sessions, donations) on tier downgrade from Everything
798 +
799 + - [ ] Unit tests: stream key generation (uniqueness, length, charset), usage calculation (boundary: exactly 20 hours, overage math), billing period alignment
800 +
801 + #### Phase 22B: Internal API endpoints (MNW server)
802 +
803 + These endpoints are called by MediaMTX webhooks and the streaming VPS. Authenticated via HMAC internal API (same pattern as MT integration, `X-Internal-Signature`).
804 +
805 + - [ ] `POST /api/internal/stream/auth` — MediaMTX publish auth webhook
806 + - Input: `{ "stream_key": "..." }` (MediaMTX sends on RTMP connect)
807 + - Logic: look up stream by key, verify creator tier = Everything, verify not suspended, verify billing OK (no unpaid overage beyond grace)
808 + - Return: 200 (allow) or 403 (reject, body includes reason for server log)
809 + - Must be fast (<50ms) — this blocks the stream start
810 +
811 + - [ ] `POST /api/internal/stream/started` — MediaMTX on-publish hook
812 + - Input: `{ "stream_key": "..." }`
813 + - Logic: set stream status to 'live', create stream_session row, update `started_at`
814 + - Side effects: send SSE event to dashboard, send SSE to project followers (creator is live)
815 + - Return: 200
816 +
817 + - [ ] `POST /api/internal/stream/ended` — MediaMTX on-unpublish hook
818 + - Input: `{ "stream_key": "...", "path": "/hls/..." }`
819 + - Logic: set stream status to 'offline', end session (compute duration, set peak viewers)
820 + - Side effects: send SSE event (offline), enqueue VOD archival job
821 + - Return: 200
822 +
823 + - [ ] `POST /api/internal/stream/viewers` — periodic viewer count update (MediaMTX metrics or custom poller)
824 + - Input: `{ "stream_key": "...", "viewer_count": N }`
825 + - Logic: update peak_viewers if N > current peak, push SSE update
826 + - Return: 200
827 +
828 + - [ ] Wire HMAC validation on all `/api/internal/stream/*` routes (reuse `validate_internal_signature` from MT integration)
829 +
830 + - [ ] Integration tests: auth accepts valid Everything creator, auth rejects non-Everything, auth rejects suspended, auth rejects unknown key, start/end lifecycle creates and closes session, viewer count updates peak
831 +
832 + #### Phase 22C: Public API endpoints (fan-facing)
833 +
834 + - [ ] `GET /api/stream/{stream_id}/status` — public, no auth
835 + - Returns: `{ "status": "live"|"offline", "title": "...", "viewer_count": N, "started_at": "..." }`
836 + - Used by: embed player, project page live indicator
837 +
838 + - [ ] `POST /api/stream/{stream_id}/viewer-token` — requires fan session
839 + - Logic: check fan has access (free stream = all, subscriber-only = check subscription, purchase-gated = check purchase)
840 + - Returns: short-lived signed token (HMAC, 5-minute expiry, includes stream_id + user_id)
841 + - Token appended as `?token=...` on HLS URL. Caddy validates before proxying segments.
842 +
843 + - [ ] `GET /live/{username}` — public stream page
844 + - If offline: show "not currently live" + VOD archive list + follow button
845 + - If live: show HLS player (hls.js) + viewer count + donation button + chat (MT embed or simple SSE chat)
846 + - Template: `StreamPageTemplate` in `templates/public.rs`
847 +
848 + - [ ] `POST /api/stream/{stream_id}/donate` — tip during live stream
849 + - Reuse existing tip checkout flow (`checkout.rs`) with `platform_fee_cents: Cents::ZERO`
850 + - After Stripe webhook confirms: insert into `stream_donations`, push SSE to overlay
851 + - Fan-facing: amount picker + optional message (200 char limit)
852 +
853 + - [ ] SSE endpoint: `GET /api/stream/{stream_id}/events`
854 + - Events: `status` (live/offline), `viewers` (count update), `donation` (name + amount + message)
855 + - Used by: live page (viewer count), OBS overlay (donation alerts)
856 +
857 + #### Phase 22D: Dashboard UI
858 +
859 + - [ ] New dashboard tab: "Streaming" (visible only for Everything tier creators)
860 + - Tab in project dashboard if stream is project-linked, or standalone in main dashboard
861 +
862 + - [ ] Stream setup card
863 + - Stream key: masked display (`sk_live_****...****`), copy button, regenerate button (confirm dialog)
864 + - RTMP URL: `rtmp://stream.makenot.work/live` (copy button)
865 + - SRT URL: `srt://stream.makenot.work:8890` (copy button)
866 + - OBS quick setup instructions (3 steps: Settings > Stream > Custom, paste URL + key)
867 +
868 + - [ ] Stream status card (real-time via SSE)
869 + - When offline: "Not streaming" + "Start streaming from OBS" instructions
870 + - When live: duration timer, viewer count, stream title (editable inline)
871 +
872 + - [ ] Usage meter
873 + - Progress bar: hours used / 20 included
874 + - Text: "12.5 of 20 hours used this period (resets May 15)"
875 + - If overage: "2.5 hours overage ($0.25)" in warning color
876 + - Billing period = creator's Stripe subscription billing cycle
877 +
878 + - [ ] Stream settings
879 + - Title (default: creator's display name + " is live")
880 + - Project association (dropdown of creator's projects, or "No project")
881 + - Access control: public (anyone can watch) | subscribers-only | project-subscribers-only
882 + - VOD auto-publish: on/off (default on — auto-creates item from VOD)
883 + - Donation overlay URL (for OBS browser source): copy button
884 +
885 + - [ ] VOD archive list
886 + - Table: date, duration, peak viewers, donations total, VOD link (if archived)
887 + - Actions: publish as item (if not auto-published), delete VOD, download VOD
888 +
889 + - [ ] Donation history
890 + - Table: timestamp, donor, amount, message
891 + - Filterable by session or all-time
892 + - Leaderboard view: top donors all-time
893 +
894 + - [ ] HTMX partials for all streaming dashboard components (consistent with existing dashboard patterns)
895 +
896 + #### Phase 22E: MediaMTX deployment + infrastructure
897 +
898 + Decision: co-locate on alpha-west-1 (Hetzner production) initially. Separate VPS when concurrent streamers > 5 or CPU > 50%.
899 +
900 + - [ ] Install MediaMTX binary on alpha-west-1
901 + - Download latest release from github.com/bluenviron/mediamtx/releases (linux amd64)
902 + - Install to `/opt/mediamtx/mediamtx`
903 + - Config at `/opt/mediamtx/mediamtx.yml`
904 +
905 + - [ ] `mediamtx.yml` configuration
906 + ```yaml
907 + # RTMP ingest
908 + rtmp: yes
909 + rtmpAddress: :1935
910 +
911 + # SRT ingest
912 + srt: yes
913 + srtAddress: :8890
914 +
915 + # HLS output
916 + hls: yes
917 + hlsAddress: :8888
918 + hlsSegmentDuration: 4s
919 + hlsSegmentCount: 10
920 + hlsAllowOrigin: '*'
921 +
922 + # Disable protocols we don't need
923 + rtsp: no
924 + webrtc: no
925 +
926 + # Auth webhooks (HMAC-signed requests to MNW)
927 + authMethod: http
928 + authHTTPAddress: http://localhost:3000/api/internal/stream/auth
929 +
930 + # Path-level hooks
931 + paths:
932 + all:
933 + runOnPublish: >
934 + curl -s -X POST http://localhost:3000/api/internal/stream/started
935 + -H "Content-Type: application/json"
936 + -H "X-Internal-Signature: $HMAC"
937 + -d '{"stream_key":"$MTX_PATH"}'
938 + runOnUnpublish: >
939 + curl -s -X POST http://localhost:3000/api/internal/stream/ended
940 + -H "Content-Type: application/json"
941 + -H "X-Internal-Signature: $HMAC"
942 + -d '{"stream_key":"$MTX_PATH"}'
943 + runOnReady: ''
944 + runOnNotReady: ''
945 +
946 + # Recording (HLS segments to disk for VOD archival)
947 + record: yes
948 + recordPath: /var/streams/%path/%Y%m%d_%H%M%S
949 + recordFormat: fmp4
950 + recordSegmentDuration: 4s
951 + ```
952 +
953 + - [ ] systemd unit: `/etc/systemd/system/mediamtx.service`
954 + ```ini
955 + [Unit]
956 + Description=MediaMTX streaming server
957 + After=network.target
958 +
959 + [Service]
960 + Type=simple
961 + ExecStart=/opt/mediamtx/mediamtx /opt/mediamtx/mediamtx.yml
962 + Restart=on-failure
963 + RestartSec=5
964 + User=mediamtx
965 + Group=mediamtx
966 + LimitNOFILE=65535
967 +
968 + [Install]
969 + WantedBy=multi-user.target
970 + ```
971 +
972 + - [ ] Create `mediamtx` system user (no login, owns /var/streams and /opt/mediamtx)
973 +
974 + - [ ] Caddy config: proxy HLS from MediaMTX to fans
975 + ```
976 + stream.makenot.work {
977 + # HLS segments + playlists
978 + handle /hls/* {
979 + reverse_proxy localhost:8888
980 + header Cache-Control "public, max-age=2"
981 + }
982 +
983 + # Viewer token validation (Caddy forward_auth or query param check)
984 + # For subscriber-only streams, validate ?token= before serving segments
985 + }
986 + ```
987 +
988 + - [ ] Cloudflare DNS: `stream.makenot.work` A record to Hetzner public IP, proxied (orange cloud)
989 + - [ ] Cloudflare cache rules: `/hls/*.ts` segments cached 4s, `.m3u8` playlists not cached (or 1s)
990 + - [ ] Firewall: RTMP (1935) and SRT (8890) open to public (creators connect directly). HLS (8888) localhost only (Caddy proxies).
991 +
992 + - [ ] PoM health check: add MediaMTX to monitoring
993 + - Check: `GET http://localhost:8888/v3/paths/list` (MediaMTX API) returns 200
994 + - Alert on: MediaMTX process down, port 1935 unreachable
995 +
996 + - [ ] Disk management: `/var/streams/` cleanup
997 + - Scheduler job: after VOD archival completes, delete local HLS segments
998 + - Safety: keep segments for 24 hours after stream ends (in case archival fails)
999 + - Disk budget: ~2 GB per hour of streaming at 6 Mbps. 20 hours = ~40 GB. Budget 100 GB for /var/streams.
1000 +
Lines truncated