| 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 |
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 |
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 |
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 |
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 |
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 |
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 |
+ |
|