max / makenotwork
26 files changed,
+0 insertions,
-5893 deletions
| @@ -1,132 +0,0 @@ | |||
| 1 | - | # Cross-Project Codebase Audit | |
| 2 | - | ||
| 3 | - | Run after major features, monthly during active development, or when concerned about cross-project consistency. Findings recorded in each project's `docs/<short-name>/audit_review.md`. Action items filed in each project's `todo.md`. | |
| 4 | - | ||
| 5 | - | ## Audit Scope | |
| 6 | - | ||
| 7 | - | | Harness ID | Project | Path | Stack | | |
| 8 | - | |------------|---------|------|-------| | |
| 9 | - | | `mnw` | MakeNotWork | MNW/server/ | Rust/Axum/PostgreSQL, Askama, HTMX | | |
| 10 | - | | `synckit` | SyncKit Client SDK | MNW/shared/synckit-client/ | Rust, reqwest, ChaCha20-Poly1305, Argon2, keychain | | |
| 11 | - | | `pom` | PoM | MNW/pom/ | Rust/Axum/SQLite, MCP (rmcp), reqwest | | |
| 12 | - | | `af` | audiofiles | Apps/audiofiles/ | Rust, eframe, egui, cpal, rusqlite | | |
| 13 | - | | `bb` | Balanced Breakfast | Apps/balanced_breakfast/ | Rust/Tauri 2, SQLite, Rhai plugins, vanilla JS | | |
| 14 | - | | `go` | GoingsOn | Apps/goingson/ | Rust/Tauri 2, SQLite, vanilla JS, IMAP/SMTP | | |
| 15 | - | | `mt` | Multithreaded | MNW/multithreaded/ | Rust/Axum/PostgreSQL, Askama, HTMX, MNW OAuth | | |
| 16 | - | | `tagtree` | TagTree | MNW/shared/tagtree/ | Rust library crate, no_std compatible, serde, criterion benchmarks | | |
| 17 | - | ||
| 18 | - | --- | |
| 19 | - | ||
| 20 | - | ## Scoring Dimensions | |
| 21 | - | ||
| 22 | - | | Dimension | What to Check | | |
| 23 | - | |-----------|---------------| | |
| 24 | - | | Code Quality | Error handling, naming, dead code, unwrap usage | | |
| 25 | - | | Architecture | Crate boundaries, layer violations, dependency graph | | |
| 26 | - | | Testing | Test count, coverage gaps, compilation | | |
| 27 | - | | Security | SQL injection, XSS, credential storage, input validation | | |
| 28 | - | | Performance | N+1 queries, indexes, pagination, caching | | |
| 29 | - | | Docs | Module-level docs, README, setup guides | | |
| 30 | - | | Dependencies | Latest stable versions required, unused deps, audit status | | |
| 31 | - | | Frontend | Namespace compliance, state management, escaping | | |
| 32 | - | | Type Safety | Domain enums vs strings, newtypes for IDs/slugs, exhaustive matching | | |
| 33 | - | | Observability | Structured logging, `#[instrument]` coverage, outbound call tracing | | |
| 34 | - | | Concurrency | TOCTOU races, atomic DB ops, unique constraints, lock ordering, ON CONFLICT | | |
| 35 | - | | Resilience | Graceful shutdown, HTTP timeouts, health checks, fire-and-forget error handling | | |
| 36 | - | | API Consistency | Response shape uniformity, pagination, error formats, HTMX/IPC patterns | | |
| 37 | - | | Migration Safety | Additive vs destructive, IF NOT EXISTS guards, concurrent index safety | | |
| 38 | - | | Codebase Size | LOC efficiency, duplicates (5+ identical lines), pattern duplicates, magic values (3+ repeats), unnecessary abstractions | | |
| 39 | - | ||
| 40 | - | --- | |
| 41 | - | ||
| 42 | - | ## Module-Level Assessment | |
| 43 | - | ||
| 44 | - | Use `get_project(id)` from the codebase harness to get the full module inventory for each project — all source files with extracted types, exports, and line counts. This replaces manual filesystem scanning. | |
| 45 | - | ||
| 46 | - | Grade each dimension **per module** (crate, directory module, or route group with ~100+ lines), then roll up to project-level. Weight by risk and size — an untested 2,000-line file drags Testing down more than an untested 50-line wrapper. | |
| 47 | - | ||
| 48 | - | **Cold spots:** any module graded B or below, or 2+ letter grades below the project median. Include a Module Heatmap in the audit review. | |
| 49 | - | ||
| 50 | - | --- | |
| 51 | - | ||
| 52 | - | ## Cross-Project Checklist | |
| 53 | - | ||
| 54 | - | ### Shared Standards | |
| 55 | - | - [ ] Parameterized SQL everywhere (zero injection risk) | |
| 56 | - | - [ ] Typed error handling (`thiserror`) in every Rust project | |
| 57 | - | - [ ] Clean crate boundaries with acyclic dependencies | |
| 58 | - | - [ ] Module-level documentation (`//!`) on major files | |
| 59 | - | - [ ] `cargo check` and `cargo test` pass in every project | |
| 60 | - | - [ ] All dependencies at latest stable versions | |
| 61 | - | ||
| 62 | - | ### Git Hygiene (grade cap: B+ if any fails) | |
| 63 | - | - [ ] Every project has `srht` + `mnw` remotes, `git status` clean, no unpushed commits | |
| 64 | - | - [ ] `.gitignore` covers `/target/`, `.env`, `.DS_Store`, IDE dirs | |
| 65 | - | - [ ] No secrets, database files, or large binaries tracked | |
| 66 | - | ||
| 67 | - | ### Documentation Consistency | |
| 68 | - | - [ ] `startup_check` shows no stale docs (all hashes current) | |
| 69 | - | - [ ] File paths in docs still exist | |
| 70 | - | - [ ] Code references (structs, endpoints, env vars) match codebase | |
| 71 | - | - [ ] Feature/pricing descriptions match across CLAUDE.md, public docs, and code | |
| 72 | - | ||
| 73 | - | ### Known Acceptable Exceptions | |
| 74 | - | - MNW CSP uses `'unsafe-inline'` for scripts — required by Stripe JS SDK | |
| 75 | - | ||
| 76 | - | --- | |
| 77 | - | ||
| 78 | - | ## Process Rules | |
| 79 | - | ||
| 80 | - | 1. **Isolation** — Do NOT read previous `audit_review.md` during the audit. Only read it after all findings are complete, when writing the update. | |
| 81 | - | 2. **Previous action item verification** — Before generating new findings, check whether the previous audit's action items were fixed. Report each as Fixed/Unfixed/Regressed. Unfixed across two consecutive audits = chronic. | |
| 82 | - | 3. **Mandatory surprise** — Identify one thing that seems wrong, unusual, or unexpectedly good. Forces actual code reading over pattern-matching. | |
| 83 | - | ||
| 84 | - | --- | |
| 85 | - | ||
| 86 | - | ## Execution Order | |
| 87 | - | ||
| 88 | - | 0. **Harness startup** — call `startup_check` to get doc freshness report, version mismatches, and project overview. Call `list_projects` for current versions and test counts. Flag any stale docs before beginning. | |
| 89 | - | 1. **Core audit** — for each project, call `get_project(id)` to get its module map, then grade per module. Module heatmaps, scorecards, cold spots (parallelizable across projects). | |
| 90 | - | 2. **Write/update** `audit_review.md` per project (reads previous review only now) | |
| 91 | - | 3. **File action items** in each project's `todo.md` (critical = current phase, architectural = deferred) | |
| 92 | - | 4. **Update** `audit_history.md` with run summary | |
| 93 | - | 5. **Harness update** — for each audited project: `update_test_count(id, count)` with latest test counts. `mark_verified(path)` on all docs that were reviewed. If versions changed, `record_deploy(id, version)`. Repopulate if module structure changed significantly: `cd _meta/harness/server && node dist/populate.js` | |
| 94 | - | ||
| 95 | - | --- | |
| 96 | - | ||
| 97 | - | ## Codebase Reduction | |
| 98 | - | ||
| 99 | - | When Codebase Size findings warrant extraction or deduplication: | |
| 100 | - | ||
| 101 | - | 1. **Audit first** — scan and rank all findings before making changes. Present for approval. | |
| 102 | - | 2. **Smallest change** — eliminate duplication without refactoring surrounding code. Preserve existing signatures. | |
| 103 | - | 3. **Pattern extraction** — after duplicates are resolved, look for deeper systemic patterns. Show before/after for one example before applying broadly. | |
| 104 | - | 4. **Verify** — no dead references, no behavior changes, net line count is measurably lower. | |
| 105 | - | ||
| 106 | - | --- | |
| 107 | - | ||
| 108 | - | ## Grading Scale | |
| 109 | - | ||
| 110 | - | | Grade | Meaning | | |
| 111 | - | |-------|---------| | |
| 112 | - | | A+ | Elegant, efficient, architecturally optimal. Rare. | | |
| 113 | - | | A | Clean, well-tested, correct. Production-ready. | | |
| 114 | - | | A- | Minor issues only, no structural problems | | |
| 115 | - | | B+ | Solid foundations, some gaps | | |
| 116 | - | | B | Works but has notable areas for improvement | | |
| 117 | - | | B- | Functional but needs focused attention | | |
| 118 | - | | C+ or below | Significant issues that should block launch | | |
| 119 | - | ||
| 120 | - | --- | |
| 121 | - | ||
| 122 | - | ## Review Template | |
| 123 | - | ||
| 124 | - | Each project's `docs/<short-name>/audit_review.md` should contain: overall grade, scorecard (dimensions table), module heatmap with cold spots, strengths, weaknesses, action items (reference `todo.md`, don't duplicate), metrics over time (LOC, tests, tests/KLOC, cold spots), and changes since last audit. | |
| 125 | - | ||
| 126 | - | --- | |
| 127 | - | ||
| 128 | - | ## Post-Session Cleanup | |
| 129 | - | ||
| 130 | - | ```bash | |
| 131 | - | find /Users/max/Code -name "target" -type d -maxdepth 4 -exec rm -rf {} + | |
| 132 | - | ``` |
| @@ -1,329 +0,0 @@ | |||
| 1 | - | # MNW Server -- Audit History | |
| 2 | - | ||
| 3 | - | Full chronological audit log. See [audit_review.md](./audit_review.md) for current state. | |
| 4 | - | ||
| 5 | - | ## Changes Since Last Audit | |
| 6 | - | ||
| 7 | - | ### Thirty-ninth audit (2026-05-01, Run 18 MNW server) | |
| 8 | - | - **Test count:** 1,933 (1,209 unit + 724 integration). 34 integration failures (uncommitted moderation/promo code changes). 0 clippy warnings. | |
| 9 | - | - **Grade:** A (maintained). v0.4.6. ~80,470 LOC. | |
| 10 | - | - **Growth:** +1,136 LOC, +72 tests since Run 17. | |
| 11 | - | - **New features since Run 17:** Moderation actions table (migration 085), sync log compaction cursor (migration 084), admin moderation UI, promo code enhancements, content scanning improvements. | |
| 12 | - | - **Cold spots:** 5 found — moderation.rs type safety (B), git/raw.rs unwraps (B), analytics.rs duplication (B+), wam_client.rs testing (B), admin CSRF (B). | |
| 13 | - | - **Bug found:** Sandbox `creator_tier` mismatch — `'SmallFiles'` in SQL vs `'small_files'` expected by `impl_str_enum!`. Sandbox users silently lose tier privileges. | |
| 14 | - | - **Mandatory surprise:** Sandbox tier bug (above). | |
| 15 | - | - **Architecture findings:** 4 instances of inline SQL in route handlers (should be in db/ layer). | |
| 16 | - | - **New action items:** 8 (1 high, 4 medium, 3 low) + 3 deferred. | |
| 17 | - | - **Previous items verified:** 4 upstream-blocked deps unchanged. All resolved items confirmed intact. | |
| 18 | - | ||
| 19 | - | ### Thirty-eighth audit (2026-04-30, Run 17 cross-project) | |
| 20 | - | - **Test count:** 1,861 (1,139 unit + 722 integration). 0 failures. 0 clippy warnings. | |
| 21 | - | - **Grade:** A (maintained). v0.4.5. ~79,334 LOC. | |
| 22 | - | - **Growth:** +11,892 LOC, +502 tests since Run 15. | |
| 23 | - | - **New features since Run 15:** OpenAPI spec (utoipa), SyncKit push idempotency (migration 083), fingerprinting tables dropped (migration 082), expanded promo code and pricing modules. | |
| 24 | - | - **Cold spots:** None. All modules A- or above. | |
| 25 | - | - **Mandatory surprise:** TOCTOU-safe slug generation with retry loop + advisory lock pattern for sandbox IP cap. Production-grade. | |
| 26 | - | - **Previous items verified:** 3 upstream-blocked deps unchanged. All resolved items confirmed intact. | |
| 27 | - | - **No new action items.** | |
| 28 | - | ||
| 29 | - | ### Thirty-third audit (2026-03-28, Run 12 cross-project) | |
| 30 | - | - **Test count:** 1,174 (584 unit + 545 integration + 17 admin + 28 health). 2 FAILURES. 0 clippy warnings. | |
| 31 | - | - **Grade:** A (maintained). v0.3.13. | |
| 32 | - | - **Migrations:** 045 -> 048 (+3: bundles, batch uploads, project features). | |
| 33 | - | - **Test failures (2):** | |
| 34 | - | - `workflows::htmx::delete_item_returns_toast` — panics at htmx.rs:345: HX-Trigger header missing on item delete response. Filed as MEDIUM action item. | |
| 35 | - | - `workflows::wizards::item_wizard_license_keys` — panics at wizards.rs:579: RowNotFound after license key wizard step. Filed as MEDIUM action item. | |
| 36 | - | - **Bundles + batch upload:** New ProjectFeature trait for feature-gated functionality. Bundle items (multiple files per item). Batch file upload support. Well-implemented type safety with A+ grade on new types. | |
| 37 | - | - **Email-first issue tracker:** (shipped since Run 11) Web write UI removed, labels removed. Issues via inbound email, replies via HMAC-signed Reply-To. Commit-message reopens added. Migration 045. | |
| 38 | - | - **I5 (Git Patch Inbound):** (shipped since Run 11) `postmark_inbound` handler, `{slug}@patches.makenot.work`, MT patches category, message-ID threading. 9 integration tests. | |
| 39 | - | - **Dependency advisory:** bincode unmaintained (RUSTSEC-2025-0141, warning only) — upstream via syntect/yara-x. | |
| 40 | - | - **Mandatory surprise:** The `ProjectFeature` trait is a clean, minimal abstraction for feature-gating. Each feature is a zero-sized type implementing the trait, with `is_enabled()` checking feature flags on the project. No feature flag infrastructure bloat — just a trait and an enum. Verdict: **Well-designed.** | |
| 41 | - | - **Previous items verified:** All 30 resolved items confirmed intact. 3 upstream-blocked deps unchanged. | |
| 42 | - | ||
| 43 | - | ### Previously unaudited (2026-03-25, email-first issue tracker + I5 git patch inbound) | |
| 44 | - | - **Test count:** 1,060 (517 unit + 544 integration + 28 health + 4 load). 0 failures. | |
| 45 | - | - **Migrations:** 043 -> 045 (+2: patch_message_ids, issue_message_ids). | |
| 46 | - | - **Email-first issue tracker:** Web write UI completely removed (create, edit, close/reopen, comment forms, labels). Issues created exclusively via inbound email (`{owner}+{repo}@issues.makenot.work`). Comments via email reply (HMAC-signed Reply-To addresses). Issue labels removed entirely. Close/reopen via commit messages only (`fixes #N`, `closes #N`, `reopens #N`). Notification emails include Reply-To, Message-ID, In-Reply-To headers for proper threading. `issue_message_ids` table for email threading (parallels `patch_message_ids`). | |
| 47 | - | - **I5 (Git Patch Inbound):** Verified code-complete. `postmark_inbound` handler processes `git send-email` patches to `{slug}@patches.makenot.work`, creates MT threads in `patches` category (auto-created on demand), multi-part series threaded via Message-ID/In-Reply-To/References. `db/patches.rs` + migration 044. 9 integration tests. Operational: DNS MX for `patches.makenot.work` + Postmark inbound domain config needed at deploy time. | |
| 48 | - | - **Dead code cleanup:** Removed 7 unused issue label DB functions, 2 unused comment functions (`delete_comment`, `get_comment`), `labels.rs` route module, 3 template structs, 3 HTML template files. | |
| 49 | - | - **New code:** `postmark_inbound_issues` handler (new issue + reply paths), `extract_issue_address`, `extract_reply_local`, `strip_quoted_text` helpers, `Reopen` variant in push_refs, 4 new DB functions (`get_issue_by_id`, `get_issue_participants`, `insert_issue_message_id`, `get_issue_id_by_any_message_id`), `send_email_with_headers_and_unsub` on EmailClient. 15 unit tests + 23 integration tests. | |
| 50 | - | - **CSRF fix:** `/postmark/webhook` broadened to `/postmark/` in exempt paths — fixes 3 previously-failing patches tests (403 on inbound webhook). | |
| 51 | - | ||
| 52 | - | ### Thirty-second audit (2026-03-22, Run 11 MNW-focused) | |
| 53 | - | - **Test count:** 1,013 (465 unit + 516 integration + 28 health + 4 load). 0 clippy warnings. Zero dead code. | |
| 54 | - | - **Grade:** A (maintained). v0.3.6. 153 source files. | |
| 55 | - | - **Migrations:** 038 -> 041 (+3: mailing_lists, backfill_mailing_list_subscribers, content_newsletter). | |
| 56 | - | - **Platform integration I3 (Mailing Lists):** New `db/mailing_lists.rs` with CRUD for per-project mailing lists + subscriber management. `mailing_lists` + `mailing_list_subscribers` tables. Auto-create content + devlog lists on project creation. Subscribe on follow/purchase, unsubscribe on unfollow. 6 integration tests. | |
| 57 | - | - **Platform integration I4 (Content Newsletter):** Refactored email delivery from follows-based to mailing-list-based. `send_release_announcements()` now queries content mailing list subscribers instead of direct followers. New `send_blog_post_announcements()` for blog post emails (previously blog posts only created MT threads, never sent emails). `web_only` toggle on items and blog posts to publish without emailing. `mark_blog_post_announced()` idempotent guard. Blog post announcement email template. 4 new integration tests. | |
| 58 | - | - **API polish:** `web_only` field added to `ItemResponse`, `BlogPostResponse`, and `BlogPostEditResponse`. | |
| 59 | - | - **Dependency fixes:** aws-lc-sys 0.38.0->0.39.0 (RUSTSEC-2026-0044 + 0048 resolved), rustls-webpki 0.103.9->0.103.10 (RUSTSEC-2026-0049 resolved for v0.103 chain). | |
| 60 | - | - **Mandatory surprise:** Mailing list delivery has zero duplication with follows-based delivery. Near-identical scheduler functions kept separate — correct per abstraction guidelines. Verdict: **Actually fine.** | |
| 61 | - | - **N+1 fix (admin.rs):** MT backfill handler was calling `get_user_by_id()` per project in a loop. Replaced with batch `get_users_by_ids()` (single `ANY($1)` query) + HashMap lookup. | |
| 62 | - | - **N+1 fix (scheduler.rs):** Onboarding drip was calling `advance_onboarding_step()` per skippable user. Replaced with `batch_advance_onboarding_step()` (single `ANY($1)` UPDATE) for skip sets, individual advance only for users that need emails. | |
| 63 | - | - **No new findings.** All previous items remain resolved. | |
| 64 | - | ||
| 65 | - | ### Thirty-first audit (2026-03-22, Run 10 cross-project) | |
| 66 | - | - **Test count:** 996 (was 1,000 — net -4 from old join/modal tests removed, +7 join wizard, +10 creation wizard offsets). 0 clippy warnings. Zero dead code. | |
| 67 | - | - **Grade:** A (maintained). v0.3.5. 147 source files (was 142). | |
| 68 | - | - **Migrations:** 035 -> 038 (+3: creator tiers, fan_plus, tag paths). | |
| 69 | - | - **Phase 25 (Creation Wizards):** Multi-step HTMX wizards for project (5 steps) and item (6 steps) creation. New route module `routes/pages/dashboard/wizards/` (mod.rs, project.rs, item.rs). 15 templates. `wizard.css` + `wizard.js` infrastructure. Old modal forms removed. 10 integration tests. | |
| 70 | - | - **Phase 26 (Join Wizard):** HTMX multi-step signup replacing client-side JS wizard. New `routes/pages/public/join_wizard.rs`. 5 steps: account (creates user + logs in), profile, creator pitch, stripe, welcome. Optional steps skippable. 7 integration tests. Old `join_handler` removed from `auth.rs`. | |
| 71 | - | - **TagTree integration:** Migration 038 adds `path TEXT NOT NULL` to tags with recursive CTE backfill. `get_tag_ancestors()` rewritten from N+1 parent chain walk to 2-query path-based lookup. `validate_tag_slug()` uses tagtree (TagConfig: max_depth 5, max_length 100). | |
| 72 | - | - **DocEngine extraction:** Documentation rendering extracted to standalone crate. Source LOC reduced from ~50K to ~29K. | |
| 73 | - | - **Rust patterns audit:** Reduced triple clone of `tc.category` in discover filters via `is_some_and()` + move. | |
| 74 | - | - **Dead code:** Zero clippy `dead_code`/`unused` warnings across all targets. Verified with `cargo clippy --all-targets`. | |
| 75 | - | - **Mandatory surprise:** The join wizard's `step_account_create` handler reuses 100% of the existing auth infrastructure (hash_password, login_user, track_session, check_password_breach, email verification) without any duplication. The entire signup flow was factored into a new file by calling existing functions — zero copy-paste, zero new auth logic. Verdict: **Impressive.** | |
| 76 | - | - **No new findings.** All previous items remain resolved. | |
| 77 | - | ||
| 78 | - | ### Thirtieth audit (2026-03-18, Run 9 cross-project) | |
| 79 | - | - **Test count:** 1,000 (unchanged). 0 clippy warnings. | |
| 80 | - | - **Grade:** A (maintained). v0.3.2. | |
| 81 | - | - **Key change:** Sentry removed — tracing-only observability. Files changed: Cargo.toml (sentry dep removed), sentry_layer.rs (deleted), main.rs (Sentry init + tracing layer removed), lib.rs (sentry_layer middleware removed), error.rs (Sentry scope tagging removed), config.rs (sentry_dsn field removed), health endpoint (sentry status removed), test harness (sentry_dsn: None removed from 4 test configs). | |
| 82 | - | - **Observability compensation:** 301 `#[instrument(skip_all)]` annotations. TraceLayer with request ID propagation and status-based log levels. Structured JSON logging in production. Health monitor with DB snapshots + alert emails. | |
| 83 | - | - **Mandatory surprise:** The Sentry removal is surgically clean — zero stale references remain in source (only 1 harmless comment). Tracing setup is more sophisticated than typical Sentry usage: request-scoped tracing, task-level instrumentation, health snapshots persisted to DB, and alert escalation with cooldown. | |
| 84 | - | - **No new findings.** All previous items remain resolved. | |
| 85 | - | ||
| 86 | - | ### Twenty-ninth audit (2026-03-17, Run 8 cross-project) | |
| 87 | - | - **Test count:** 968 -> 1,000 (+32 integration tests). Milestone: four-digit test count. | |
| 88 | - | - **Grade:** A (maintained). v0.3.0. | |
| 89 | - | - **Clippy:** 0 warnings (was 7 — all resolved: collapsible_if, too_many_arguments, explicit_counter_loop). | |
| 90 | - | - **New finding:** ~~LOW: ILIKE wildcard characters (`%`, `_`) not escaped in user search input across db/issues.rs, db/discover.rs, db/categories.rs, db/tags.rs.~~ Resolved: SQL-side `replace()` chain escapes `\`, `%`, `_` in all 20 ILIKE clauses; Rust-side escaping in issues.rs `format!` pattern. | |
| 91 | - | - **Mandatory surprise:** 1,000 tests with zero production unwraps (265 unwrap/expect, all `#[cfg(test)]` only) — Impressive discipline at 50K LOC. | |
| 92 | - | - **Previous items verified:** All resolved. No carried items from Run 7. | |
| 93 | - | ||
| 94 | - | ### Twenty-seventh audit (2026-03-16, Run 6 cross-project) | |
| 95 | - | - **Test count:** 832 -> 968 (+136 tests from G6 implementation) | |
| 96 | - | - **Grade:** A (maintained). G6 features (issue email notifications + commit message references) fully integrated. | |
| 97 | - | - **Source LOC:** 49,940 (up from ~40K), 143 files (up from 186 — likely different counting method) | |
| 98 | - | - **Clippy:** 7 warnings (4 collapsible_if, 2 too_many_arguments on new email methods, 1 explicit_counter_loop). Previously 2. | |
| 99 | - | - **New findings:** LOW: parse_issue_refs regex recompilation per call (should use LazyLock, same pattern already fixed in docs.rs) | |
| 100 | - | - **Mandatory surprise:** parse_issue_refs regex — LOW, functional but inconsistent with existing LazyLock usage elsewhere | |
| 101 | - | - **Previous items verified:** All previous remediated items confirmed intact. 2 upstream-blocked deps unchanged. | |
| 102 | - | ||
| 103 | - | ### Twenty-sixth audit (2026-03-13, pre-launch skeptical lens) | |
| 104 | - | - **Test count:** 821 -> 824 (+3 tests) | |
| 105 | - | - **Grade:** A (maintained). Pre-launch skeptical audit found 2 medium + 1 low issue, all bounded in impact. | |
| 106 | - | - **New findings:** Rate limiting gap on /forgot-password, refund flow not transactional, v2 webhook non-constant-time comparison. | |
| 107 | - | - **Positive surprise:** Password reset token auto-invalidation via embedded password hash — confirms excellent security engineering. | |
| 108 | - | - **Previous items verified:** All 27 prior remediated items confirmed intact. email.rs split, DMARC upgrade, Postmark DKIM all done. | |
| 109 | - | ||
| 110 | - | **Post-audit remediation (2026-03-13):** | |
| 111 | - | - All 3 findings from twenty-sixth audit resolved: forgot-password rate limiting, refund transaction wrapping, constant-time webhook comparison | |
| 112 | - | - Additionally: from_trusted replaced with validated constructor in login/signup, LazyLock for docs.rs regex | |
| 113 | - | - Documentation upgraded to A: SyncKit route handlers documented (15 handlers), SyncKit model field docs added (32 fields), payments.rs and auth.rs module docs expanded, architecture not applicable (MNW docs already existed), README created. | |
| 114 | - | ||
| 115 | - | ### Twenty-fifth audit (2026-03-11, full re-audit) | |
| 116 | - | - **Growth:** 55,223 -> 57,172 total LOC (+3.5%), 765 -> 821 tests (+56), 187 -> 186 files | |
| 117 | - | - **Source LOC:** 40,873 (src/), 16,299 (tests/) | |
| 118 | - | - **Test density:** 14.4 tests/KLOC (up from 13.9) | |
| 119 | - | - **Clippy:** 2 cosmetic warnings (single_match in mnw-admin.rs and lib.rs), down from 0 -- new code introduced minor style nit | |
| 120 | - | - **Scheduler:** New `scheduler.rs` module (180 LOC) for scheduled publish and onboarding drip emails | |
| 121 | - | - **Migrations:** 24 (up from 21) | |
| 122 | - | - **Instrumentation:** 0 `#[instrument]` annotations; 199 tracing log calls across 36 files (manual structured tracing compensates for lack of #[instrument]) | |
| 123 | - | - **Production unwrap hygiene:** ~30 non-test unwrap/expect calls, all justified (startup, infallible HMAC, static regex, static redirect paths) | |
| 124 | - | - **Previous fixes verified:** All 24 remediated items confirmed intact | |
| 125 | - | - **New action items:** 3 (email.rs split, DMARC upgrade, Postmark DKIM verification) | |
| 126 | - | ||
| 127 | - | ### Twenty-fourth audit (2026-03-10, delta review) | |
| 128 | - | - **Growth:** 47,463 -> 55,223 LOC (+16%), 168 -> 187 files, 684 -> 765 tests | |
| 129 | - | - **Trust tiers:** Upload trust system (migration 021) -- new uploads default to `held_for_review` until creator is trusted. Affects 6 scanning/storage tests that expect `clean` status on macOS (no ClamAV + no trust override in test harness). | |
| 130 | - | - **Git repos:** Linked to projects with bidirectional navigation + releases page (migration 020, `db/git_repos.rs`, `routes/git.rs`) | |
| 131 | - | - **Promo codes unification:** Discount codes + download codes merged into unified promo codes system (migration 019, `db/promo_codes.rs`, `routes/api/promo_codes.rs`). Old test files renamed. | |
| 132 | - | - **CI scripts:** `deploy/run-ci.sh` and `deploy/setup-git-ssh.sh` added for automated CI | |
| 133 | - | - **Previous fixes verified:** All 22 remediated items from twenty-second and twenty-third audits confirmed intact | |
| 134 | - | - **6 environment-dependent test failures (fixed 2026-03-10):** All 6 caused by untrusted test users. Fixed by adding `h.trust_user(user_id)` to test setup helpers. | |
| 135 | - | ||
| 136 | - | ### Improved (twenty-third audit -- 2026-03-09) | |
| 137 | - | - **Clippy clean-up:** 33 warnings fixed across 14 files | |
| 138 | - | - **Naming consistency:** `discount_code` -> `promo_code` in item checkout path | |
| 139 | - | - **Test coverage:** 8 new tests + 53 adversarial tests (597 -> 684 tests) | |
| 140 | - | - **Input validation hardened:** 100-char length cap for FreeAccess codes; trial_days capped at 365; `pwyw_min_cents` validated; discount value capped | |
| 141 | - | - **Security:** postmark_token redacted in EmailConfig Debug impl | |
| 142 | - | - **Concurrency:** subscription webhook promo code increment wrapped in DB transaction | |
| 143 | - | ||
| 144 | - | ### Improved (twenty-second audit -- 2026-03-08) | |
| 145 | - | - All 16 findings remediated. 6 dead code functions removed. 8 missing `#[instrument]` attributes added. | |
| 146 | - | - Contact revocation feature shipped. 9 new tests (588 -> 597). | |
| 147 | - | ||
| 148 | - | ### Regressed | |
| 149 | - | - Nothing. | |
| 150 | - | ||
| 151 | - | ### Grade changes (this audit) | |
| 152 | - | - No grade changes from previous audit. All module and project grades stable. | |
| 153 | - | ||
| 154 | - | ## Metrics Over Time | |
| 155 | - | ||
| 156 | - | | Audit Date | LOC | Rust Files | Tests | Tests/KLOC | Clippy Warnings | Cold Spots | Overall | | |
| 157 | - | |------------|-----|-----------|-------|-----------|----------------|------------|---------| | |
| 158 | - | | 2026-03-06 | 45K | 154 | 588 | 13.0 | 0 | 3 | A | | |
| 159 | - | | 2026-03-08 | 47K | 168 | 597 | 12.6 | 0 | 1 | A | | |
| 160 | - | | 2026-03-09 | 52K | 175 | 684 | 13.2 | 0 | 1 | A | | |
| 161 | - | | 2026-03-10 | 55K | 187 | 765 | 13.9 | 0 | 1 | A | | |
| 162 | - | | 2026-03-11 | 57K | 186 | 821 | 14.4 | 2 | 1 | A | | |
| 163 | - | | 2026-03-13 | 57K | 186 | 832 | 14.6 | 2 | 1 | A | | |
| 164 | - | | 2026-03-16 | 50K src | 143 | 968 | 19.4 | 7 | 0 | A | | |
| 165 | - | | 2026-03-17 | 50K src | 143 | 1,000 | 20.0 | 0 | 0 | A | | |
| 166 | - | | 2026-03-18 | 50K src | 142 | 1,000 | 20.0 | 0 | 0 | A | | |
| 167 | - | | 2026-03-22 | 29K src | 147 | 996 | 34.3 | 0 | 0 | A | | |
| 168 | - | | 2026-03-22 | 29K src | 153 | 1,013 | 34.9 | 0 | 0 | A | | |
| 169 | - | | 2026-03-28 | ~30K src | ~160 | 1,174 | ~39 | 0 | 0 | A | | |
| 170 | - | ||
| 171 | - | --- | |
| 172 | - | ||
| 173 | - | ## Documentation Review | |
| 174 | - | ||
| 175 | - | **Last reviewed:** 2026-03-04 (first doc audit, full scope including unpublished/internal) | |
| 176 | - | ||
| 177 | - | ### Overall Grade: A | |
| 178 | - | ||
| 179 | - | Large doc surface (80+ files across public, unpublished, internal). Public-facing docs had several inaccuracies fixed this audit. Unpublished docs had a systemic issue: many creator-facing docs described planned features as available (analytics, embedding, video hosting, streaming). Internal outreach docs had stale tier names, outdated TikTok deadline, and DEV.to member count inconsistency. competition.md had 30+ features listed as "Planned" that are now "Done". All identified issues fixed. | |
| 180 | - | ||
| 181 | - | ### Document Heatmap | |
| 182 | - | ||
| 183 | - | #### Public Docs (`docs/public/`) | |
| 184 | - | ||
| 185 | - | | Document | Status | Last Verified | Notes | | |
| 186 | - | |----------|:------:|:-------------:|-------| | |
| 187 | - | | about/roadmap.md | Fixed | 2026-03-04 | Passkeys moved to "What's Built", test count 349->386 | | |
| 188 | - | | about/how-we-work.md | Fixed | 2026-03-04 | Removed "forums" from Basic tier, "video/streams" from step 2 | | |
| 189 | - | | about/story.md | Current | 2026-03-04 | Founder story | | |
| 190 | - | | about/guarantees.md | Current | 2026-03-04 | SLA commitments | | |
| 191 | - | | support/faq.md | Fixed | 2026-03-04 | Removed 4 broken links to non-existent sub-pages | | |
| 192 | - | | support/contact.md | Current | 2026-03-04 | Contact info | | |
| 193 | - | | legal/privacy-policy.md | Current | 2026-03-04 | Privacy policy | | |
| 194 | - | | legal/terms-of-service.md | Current | 2026-03-04 | ToS | | |
| 195 | - | | legal/acceptable-use.md | Current | 2026-03-04 | AUP | | |
| 196 | - | ||
| 197 | - | #### CLAUDE.md (MNW Section) | |
| 198 | - | ||
| 199 | - | | Area | Status | Last Verified | Notes | | |
| 200 | - | |------|:------:|:-------------:|-------| | |
| 201 | - | | Source tree | Fixed | 2026-03-04 | Added lib.rs, constants.rs, docs.rs, helpers.rs, monitor.rs, sentry_layer.rs, scanning/, synckit_auth.rs, wordlist.rs | | |
| 202 | - | | Route tree | Fixed | 2026-03-04 | dashboard.rs->dashboard/, stripe.rs->stripe/, added oauth.rs + synckit.rs | | |
| 203 | - | | templates.rs | Fixed | 2026-03-04 | Changed to templates/ (directory module) | | |
| 204 | - | | types.rs | Fixed | 2026-03-04 | Changed to types/ (directory module) | | |
| 205 | - | | Migrations | Fixed | 2026-03-04 | 001-040 -> 001-009 | | |
| 206 | - | | Pricing tiers | Fixed | 2026-03-04 | Removed "forums" from Basic | | |
| 207 | - | ||
| 208 | - | #### Unpublished Docs (`docs/unpublished/`) | |
| 209 | - | ||
| 210 | - | | Area | Status | Last Verified | Notes | | |
| 211 | - | |------|:------:|:-------------:|-------| | |
| 212 | - | | strategy/competition.md | Fixed | 2026-03-04 | 30+ feature statuses updated Planned->Done, test count 28->386, SyncKit status corrected, feature matrix updated | | |
| 213 | - | | strategy/transition.md | Fixed | 2026-03-04 | Codebase stats updated (10->109 files, 3.8K->33K lines), file list modernized | | |
| 214 | - | | strategy/pitch.md | Fixed | 2026-03-04 | Removed "forums" from Basic tier | | |
| 215 | - | | community/forums.md | Fixed | 2026-03-04 | Platform forum marked as Planned (was written as if it exists) | | |
| 216 | - | | community/code-of-conduct.md | Fixed | 2026-03-04 | Forum reference softened to future tense | | |
| 217 | - | | getting-started/tier-basic.md | Fixed | 2026-03-04 | Added status note about analytics placeholder, forums, contacts dashboard | | |
| 218 | - | | getting-started/tier-big-files.md | Fixed | 2026-03-04 | Added status note: video hosting not yet implemented | | |
| 219 | - | | getting-started/tier-streaming.md | Fixed | 2026-03-04 | Added status note: live streaming not yet implemented | | |
| 220 | - | | creator/analytics.md | Fixed | 2026-03-04 | Added status note: analytics tab is placeholder | | |
| 221 | - | | creator/embedding.md | Fixed | 2026-03-04 | Added status note: embeds not yet implemented | | |
| 222 | - | | creator/dashboard.md | Fixed | 2026-03-04 | Added status note: detailed financial metrics not yet built | | |
| 223 | - | | creator/purchases.md | Fixed | 2026-03-04 | "multiple formats" -> "original uploaded format" | | |
| 224 | - | | tech/security.md | Fixed | 2026-03-04 | GitHub URL -> Sourcehut URL | | |
| 225 | - | | tech/open-source.md | Fixed | 2026-03-04 | GitHub URL -> Sourcehut URL | | |
| 226 | - | | tech/portability.md | Current | 2026-03-04 | Properly marks planned features, no issues | | |
| 227 | - | | creator/contact-sharing.md | Current | 2026-03-04 | Properly marks planned features, no issues | | |
| 228 | - | | creator/downloads.md | Current | 2026-03-04 | Accurate: original format only | | |
| 229 | - | | strategy/synckit-plan.md | Fixed | 2026-03-04 | Stripped calendar dates (sequencing only), marked Phase 1 as done, updated to reflect Rust SDK (not Swift), corrected DB schema/architecture to match implementation, updated technical roadmap | | |
| 230 | - | | All legal docs | Current | 2026-03-04 | No feature claims, stable content | | |
| 231 | - | | All business docs | Current | 2026-03-04 | No feature claims, stable content | | |
| 232 | - | ||
| 233 | - | #### Internal Docs (`docs/internal/`) | |
| 234 | - | ||
| 235 | - | | Area | Status | Last Verified | Notes | | |
| 236 | - | |------|:------:|:-------------:|-------| | |
| 237 | - | | outreach/outreach.md | Fixed | 2026-03-04 | Tier naming fixed, TikTok deadline updated | | |
| 238 | - | | outreach/creator_qol.md | Fixed | 2026-03-04 | Path references updated (docs/reference/ -> docs/unpublished/, tier-text.md -> tier-basic.md, transfer.md -> migration.md) | | |
| 239 | - | | outreach/creators/index.md | Fixed | 2026-03-04 | TikTok deadline updated | | |
| 240 | - | | outreach/creators/tech.md | Fixed | 2026-03-04 | DEV.to member count 3M+ -> 5M+ | | |
| 241 | - | | outreach/creators/niches.md | Fixed | 2026-03-04 | Course creator pitch tier $10 -> $20-30 | | |
| 242 | - | | outreach/creators/*.md (other niche files) | Not audited | -- | Creator stats are snapshots; refresh before outreach | | |
| 243 | - | | outreach/creator-investment-strategy.md | Not audited | -- | Consolidated 2026-03-04: removed duplicated sections from outreach.md, added cross-references | | |
| 244 | - | | outreach/creator-budget.md | Not audited | -- | External subscription prices may have changed | | |
| 245 | - | | outreach/creators/dashboard.md | Not audited | -- | Manually maintained counts may drift from niche files | | |
| 246 | - | ||
| 247 | - | #### Root Docs | |
| 248 | - | ||
| 249 | - | | Document | Status | Last Verified | Notes | | |
| 250 | - | |----------|:------:|:-------------:|-------| | |
| 251 | - | | docs/todo.md | Current | 2026-03-04 | Status line matches codebase | | |
| 252 | - | | docs/audit_review.md | Current | 2026-03-04 | Code audit history | | |
| 253 | - | | docs/docs-todo.md | Deleted | 2026-03-10 | Merged into docs/mnw/todo.md | | |
| 254 | - | | docs/notes-todo.md | Deleted | 2026-03-10 | Merged into docs/mnw/todo.md | | |
| 255 | - | | docs/importers.md | Not audited | -- | Phase 13D importer specs | | |
| 256 | - | ||
| 257 | - | ### Stale References Found (2026-03-04 Audit) | |
| 258 | - | ||
| 259 | - | #### Public Docs | |
| 260 | - | | Location | Issue | Resolution | | |
| 261 | - | |----------|-------|------------| | |
| 262 | - | | roadmap.md | Passkeys listed under "What's Next" -- already done (Phase 9.2) | Moved to "What's Built" | | |
| 263 | - | | roadmap.md | Test count says 349 -- actual is 386 | Updated | | |
| 264 | - | | how-we-work.md | "forums" in Basic tier -- not implemented | Removed | | |
| 265 | - | | how-we-work.md | "audio, video, text, or streams" -- video/streaming not built | Changed to "audio, text, or digital files" | | |
| 266 | - | | faq.md | Links to faq-billing.md, faq-content.md, faq-payouts.md, faq-technical.md -- none exist | Removed broken links | | |
| 267 | - | ||
| 268 | - | #### CLAUDE.md | |
| 269 | - | | Location | Issue | Resolution | | |
| 270 | - | |----------|-------|------------| | |
| 271 | - | | MNW section | 5 files listed as .rs that are directory modules | Fixed all 5 | | |
| 272 | - | | MNW section | Migration count 001-040, only 9 exist | Fixed to 001-009 | | |
| 273 | - | | MNW section | Route tree missing oauth.rs, synckit.rs | Added | | |
| 274 | - | | MNW section | Src tree missing ~10 modules | Added all | | |
| 275 | - | | MNW pricing | "forums" in Basic tier | Removed | | |
| 276 | - | ||
| 277 | - | #### Unpublished Docs | |
| 278 | - | | Location | Issue | Resolution | | |
| 279 | - | |----------|-------|------------| | |
| 280 | - | | competition.md | 30+ features listed as "Planned" that are now Done | Updated all statuses | | |
| 281 | - | | competition.md | Test count "Unit tests (15) + integration tests (13)" | Updated to "386 automated tests" | | |
| 282 | - | | competition.md | SyncKit listed as entirely Planned | Updated 11 items to Done | | |
| 283 | - | | competition.md | Feature matrix: discount codes "No", subscriptions "Planned" | Updated both to Yes/Done | | |
| 284 | - | | transition.md | "10 Rust source files", "~3,800 lines of Rust" | Updated to 109 files, ~33,000 lines | | |
| 285 | - | | transition.md | Old file structure (db.rs, routes/pages.rs, etc.) | Updated to current directory modules | | |
| 286 | - | | pitch.md | "forums" in Basic tier | Removed | | |
| 287 | - | | forums.md | Platform forum described as existing | Marked as Planned | | |
| 288 | - | | code-of-conduct.md | References "the main Makenot.work forum" | Updated to future tense | | |
| 289 | - | | tier-big-files.md | Video hosting described as available | Added "not yet implemented" note | | |
| 290 | - | | tier-streaming.md | Live streaming described as available | Added "not yet implemented" note | | |
| 291 | - | | creator/analytics.md | Analytics described as built | Added placeholder status note | | |
| 292 | - | | creator/embedding.md | Embeds described as built | Added "not yet implemented" note | | |
| 293 | - | | creator/dashboard.md | Detailed financial metrics described as built | Added placeholder status note | | |
| 294 | - | | creator/purchases.md | "Downloadable in multiple formats" | Changed to "original uploaded format" | | |
| 295 | - | | tech/security.md | GitHub URL (github.com/makecreative/makenot.work) | Changed to Sourcehut | | |
| 296 | - | | tech/open-source.md | Same GitHub URL | Changed to Sourcehut | | |
| 297 | - | ||
| 298 | - | #### Internal Docs | |
| 299 | - | | Location | Issue | Resolution | | |
| 300 | - | |----------|-------|------------| | |
| 301 | - | | outreach.md | Tier names "Text, Small Files, Audio, Big Files" | Changed to "Basic, Small Files, Big Files, Streaming" | | |
| 302 | - | | outreach.md | TikTok "full shutdown by March 2026" | Updated to "ongoing regulatory limbo" | | |
| 303 | - | | creators/index.md | Same TikTok deadline | Updated | | |
| 304 | - | | creator_qol.md | References docs/reference/ paths | Changed to docs/unpublished/ | | |
| 305 | - | | creator_qol.md | References tier-text.md | Changed to tier-basic.md | | |
| 306 | - | | creator_qol.md | References transfer.md | Changed to migration.md | | |
| 307 | - | | creators/tech.md | DEV.to "3M+ devs" | Updated to "5M+ reach" | | |
| 308 | - | | creators/niches.md | Course creator pitch says "$10/month" | Changed to "$20-30/month" with tier explanation | | |
| 309 | - | ||
| 310 | - | ### Cold Spots (Not Yet Audited) | |
| 311 | - | ||
| 312 | - | | Area | Size | Risk | Notes | | |
| 313 | - | |------|------|------|-------| | |
| 314 | - | | outreach/creators/*.md (niche files) | 9 files, ~321 creators | Medium | Patreon counts, subscriber numbers are snapshots. Refresh before actual outreach. | | |
| 315 | - | | outreach/creator-investment-strategy.md | 1 file | Done | Consolidated 2026-03-04: removed duplicated sections from outreach.md, added cross-references | | |
| 316 | - | | outreach/creator-budget.md | 1 file | Low | External subscription prices may have changed | | |
| 317 | - | | outreach/creators/dashboard.md | 1 file | Low | Manually maintained counts may drift from niche files | | |
| 318 | - | | strategy/synckit-plan.md | 1 file | Done | Audited 2026-03-04: dates stripped, implementation divergences noted, Phase 1 marked done | | |
| 319 | - | | docs-todo.md, notes-todo.md | Deleted | -- | Merged into docs/mnw/todo.md (2026-03-10) | | |
| 320 | - | | legal/copyright.md | 1 file | Low | DMCA agent address placeholder | | |
| 321 | - | | legal/liability.md | 1 file | Low | Multiple [STATE] and [Pending legal review] placeholders | | |
| 322 | - | ||
| 323 | - | ### Action Items | |
| 324 | - | ||
| 325 | - | - Keep roadmap.md test count in sync when test count changes | |
| 326 | - | - When forums are implemented, add back to Basic tier and update forums.md/code-of-conduct.md | |
| 327 | - | - When video hosting ships, remove status notes from tier-big-files.md and related creator docs | |
| 328 | - | - When streaming ships, remove status note from tier-streaming.md | |
| 329 | - | - Refresh creator stats in niche files before beginning actual outreach |
| @@ -1,489 +0,0 @@ | |||
| 1 | - | # MakeNotWork -- Audit Review | |
| 2 | - | ||
| 3 | - | **Last audited:** 2026-05-11 (Run 26, Ultra Fuzz -- 5-axis deep audit + platform overview verification) | |
| 4 | - | **Previous audit:** 2026-05-11 (Run 25, Ultra Fuzz -- 5-axis deep audit) | |
| 5 | - | ||
| 6 | - | ## Overall Grade: A | |
| 7 | - | ||
| 8 | - | Run 26: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance) + Platform Overview feature/math verification. v0.5.14. ~88,978 LOC. ~1,225 test annotations. 111 migrations. 0 CRITICAL, 0 SERIOUS, 4 MINOR findings. 5 cold spots. 5/5 axes at A. All 3 Run 25 SERIOUS items verified fixed. 7 Run 25 MINOR items still open. Platform overview: all 27 advertised features verified present; comparison table math has inconsistencies. | |
| 9 | - | ||
| 10 | - | ## Platform Overview Verification | |
| 11 | - | ||
| 12 | - | ### Advertised Features -- All 27 Verified Present | |
| 13 | - | ||
| 14 | - | | Feature | Status | Test Coverage | | |
| 15 | - | |---------|--------|---------------| | |
| 16 | - | | One-time purchases | EXISTS | WELL-TESTED (35+) | | |
| 17 | - | | Pay-what-you-want | EXISTS | UNDERTESTED (5) | | |
| 18 | - | | Subscriptions | EXISTS | WELL-TESTED (41+) | | |
| 19 | - | | Bundles | EXISTS | **UNTESTED (0)** | | |
| 20 | - | | Promo codes | EXISTS | WELL-TESTED (31) | | |
| 21 | - | | License keys | EXISTS | ADEQUATE (30 template tests; DB module 0) | | |
| 22 | - | | Guest checkout | EXISTS | UNDERTESTED (2) | | |
| 23 | - | | Audio/video streaming | EXISTS | **UNTESTED (0)** | | |
| 24 | - | | Chapters | EXISTS | UNDERTESTED (1) | | |
| 25 | - | | File versioning | EXISTS | UNDERTESTED (1) | | |
| 26 | - | | Malware scanning | EXISTS | WELL-TESTED (112) | | |
| 27 | - | | Project storefronts | EXISTS | WELL-TESTED (31 validation) | | |
| 28 | - | | Blog publishing | EXISTS | ADEQUATE (3 validation) | | |
| 29 | - | | Mailing lists/broadcasts | EXISTS | **UNTESTED (0)** | | |
| 30 | - | | RSS feeds | EXISTS | WELL-TESTED (20) | | |
| 31 | - | | Follower system | EXISTS | **UNTESTED (0)** | | |
| 32 | - | | Curated collections | EXISTS | UNDERTESTED (2) | | |
| 33 | - | | Custom domains + TLS | EXISTS | **UNTESTED (0)** | | |
| 34 | - | | Embeddable widgets | EXISTS | **UNTESTED (0)** | | |
| 35 | - | | Analytics | EXISTS | WELL-TESTED (12) | | |
| 36 | - | | Data export (JSON/CSV/ZIP) | EXISTS | **UNTESTED (0)** | | |
| 37 | - | | Git repos + browser/blame | EXISTS | WELL-TESTED (52) | | |
| 38 | - | | Discover page + search | EXISTS | **UNTESTED (0)** | | |
| 39 | - | | 2FA/passkeys | EXISTS | ADEQUATE (12 crypto; enrollment 0) | | |
| 40 | - | | CSRF protection | EXISTS | WELL-TESTED (19) | | |
| 41 | - | | Integrated forums (MT) | EXISTS | N/A (external service) | | |
| 42 | - | | Email-based issues | EXISTS | ADEQUATE (address parsing tests) | | |
| 43 | - | ||
| 44 | - | **8 advertised features have zero tests**: bundles, audio/video streaming, mailing lists, followers, custom domains, embeddable widgets, data export, discover/search. These are the highest-priority coverage gaps. | |
| 45 | - | ||
| 46 | - | ### Comparison Table Math Issues | |
| 47 | - | ||
| 48 | - | The revenue comparison table in `platform-overview.html` has inconsistencies in the Stripe fee model used: | |
| 49 | - | ||
| 50 | - | | Row | Issue | | |
| 51 | - | |-----|-------| | |
| 52 | - | | $500 MNW upper bound | Shows $470 (= $500 - $30 Stripe, no tier). Should be $460 ($500 - $30 - $10 Basic). Off by $10. | | |
| 53 | - | | $1,000 MNW upper bound | Shows $941 (= $1000 - $59 Stripe, no tier). Should be $931 ($1000 - $59 - $10 Basic). Off by $10. | | |
| 54 | - | | $500 Savings upper bound | Shows $75. Should be $65 ($460 - $395) if MNW upper is corrected. | | |
| 55 | - | | $1,000 Savings upper bound | Shows $121. Should be $111 ($931 - $820) if MNW upper is corrected. | | |
| 56 | - | | $5,000 row | Uses ~2.9% flat Stripe fee (no per-transaction $0.30), inconsistent with $500/$1000 rows which use 2.9% + $0.30 | | |
| 57 | - | | Stripe fee footnote | Says "~3%" but effective rate at $10/txn is ~5.9% (2.9% + $0.30/$10). Footnote should clarify per-transaction fee | | |
| 58 | - | ||
| 59 | - | The $2,000 row matches the callout ("~$1,850") and is internally consistent. The breakeven claim ("$67/month") is correct when compared against a 15% platform ($10 tier / 15% = $66.67). | |
| 60 | - | ||
| 61 | - | **Recommendation**: Recalculate all cells with a single explicit Stripe fee model (2.9% + $0.30 per transaction at $10 avg sale). Ensure the MNW range always subtracts the $10 Basic tier at the upper bound. | |
| 62 | - | ||
| 63 | - | ## Scorecard | |
| 64 | - | ||
| 65 | - | | Dimension | Grade | Notes | | |
| 66 | - | |-----------|:-----:|-------| | |
| 67 | - | | Code Quality | A | Zero .unwrap() in production paths. Clean macro patterns throughout | | |
| 68 | - | | Architecture | A | Clean layer separation. Trait-based backends for storage/email/payments | | |
| 69 | - | | Testing | A- | ~1,225 test annotations, proptest active, adversarial tests. 8 advertised features at 0 tests | | |
| 70 | - | | Security | A | Constant-time compare, fail-closed scanning, CSRF everywhere, Argon2id, HMAC webhooks, PKCE S256. DUMMY_HASH pattern on all login paths | | |
| 71 | - | | Performance | A | Discover facets parallelized. Batch loading. Bounded scan semaphore. Advisory lock pinned | | |
| 72 | - | | Documentation | A | Module-level //! on all major files. README.md present | | |
| 73 | - | | Dependencies | A- | 4 transitive advisories (none exploitable). async-trait retained (required for dyn dispatch) | | |
| 74 | - | | Frontend | A | Askama auto-escape, json_escape prevents JSON-LD XSS, HTMX patterns consistent. CSRF field names consistent | | |
| 75 | - | | Type Safety | A+ | 36 UUID newtypes, 25+ domain enums, validated string types, Cents/PriceCents monetary newtypes | | |
| 76 | - | | Observability | A | All route modules instrumented including embed/ and payments/ | | |
| 77 | - | | Concurrency | A | ON CONFLICT, FOR UPDATE, DashMap caches. Scheduler advisory lock pinned. Promo code atomically reserved at checkout | | |
| 78 | - | | Resilience | A | Graceful shutdown with 10s deadline, timeouts on all outbound calls, fail-closed scanning | | |
| 79 | - | | API Consistency | A | ListResponse wrapper, json_error_layer, versioned SyncKit routes | | |
| 80 | - | | Migration Safety | A | 111 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout | | |
| 81 | - | | Codebase Size | A | All three oversized files split into modules. health/mod.rs at 749 (monolithic probe fn) | | |
| 82 | - | ||
| 83 | - | ## Module Heatmap | |
| 84 | - | ||
| 85 | - | | Module | Code | Arch | Test | Security | Perf | Docs | TypeSafe | Observ | Size | | |
| 86 | - | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:--------:|:------:|:----:| | |
| 87 | - | | lib.rs + main.rs | A | A | B+ | A | A | A | A | A | A | | |
| 88 | - | | config.rs | A | A | A | A | n/a | A | A | A- | A | | |
| 89 | - | | error.rs | A | A | A | A | n/a | A | A | A | A | | |
| 90 | - | | auth.rs | A | A | A- | A+ | A | A | A | A | A | | |
| 91 | - | | csrf.rs | A+ | A | A | A+ | A- | A | A | A- | A | | |
| 92 | - | | constants.rs | A+ | n/a | A | A | n/a | A- | A | n/a | A | | |
| 93 | - | | helpers.rs | A | A | A | A | A | A | A- | B+ | A | | |
| 94 | - | | rate_limit.rs | A | A | A- | A | A | A | A | A | A | | |
| 95 | - | | storage.rs | A | A | A | A | A | A | A+ | B+ | A | | |
| 96 | - | | pricing.rs | A | A+ | A+ | A | A | A | A- | n/a | A | | |
| 97 | - | | crypto.rs | A | A | A | A+ | A | A | A | n/a | A | | |
| 98 | - | | formatting.rs | A- | A | A+ | A | A | A- | A | n/a | A | | |
| 99 | - | | rss.rs | A | A | A | A | A | A | A- | n/a | A | | |
| 100 | - | | synckit_auth.rs | A | A | A | A | A | A | A | A | A | | |
| 101 | - | | metrics.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 102 | - | | db/enums.rs | A | A | A | A | n/a | A | A+ | n/a | A | | |
| 103 | - | | db/id_types.rs | A+ | A | A | n/a | n/a | A | A+ | n/a | A+ | | |
| 104 | - | | db/validated_types.rs | A | A | A+ | A | A | A | A+ | n/a | A | | |
| 105 | - | | db/users.rs | A | A | B | A | A- | A | A | A | A | | |
| 106 | - | | db/items.rs | A | A | B | A | A- | A | A | A | A | | |
| 107 | - | | db/synckit.rs | A | A | B | A | A | A | A | A | A | | |
| 108 | - | | db/transactions.rs | A | A | B+ | A | A | A | A- | A | A | | |
| 109 | - | | db/discover.rs | A | A | **B-** | A | A | A | A | n/a | A- | | |
| 110 | - | | db/cart.rs | A | A | B | A | A | A | A | n/a | A | | |
| 111 | - | | db/creator_tiers.rs | A | A+ | A | A | A | A | A | n/a | A | | |
| 112 | - | | db/versions.rs | A | A | A | A | A | A | A | n/a | A | | |
| 113 | - | | db/builds.rs | A | A+ | A | A | A | A | A | n/a | A | | |
| 114 | - | | db/pending_refunds.rs | A+ | A | B | A | A | A | A | n/a | A | | |
| 115 | - | | db/pending_s3_deletions.rs | A | A- | n/a | A | A | A | A | n/a | A | | |
| 116 | - | | db/license_keys.rs | A | A | B | A | A | A | A | n/a | A | | |
| 117 | - | | db/promo_codes.rs | A | A | A | A | A | A | A | n/a | A | | |
| 118 | - | | db/tips.rs | A | A | B | A | A | A | A | n/a | A | | |
| 119 | - | | db/idempotency.rs | A | A | B- | A | A | A | A | n/a | A | | |
| 120 | - | | db/pending_uploads.rs | A | A | n/a | A | A | A | A | n/a | A | | |
| 121 | - | | db/moderation.rs | A | A | B+ | A | n/a | A | A | A | A | | |
| 122 | - | | db/reports.rs | A | A | B+ | A | n/a | A | A | A | A | | |
| 123 | - | | db/bundles.rs | A | A | **B-** | A | A | A | A | n/a | A | | |
| 124 | - | | db/follows.rs | A | A | **B-** | A | A | A | A | n/a | A | | |
| 125 | - | | db/mailing_lists.rs | A | A | **B-** | A | A | A | A | n/a | A | | |
| 126 | - | | db/custom_domains.rs | A | A | **B-** | A | A | A | A | n/a | A | | |
| 127 | - | | db/media_files.rs | A | A | **B-** | A | A | A | A | n/a | A | | |
| 128 | - | | db/models/* | A | A | B+ | A | n/a | A- | A | n/a | A | | |
| 129 | - | | types/ | A | A | B | A | n/a | A | A | n/a | A | | |
| 130 | - | | scanning/ | A | A+ | A- | A+ | A- | A | A- | A | A | | |
| 131 | - | | scanning/archive.rs | A- | A | A- | A- | A- | A | A- | A | A | | |
| 132 | - | | scanning/structural.rs | A- | A | A | B+ | A | A | A | A- | A | | |
| 133 | - | | payments/ | A | A | A- | A | A- | A | A | A | B+ | | |
| 134 | - | | payments/webhooks.rs | A | A | A | A | A | A | A | A | A | | |
| 135 | - | | payments/checkout.rs | A- | A | A- | A | A | A | A- | A | A | | |
| 136 | - | | email/ | A | A | A | A | A- | A | A | B+ | A- | | |
| 137 | - | | scheduler/ | A | A+ | B+ | A | A | A | A | A- | A | | |
| 138 | - | | scheduler/cleanup.rs | A | A | n/a | A | A | A | A | A- | A | | |
| 139 | - | | validation/ | A | A | A+ | A+ | A | B+ | A | n/a | A | | |
| 140 | - | | import/ | A | A | A | A | B+ | A- | A | B+ | A | | |
| 141 | - | | git/ | A | A | A | A+ | B+ | A- | A | B | A | | |
| 142 | - | | git_ssh.rs | A | A | A | A | A | A- | A | B+ | A | | |
| 143 | - | | build_runner.rs | A | A- | B+ | A+ | A- | A- | A | A | A | | |
| 144 | - | | monitor.rs | A | A | A- | A | A | A | A | A+ | A | | |
| 145 | - | | templates/ | A | A- | n/a | A | A | A- | A | B+ | B+ | | |
| 146 | - | | templates/purchase.html | A- | n/a | n/a | A | n/a | n/a | n/a | n/a | **B+** | | |
| 147 | - | | routes/auth.rs | A | A | n/a | A+ | A | A | A | A | A | | |
| 148 | - | | routes/oauth.rs | A | A | n/a | A | A | A | A | A- | A- | | |
| 149 | - | | routes/admin/ | A | A | n/a | A | A | A | A | A | A | | |
| 150 | - | | routes/api/ | A | A | n/a | A | A | A | A | A | B+ | | |
| 151 | - | | routes/api/exports/ | A- | A- | n/a | A | A- | A | A | A | A- | | |
| 152 | - | | routes/api/exports/content.rs | A- | A- | n/a | A | A- | A | A | A | A | | |
| 153 | - | | routes/stripe/ | A | A | n/a | A | A- | A | A | A | B+ | | |
| 154 | - | | routes/stripe/checkout/ | A | A | n/a | A- | A | A | A | A | A | | |
| 155 | - | | routes/stripe/checkout/cart.rs | A | A | n/a | A- | A | A | A | A | A | | |
| 156 | - | | routes/synckit/ | A | A | n/a | A | A- | A | A | A | A | | |
| 157 | - | | routes/pages/discover.rs | A | A | n/a | A | A | B+ | A | A | A | | |
| 158 | - | | routes/pages/ (other) | A | A | n/a | A | A | A | A | A- | B+ | | |
| 159 | - | | routes/embed/ | A | A | n/a | **B+** | A | A- | A | A | A | | |
| 160 | - | | routes/git/ | A | A | n/a | A | A | A | A | A | A | | |
| 161 | - | | routes/storage/ | A | A | n/a | A | A | A | A | A | A | | |
| 162 | - | | routes/storage/versions.rs | A | A- | n/a | A | A | A | A | A | A | | |
| 163 | - | | routes/storage/uploads.rs | A | A | n/a | A | A | A | A | A | A | | |
| 164 | - | | routes/storage/downloads.rs | A | A | n/a | A | A- | A | A | A | A | | |
| 165 | - | | routes/storage/images.rs | **B+** | A | n/a | A | A | A | A | A | A- | | |
| 166 | - | ||
| 167 | - | **Bold** = cold spot (B or below, or significant test gap on advertised feature). | |
| 168 | - | ||
| 169 | - | ### Cold Spots | |
| 170 | - | ||
| 171 | - | | Module | Grade | Issue | | |
| 172 | - | |--------|:-----:|-------| | |
| 173 | - | | routes/storage/images.rs | B+ | Non-atomic 3-UPDATE cover image, non-atomic storage swap, missing S3 cleanup (carried from Run 25) | | |
| 174 | - | | routes/embed/ | B+ | Unescaped URLs in `<img src>` attributes (server-generated URLs, low risk) | | |
| 175 | - | | templates/purchase.html | B+ | Cart add-to-cart JS doesn't check HTTP error status (carried from Run 25) | | |
| 176 | - | | db/ (6 modules) | B- (test) | bundles, follows, mailing_lists, custom_domains, media_files, discover all have 0 unit tests for advertised features | | |
| 177 | - | | routes/pages/blog.rs | B+ | `Slug::from_trusted` on user-supplied URL path bypasses validation | | |
| 178 | - | ||
| 179 | - | ### Resolved Cold Spots (from Run 25) | |
| 180 | - | ||
| 181 | - | - ~~db/cart.rs (B+)~~ -- Fixed. SQL now correctly references `t.buyer_id`. | |
| 182 | - | - ~~routes/api/projects.rs delete (B-)~~ -- Fixed. S3 keys collected and enqueued to `pending_s3_deletions`, storage decremented. | |
| 183 | - | - ~~routes/api/exports/content.rs (B+)~~ -- Fixed. ZIP now writes to temp file on disk. Peak memory is O(largest_single_file). | |
| 184 | - | ||
| 185 | - | ## Mandatory Surprises | |
| 186 | - | ||
| 187 | - | **Run 26 (5 surprises, one per axis):** | |
| 188 | - | ||
| 189 | - | 1. **Payments -- Pending refund queue with FOR UPDATE SKIP LOCKED (unexpectedly good):** `db/pending_refunds.rs` + `checkout_helpers.rs:check_pending_refund`. When a `charge.refunded` webhook arrives before `checkout.session.completed`, the refund is queued and processed after checkout completion. Claims use `FOR UPDATE SKIP LOCKED` for safe concurrency. Stale refunds are escalated. Additionally, webhook dedup returns 503 on dedup-check failure rather than proceeding without protection -- subtle correctness choice. | |
| 190 | - | ||
| 191 | - | 2. **Storage -- Durable pending_uploads/pending_s3_deletions queue system (unexpectedly good):** Every presigned URL issuance records a `pending_upload` in PostgreSQL. If the client never confirms, a reaper reclaims the S3 object. For deletions, `pending_s3_deletions` uses `FOR UPDATE SKIP LOCKED` with atomic attempt tracking. Combined with `recalculate_all_storage_batch` drift corrector, this is a three-layer defense against storage accounting bugs. Enterprise-grade resource lifecycle management. | |
| 192 | - | ||
| 193 | - | 3. **UX Wiring -- CSRF implementation is textbook-perfect (unexpectedly good):** Constant-time comparison via `crate::helpers::constant_time_compare`. Global HTMX injection via `htmx:configRequest` means 150+ state-changing HTMX calls automatically include the token. 19 unit tests including adversarial cases. Exempt path matching prevents prefix collisions. Total CSRF bypass surface is near zero. | |
| 194 | - | ||
| 195 | - | 4. **Security -- Archive scanning does not trust attacker-controlled metadata (unexpectedly good):** `scanning/archive.rs:91-132` actually decompresses each ZIP entry byte-by-byte to measure real decompressed size, rather than trusting the ZIP central directory `size()` field. When decompression errors occur (a common evasion technique), it falls back to conservative `claimed_size * 10` estimate. Magic bytes captured during first pass rather than making a second read. Production-grade anti-ZIP-bomb engineering. | |
| 196 | - | ||
| 197 | - | 5. **Performance -- Content export ZIP architecture (unexpectedly good):** `routes/api/exports/content.rs` writes to a temp file on disk keeping peak memory at O(single file). Downloads S3 objects one at a time, dropping each buffer after writing to ZIP. Uploads finished ZIP back to S3 via multipart in 10MB chunks. Returns presigned download URL. Enforces 2GB cap with clear error. Includes README.txt manifest. Gracefully handles partial failures. Production-grade export engineering. | |
| 198 | - | ||
| 199 | - | ### Previous Surprises | |
| 200 | - | ||
| 201 | - | **Run 25:** Pending refund queue, durable S3 deletion queue, json_escape HTML entity protection, anti-timing dummy hash on all login paths, discover page 5-way parallel facet queries. | |
| 202 | - | ||
| 203 | - | **Run 24:** Claim token architecture, LATERAL join batch storage recalc, CSRF constant-time comparison, TOTP replay prevention via time step, storage quota atomicity + idempotency checks. | |
| 204 | - | ||
| 205 | - | **Run 23:** Webhook signature gold standard, atomic storage quota enforcement, CSRF dual-layer extraction, archive decompression fallback (bad, fixed), advisory lock protocol. | |
| 206 | - | ||
| 207 | - | ## Strengths | |
| 208 | - | ||
| 209 | - | ### 1. Security-in-depth | |
| 210 | - | Zero SQL injection vectors across 200+ queries. Argon2id (46MiB/2 iterations), SHA-256-based constant-time comparison, CSRF synchronizer tokens (constant-time validated), session fixation prevention, account lockout with anti-enumeration dummy hashes on ALL login paths (web, SyncKit, OAuth), PKCE S256 required, rate limiting on all endpoint classes (22 distinct configurations), HMAC-signed URLs, 6-layer malware scanning with fail-closed, ZIP bomb detection with byte-by-byte decompression, path traversal prevention, shell command validation, TOTP replay prevention via time step tracking, passkey counter updates, breached password check via HaveIBeenPwned k-anonymity. | |
| 211 | - | ||
| 212 | - | ### 2. Type safety discipline | |
| 213 | - | 36 UUID newtypes via `define_pg_uuid_id!`, 25+ domain enums via `impl_str_enum!`, validated string types, Cents/PriceCents monetary newtypes with proptest coverage. All money math in integer cents (i32/i64), zero floating point in money paths. `SUM(BIGINT)::BIGINT` cast used consistently across all 40+ SUM queries. License keys use CSPRNG (~55 bits entropy). FOR UPDATE row locking on license activation prevents TOCTOU. | |
| 214 | - | ||
| 215 | - | ### 3. Payment robustness | |
| 216 | - | Three-layer webhook idempotency. Bidirectional pending refund matching with FOR UPDATE SKIP LOCKED. Atomic promo code enforcement at DB level. `FOR UPDATE` row locking on tier deletion, license activation, pending refund claims. Self-purchase blocked across all paths. Cart PWYW minimum enforced. Tip amounts capped. Guest purchase claim tokens are cryptographically sound and idempotent. Direct Charges pattern architecturally enforces 0% platform fee by never setting `application_fee_amount`. | |
| 217 | - | ||
| 218 | - | ### 4. Operational maturity | |
| 219 | - | All Run 25 SERIOUS items fixed. Pending S3 deletions with UNIQUE constraint. Scheduler advisory lock pinned. Soft-delete purge handles version S3 keys. Confirm uploads wrapped in transactions. Discover page parallelized. Content exports stream to disk one-by-one. N+1 queries consolidated. Project deletion now properly enqueues S3 keys and decrements storage. | |
| 220 | - | ||
| 221 | - | ## Weaknesses | |
| 222 | - | ||
| 223 | - | ### Active (Run 26) | |
| 224 | - | ||
| 225 | - | - **8 advertised features with zero test coverage** -- Bundles, audio/video streaming, mailing lists/broadcasts, followers, custom domains, embeddable widgets, data export, discover/search all have 0 unit tests. These are all features shown in the platform overview sent to potential creators. | |
| 226 | - | - **Comparison table math inconsistencies** -- `platform-overview.html` revenue comparison table has incorrect upper bounds at $500 and $1,000 revenue levels (off by $10, missing Basic tier fee). Stripe fee model varies across rows. | |
| 227 | - | - **Non-atomic item cover image update** -- `routes/storage/images.rs:369-371` does 3 separate UPDATEs. Carried from Run 25. | |
| 228 | - | - **Non-atomic project image storage swap** -- `routes/storage/images.rs:173-185` decrements then increments in two queries. Carried from Run 25. | |
| 229 | - | - **Project image missing S3 deletion enqueue** -- `routes/storage/images.rs:173-179` does not enqueue old project image. Carried from Run 25. | |
| 230 | - | - **Unescaped URLs in embed HTML** -- `routes/embed/item.rs:108` and similar. Server-generated CDN URLs interpolated raw into `<img src>`. Low risk (not user input) but violates defense-in-depth. | |
| 231 | - | - **Slug::from_trusted on user-supplied path** -- `routes/pages/blog.rs:175` bypasses slug validation. No injection risk (parameterized SQL) but inconsistent with other handlers. | |
| 232 | - | - **Broadcast validation uses byte length** -- `routes/api/users/broadcast.rs:46,53` uses `.len()` instead of `.chars().count()`. Stricter than intended for multi-byte characters. | |
| 233 | - | ||
| 234 | - | ### Resolved (Run 25 -> Run 26) | |
| 235 | - | ||
| 236 | - | - ~~Cart preflight SQL column name bug~~ -- Fixed. `t.buyer_id` now used correctly. | |
| 237 | - | - ~~Project deletion orphans S3 objects~~ -- Fixed. S3 keys collected, enqueued, storage decremented. | |
| 238 | - | - ~~Content export buffers 2GB in memory~~ -- Fixed. ZIP writes to temp file on disk. | |
| 239 | - | ||
| 240 | - | ## Bug Reports by Axis | |
| 241 | - | ||
| 242 | - | ### Payments | |
| 243 | - | 0 CRITICAL, 0 SERIOUS, 0 MINOR, 4 NOTE | |
| 244 | - | ||
| 245 | - | | # | Sev | Location | Description | | |
| 246 | - | |---|-----|----------|-------------| | |
| 247 | - | | P1 | NOTE | `validated_types.rs:226` | Cents `encode_by_ref` casts i64 to i32 with debug_assert only. Release builds would silently truncate values > i32::MAX. Safe in practice -- all write paths originate from PriceCents (capped at $10k). | | |
| 248 | - | | P2 | NOTE | `routes/stripe/webhook/checkout.rs:42` | `payment_intent_id` defaults to "unknown" when None. Collision risk if multiple sessions use the fallback. Stripe always sets payment_intent on completed sessions. | | |
| 249 | - | | P3 | NOTE | `checkout_helpers.rs:248` | Revenue split rounding distributes remainder to first members in list order. Deterministic but slightly favors early members. | | |
| 250 | - | | P4 | NOTE | `db/transactions.rs:462` | `CreateProjectTransactionParams.amount_cents` is `i32` while item transactions use `Cents` (i64). Type inconsistency. | | |
| 251 | - | ||
| 252 | - | ### Storage | |
| 253 | - | 0 CRITICAL, 0 SERIOUS, 2 MINOR, 1 NOTE (carried from Run 25) | |
| 254 | - | ||
| 255 | - | | # | Sev | Location | Description | | |
| 256 | - | |---|-----|----------|-------------| | |
| 257 | - | | S1 | MINOR | `routes/storage/images.rs:369-371` | Item cover update: 3 separate UPDATEs for url, s3_key, file_size. Crash-unsafe. | | |
| 258 | - | | S2 | MINOR | `routes/storage/images.rs:173-185` | Project image replace: non-atomic decrement/increment + missing S3 deletion enqueue. | | |
| 259 | - | | S3 | NOTE | `routes/api/exports/content.rs:138-159` | Sequential S3 downloads in content export. Acceptable tradeoff (bounded memory) but large exports are slow. | | |
| 260 | - | ||
| 261 | - | ### UX Wiring | |
| 262 | - | 0 CRITICAL, 0 SERIOUS, 2 MINOR, 2 NOTE | |
| 263 | - | ||
| 264 | - | | # | Sev | Location | Description | | |
| 265 | - | |---|-----|----------|-------------| | |
| 266 | - | | U1 | MINOR | `routes/embed/item.rs:108,248-249,289` | Unescaped URLs in embed HTML `<img src>`. Server-generated CDN URLs, not user input. Defense-in-depth gap. | | |
| 267 | - | | U2 | MINOR | `routes/api/users/broadcast.rs:46,53` | Broadcast subject/body validation uses `.len()` (bytes) instead of `.chars().count()`. Multi-byte characters counted as 2-4. | | |
| 268 | - | | U3 | NOTE | `routes/pages/blog.rs:175` | `Slug::from_trusted(post_slug)` on user-supplied URL path bypasses slug validation. No injection risk. | | |
| 269 | - | | U4 | NOTE | `templates/pages/purchase.html:142` | Cart add-to-cart JS doesn't check HTTP error status before redirect. Carried from Run 25. | | |
| 270 | - | ||
| 271 | - | ### Security | |
| 272 | - | 0 CRITICAL, 0 SERIOUS, 0 MINOR, 4 NOTE | |
| 273 | - | ||
| 274 | - | | # | Sev | Location | Description | | |
| 275 | - | |---|-----|----------|-------------| | |
| 276 | - | | X1 | NOTE | `main.rs:116-121` | `with_http_only(true)` not explicitly set on session cookie. tower-sessions defaults to true, but implicit. | | |
| 277 | - | | X2 | NOTE | `auth.rs:206` | `MaybeUser` skips session revocation check (documented, intentional). Used only on read-only endpoints. | | |
| 278 | - | | X3 | NOTE | `synckit_auth.rs:57-68` | JWT `iat` claim not validated. Standard practice; `exp` is validated. | | |
| 279 | - | | X4 | NOTE | `csrf.rs:138-146` | CSRF exempt list is broad (11 paths). Each has documented justification. Worth monitoring as routes are added. | | |
| 280 | - | ||
| 281 | - | ### Performance | |
| 282 | - | 0 CRITICAL, 0 SERIOUS, 0 MINOR, 4 NOTE | |
| 283 | - | ||
| 284 | - | | # | Sev | Location | Description | | |
| 285 | - | |---|-----|----------|-------------| | |
| 286 | - | | F1 | NOTE | `routes/pages/public/discover.rs:341-361` | Discover page fires 7+ parallel DB queries per request. At 100 concurrent users = 700 query demands on 25-connection pool. CDN caching (`s-maxage=60`) mitigates. | | |
| 287 | - | | F2 | NOTE | `routes/api/exports/mod.rs:268-270` | N+1 query: collection items loaded per-collection (up to 50 queries). Export is rate-limited. | | |
| 288 | - | | F3 | NOTE | `build_runner.rs:396-408` | Build artifact read entirely into memory before S3 upload. Single-build lock limits blast radius. | | |
| 289 | - | | F4 | NOTE | `scheduler/integrity.rs:54-62` | Weekly sales count drift check joins entire transactions table. Grows with platform. LIMIT 50 bounds result but not scan. | | |
| 290 | - | ||
| 291 | - | ## Cross-Cutting Concerns | |
| 292 | - | ||
| 293 | - | ### Test coverage gaps on advertised features (All Axes) | |
| 294 | - | 8 features shown in the platform overview sent to potential creators have zero tests: bundles, audio/video streaming, mailing lists/broadcasts, followers, custom domains, embeddable widgets, data export, and discover/search. While the code for these features is solid (A-grade on code quality), regressions in any of these would be invisible to CI. The highest-risk gaps are audio/video streaming (paid tier feature with access control) and discover/search (the primary content discovery mechanism). | |
| 295 | - | ||
| 296 | - | ### Image upload atomicity gap (Storage + UX) | |
| 297 | - | Both project image and item cover image confirms have atomicity issues. The item path does 3 separate UPDATEs; the project path does non-atomic storage swap and skips S3 cleanup. The version upload path (`uploads.rs`) correctly uses `try_replace_storage` and `pending_s3_deletions` -- the image paths should follow the same pattern. Carried from Run 25. | |
| 298 | - | ||
| 299 | - | ### Platform overview accuracy (UX + Payments) | |
| 300 | - | The comparison table math has inconsistencies that could be spotted by a savvy creator doing their own calculations. While the core value proposition is correct (0% platform fee saves money at scale), the specific dollar figures at $500 and $1,000 revenue are off by $10 at the upper bound. The Stripe fee model is not consistent across rows. | |
| 301 | - | ||
| 302 | - | ## Components Successfully Stress-Tested | |
| 303 | - | ||
| 304 | - | ### Payments (16 vectors survived) | |
| 305 | - | Webhook replay, double-credit, concurrent promo exhaustion (atomic at DB level), cross-user data access, self-purchase, negative/overflow amounts, floating-point money math, out-of-order webhooks, suspended creator purchases, SUM(BIGINT) pitfall, PWYW cart minimum enforcement, tip amount cap, stale pending transaction cleanup, bundle refund cascade, subscription tier delete TOCTOU, webhook handler crash with retry/dead-letter. | |
| 306 | - | ||
| 307 | - | ### Storage (10 vectors survived) | |
| 308 | - | Cross-user file overwrites, path traversal in filenames, content type smuggling, storage quota bypass (atomic enforcement), double-spend on idempotent confirm, malware file serving, orphaned upload cleanup, SUM(BIGINT) pitfall, transactional confirm uploads, soft-delete purge with version S3 keys. | |
| 309 | - | ||
| 310 | - | ### UX Wiring (10 vectors survived) | |
| 311 | - | XSS via template injection, open redirect, user enumeration, markdown/HTML injection, pagination abuse, integer overflow in pricing, internal detail leakage, CSV injection (sanitize_csv_cell), Unicode boundary attacks, JSON-LD breakout. | |
| 312 | - | ||
| 313 | - | ### Security (17 vectors survived) | |
| 314 | - | Virus scan bypass via ClamAV downtime (fail-closed), content-type spoofing (PE-as-audio, ZIP-as-audio), path traversal in archives (including URL-encoded), session fixation (cycle_id on login), timing-based user enumeration (dummy hash on all 3 login paths), brute force login (lockout after 5, 15-min), X-Forwarded-For spoofing (Cloudflare-aware), session reuse after password change (delete_other_sessions), OAuth code replay (atomic consume), PKCE downgrade (plain rejected), token prediction (CSPRNG), CSRF on state-changing endpoints, SSH command injection (reconstructed from validated components), passkey cloning (counter), TOTP replay (last_used_step), IDOR on passkeys/sessions, archive bombs (byte counting + 10x multiplier). | |
| 315 | - | ||
| 316 | - | ### Performance (12 vectors survived) | |
| 317 | - | Connection pool exhaustion (bounded at 25), file scanning memory (semaphore of 4), SSE connection accumulation (bounded at 10/user), scheduler job accumulation (advisory lock), background task leaks (monitored), ZIP bombs (byte counting), bulk operations (batch queries, 100-item cap), rate limiter bypass (Cloudflare-aware IP), concurrent scan memory pressure, large file handling (streamed uploads via presigned URLs), global lock contention (DashMap, no Mutex/RwLock), graceful shutdown (10s drain). | |
| 318 | - | ||
| 319 | - | ## Confidence Assessment | |
| 320 | - | ||
| 321 | - | | Axis | Confidence | Notes | | |
| 322 | - | |------|-----------|-------| | |
| 323 | - | | Payments | HIGH | Core payment logic excellent. All Run 25 bugs fixed. Bundles, tips, license keys DB modules need unit tests. | | |
| 324 | - | | Storage | HIGH | Project deletion S3 orphan gap fixed. Image atomicity carried but low-severity. Scanning pipeline rock-solid. | | |
| 325 | - | | UX Wiring | HIGH | CSRF excellent. Templates safe. Embed URL escaping is defense-in-depth gap, not exploitable. | | |
| 326 | - | | Security | HIGH (95%) | No vulnerabilities found. 17 attack vectors survived. All security claims in platform overview verified true. | | |
| 327 | - | | Performance | HIGH (current scale) | Pool and rate limiting solid. Discover page query fan-out is the scalability cliff but CDN mitigates. | | |
| 328 | - | ||
| 329 | - | ## Metrics | |
| 330 | - | ||
| 331 | - | - Modules audited: 65+ | |
| 332 | - | - Total cold spots: 5 (+ 6 DB modules at B- for test coverage) | |
| 333 | - | - Bugs by severity: 0 critical, 0 serious, 4 minor, 15 note | |
| 334 | - | - Axes at A or above: 5/5 | |
| 335 | - | ||
| 336 | - | ## Axis Summary Grades | |
| 337 | - | ||
| 338 | - | | Axis | Overall | Cold Spots | Mandatory Surprise | | |
| 339 | - | |------|---------|------------|-------------------| | |
| 340 | - | | Payments | A | None (cart.rs fixed) | Pending refund queue + webhook dedup 503 (good) | | |
| 341 | - | | Storage | A | images.rs (B+) | Durable pending_uploads/pending_s3_deletions system (good) | | |
| 342 | - | | UX Wiring | A | embed/ (B+), purchase.html (B+) | CSRF implementation textbook-perfect (good) | | |
| 343 | - | | Security | A | None | Archive scanning byte-by-byte decompression (good) | | |
| 344 | - | | Performance | A | None | Content export ZIP disk-backed architecture (good) | | |
| 345 | - | ||
| 346 | - | ## Recommended Priority Order | |
| 347 | - | ||
| 348 | - | 1. **[COVERAGE]** Add tests for 8 untested advertised features (bundles, streaming, mailing lists, followers, custom domains, widgets, export, discover) -- highest risk-to-effort ratio | |
| 349 | - | 2. **[CONTENT]** Fix platform-overview.html comparison table math ($500/$1000 rows off by $10, clarify Stripe fee model) | |
| 350 | - | 3. **[MINOR]** Consolidate item cover image into single UPDATE (`routes/storage/images.rs:369-371`) | |
| 351 | - | 4. **[MINOR]** Use `try_replace_storage` for project image replace + enqueue old S3 key | |
| 352 | - | 5. **[MINOR]** Apply `html_escape()` to URLs in embed HTML | |
| 353 | - | 6. **[MINOR]** Fix broadcast validation to use `.chars().count()` instead of `.len()` | |
| 354 | - | 7. **[NOTE]** Add HTTP error check to purchase.html cart JS | |
| 355 | - | 8. **[NOTE]** Use `Slug::new()` instead of `Slug::from_trusted()` in blog changelog handler | |
| 356 | - | 9. **[NOTE]** Batch-load collection items in export handler | |
| 357 | - | 10. **[DEFERRED]** Stream build artifacts to S3 via multipart upload | |
| 358 | - | 11. **[DEFERRED]** Extract shared `validate_promo_code()` helper (carried from Run 24) | |
| 359 | - | ||
| 360 | - | ## Action Items | |
| 361 | - | ||
| 362 | - | ### Run 26 (2026-05-11) | |
| 363 | - | ||
| 364 | - | 115. **[COVERAGE]** Add unit tests for bundles (db/bundles.rs, routes/api/items/bundles.rs) | |
| 365 | - | 116. **[COVERAGE]** Add unit tests for audio/video streaming (routes/storage/media.rs, db/media_files.rs) | |
| 366 | - | 117. **[COVERAGE]** Add unit tests for mailing lists/broadcasts (db/mailing_lists.rs, routes/api/users/broadcast.rs) | |
| 367 | - | 118. **[COVERAGE]** Add unit tests for followers (db/follows.rs, routes/api/follows.rs) | |
| 368 | - | 119. **[COVERAGE]** Add unit tests for custom domains (db/custom_domains.rs, routes/custom_domain.rs) | |
| 369 | - | 120. **[COVERAGE]** Add unit tests for embeddable widgets (routes/embed/) | |
| 370 | - | 121. **[COVERAGE]** Add unit tests for data export (routes/api/exports/) | |
| 371 | - | 122. **[COVERAGE]** Add unit tests for discover/search (db/discover.rs) | |
| 372 | - | 123. **[CONTENT]** Fix platform-overview.html comparison table math and clarify Stripe fee model | |
| 373 | - | 124. **[MINOR]** Consolidate item cover image into single UPDATE (`routes/storage/images.rs:369-371`) | |
| 374 | - | 125. **[MINOR]** Use `try_replace_storage` for project image replace + enqueue old S3 key to `pending_s3_deletions` | |
| 375 | - | 126. **[MINOR]** Apply `html_escape()` to URLs in embed HTML (`routes/embed/item.rs`) | |
| 376 | - | 127. **[MINOR]** Fix broadcast validation `.len()` -> `.chars().count()` (`routes/api/users/broadcast.rs:46,53`) | |
| 377 | - | ||
| 378 | - | ### Carried from Run 25 (still open) | |
| 379 | - | ||
| 380 | - | 106. **[MINOR]** Consolidate item cover image into single UPDATE -- same as 124 above | |
| 381 | - | 107. **[MINOR]** Use `try_replace_storage` for project image replace -- same as 125 above | |
| 382 | - | 108. **[MINOR]** Enqueue old project image S3 key to `pending_s3_deletions` -- same as 125 above | |
| 383 | - | 109. **[MINOR]** Add HTTP error check to purchase.html cart add-to-cart JS | |
| 384 | - | 110. **[MINOR]** Fix `format_price` negative formatting to use `-$X.XX` | |
| 385 | - | 111. **[MINOR]** Batch-load collection items in export handler (`exports/mod.rs:268-270`) | |
| 386 | - | 112. **[MINOR]** Switch `check_sandbox_cap` to `pg_try_advisory_lock` | |
| 387 | - | ||
| 388 | - | ### Deferred | |
| 389 | - | ||
| 390 | - | 113. DEFERRED: Stream build artifacts to S3 via multipart upload (carried from Run 25) | |
| 391 | - | 114. DEFERRED: Extract shared `validate_promo_code()` helper (carried from Run 24 -- **CHRONIC** at Run 26, 3 consecutive runs) | |
| 392 | - | ||
| 393 | - | ### Open (blocked on upstream) | |
| 394 | - | ||
| 395 | - | 23. Monitor aws-sdk-s3 for lru fix (RUSTSEC-2026-0002) | |
| 396 | - | 24. Monitor async-stripe for instant fix (RUSTSEC-2024-0384) | |
| 397 | - | 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049) | |
| 398 | - | 33. bincode unmaintained (RUSTSEC-2025-0141) -- upstream via syntect/yara-x, warning only | |
| 399 | - | ||
| 400 | - | ## Previous Action Item Verification (Run 25) | |
| 401 | - | ||
| 402 | - | | # | Item | Status | | |
| 403 | - | |---|------|--------| | |
| 404 | - | | 103 | Fix `db/cart.rs:106` -- change `t.user_id` to `t.buyer_id` | **Fixed** (verified: `WHERE t.buyer_id = $2`) | | |
| 405 | - | | 104 | Add S3 cleanup + storage decrement to project deletion | **Fixed** (verified: collects S3 keys, enqueues to pending_s3_deletions, decrements storage) | | |
| 406 | - | | 105 | Stream content export ZIP to S3 instead of in-memory buffer | **Fixed** (verified: writes to temp file on disk via `tempfile::tempdir()`) | | |
| 407 | - | | 106 | Consolidate item cover image into single UPDATE | **Still open** | | |
| 408 | - | | 107 | Use `try_replace_storage` for project image replace | **Still open** | | |
| 409 | - | | 108 | Enqueue old project image S3 key to `pending_s3_deletions` | **Still open** | | |
| 410 | - | | 109 | Add HTTP error check to purchase.html cart JS | **Still open** | | |
| 411 | - | | 110 | Fix `format_price` negative formatting | **Still open** | | |
| 412 | - | | 111 | Batch-load collection items in export handler | **Still open** | | |
| 413 | - | | 112 | Switch `check_sandbox_cap` to `pg_try_advisory_lock` | **Still open** | | |
| 414 | - | | 113 | Stream build artifacts to S3 via multipart upload | **Deferred** (carried) | | |
| 415 | - | | 114 | Extract shared `validate_promo_code()` helper | **Deferred** (carried -- now CHRONIC) | | |
| 416 | - | ||
| 417 | - | 3 of 3 SERIOUS items fixed. 0 of 7 MINOR items fixed. 2 deferred items carried forward. | |
| 418 | - | ||
| 419 | - | ### Chronic Items (unfixed across 3+ consecutive runs) | |
| 420 | - | ||
| 421 | - | - **Item 114: Extract shared `validate_promo_code()` helper** -- Deferred since Run 24 (3 consecutive runs: 24, 25, 26). Low risk but growing code divergence between checkout paths. | |
| 422 | - | ||
| 423 | - | ### False Positives Identified (Run 26) | |
| 424 | - | ||
| 425 | - | - **Content export still buffers files in memory** -- Agent flagged as still-open SERIOUS. Actually fixed: the ZIP writes to disk. Individual file downloads are O(single file) then dropped. This is the expected pattern. | |
| 426 | - | - **Discover page pool exhaustion** -- Agent flagged at MEDIUM. CDN caching at s-maxage=60 effectively mitigates. Each unique filter combination is a cache miss but rate limiting provides admission control. | |
| 427 | - | - **HttpOnly cookie not set** -- tower-sessions defaults HttpOnly to true. The explicit call is best practice but not a vulnerability. | |
| 428 | - | - **Tip checkout missing minimum charge** -- Route handler at `routes/stripe/checkout/tips.rs` validates minimum $1.00. | |
| 429 | - | - **FixedPricing no upper bound** -- Stripe session amount is set server-side. Client cannot override. | |
| 430 | - | - **MaybeUser session revocation bypass** -- Documented, intentional. Used only on read-only/public pages. | |
| 431 | - | ||
| 432 | - | ## Delta Since Run 25 | |
| 433 | - | ||
| 434 | - | ### Fixed (from Run 25) | |
| 435 | - | 3 of 3 SERIOUS items verified fixed. 0 of 7 MINOR items fixed. No regressions detected. | |
| 436 | - | ||
| 437 | - | ### New Findings (not in Run 25) | |
| 438 | - | - Unescaped URLs in embed HTML (MINOR) -- new | |
| 439 | - | - Slug::from_trusted on user path (NOTE) -- new | |
| 440 | - | - Broadcast validation byte vs char length (MINOR) -- new | |
| 441 | - | - 8 advertised features with zero test coverage (COVERAGE) -- new analysis focus | |
| 442 | - | - Comparison table math inconsistencies (CONTENT) -- new analysis focus | |
| 443 | - | - Sales count drift check full-table scan (NOTE) -- new | |
| 444 | - | ||
| 445 | - | ### Grade Changes | |
| 446 | - | - db/cart.rs: B+ -> A (SQL column bug fixed) | |
| 447 | - | - routes/api/projects.rs (delete): B- -> removed (S3 orphan gap fixed) | |
| 448 | - | - routes/api/exports/content.rs: B+ -> A- (memory buffering fixed) | |
| 449 | - | - routes/embed/: A -> B+ (unescaped URLs found) | |
| 450 | - | - db/discover.rs: A -> B- (zero tests for advertised feature) | |
| 451 | - | - db/bundles.rs, db/follows.rs, db/mailing_lists.rs, db/custom_domains.rs, db/media_files.rs: (new) B- test grade | |
| 452 | - | - Overall: A (held) | |
| 453 | - | - Cold spots: 5 -> 5 (3 resolved, 3 new) | |
| 454 | - | - SERIOUS findings: 3 -> 0 | |
| 455 | - | - Testing dimension: A -> A- (8 untested advertised features identified) | |
| 456 | - | ||
| 457 | - | ## Metrics Over Time | |
| 458 | - | ||
| 459 | - | | Audit Date | LOC | Rust Files | Tests | Tests/KLOC | Clippy Warnings | Cold Spots | Overall | | |
| 460 | - | |------------|-----|-----------|-------|-----------|----------------|------------|---------| | |
| 461 | - | | 2026-03-14 | 4,808 | 36 | 90 | 18.7 | 0 | 7 | B+ | | |
| 462 | - | | 2026-03-14 (remediation) | ~4,600 | 33 | 97 | ~21 | 0 | 3 | A- | | |
| 463 | - | | 2026-03-14 (rate limit) | ~4,700 | 34 | 99 | ~21 | 0 | 3 | A- | | |
| 464 | - | | 2026-03-14 (coverage) | ~4,800 | 34 | 106 | ~22 | 0 | 1 | A | | |
| 465 | - | | 2026-03-14 (ammonia) | ~4,800 | 34 | 106 | ~22 | 0 | 0 | A | | |
| 466 | - | | 2026-03-16 (Run 6) | 6,232 | ~36| 146 | ~23 | 0 | 0 | A | | |
| 467 | - | | 2026-03-16 (P19+P20) | ~7,000 | ~38| 173 | ~25 | 0 | 0 | A | | |
| 468 | - | | 2026-03-17 (Run 8) | ~7,000 | ~38| 222 | ~32 | 0 | 0 | A | | |
| 469 | - | | 2026-03-18 (Run 9) | ~7,000 | ~38| 222 | ~32 | 0 | 0 | A | | |
| 470 | - | | 2026-03-22 (coverage) | ~7,000 | ~39| 249 | ~36 | 0 | 0 | A | | |
| 471 | - | | 2026-03-28 (Run 12) | ~7,200 | ~39| 225+ | ~32 | 0 | 0 | A | | |
| 472 | - | | 2026-04-06 (Run 13) | ~29,000 | ~153| ~1,186 | ~40 | 0 | 0 | A | | |
| 473 | - | | 2026-04-15 (Run 14) | ~67,442 | -- | ~1,356 | ~20 | 0 | 0 | A | | |
| 474 | - | | 2026-04-18 (Run 15) | ~67,442 | -- | 1,356 (29 fail) | ~20 | 0 | 4 | A- | | |
| 475 | - | | 2026-04-22 (Run 15 corrected) | ~67,442 | -- | 1,359 | ~20 | 0 | 1 | A | | |
| 476 | - | | 2026-04-30 (Run 17) | ~79,334 | -- | 1,861 | ~15.0 | 0 | 0 | A | | |
| 477 | - | | 2026-05-01 (Run 18) | ~80,470 | -- | 1,933 (34 int. fail) | ~15.1 | 0 | 5 | A | | |
| 478 | - | | 2026-05-02 (Run 19) | ~81,384 | -- | 1,923 (0 fail) | ~23.6 | 0 | 2 | A | | |
| 479 | - | | 2026-05-04 (Run 20) | ~83,232 | 238 | 1,214+ annotations | ~14.6 | 0 | 2 | A | | |
| 480 | - | | 2026-05-08 (Run 21) | ~87,427 | -- | 1,218 annotations | ~13.9 | 0 | 5 | A | | |
| 481 | - | | 2026-05-09 (Run 22) | ~87,853 | -- | 1,215 annotations | ~13.8 | 0 | 3 | A | | |
| 482 | - | | 2026-05-09 (Run 23) | ~88,082 | -- | ~1,215 annotations | ~13.8 | 0 | 0 | A | | |
| 483 | - | | 2026-05-09 (Run 24) | ~88,082 | -- | ~1,215 annotations | ~13.8 | 0 | 2 | A | | |
| 484 | - | | 2026-05-11 (Run 25) | ~88,978 | -- | ~1,225 annotations | ~13.8 | 0 | 5 | A | | |
| 485 | - | | 2026-05-11 (Run 26) | ~88,978 | -- | ~1,225 annotations | ~13.8 | 0 | 5 | A | | |
| 486 | - | ||
| 487 | - | --- | |
| 488 | - | ||
| 489 | - | See [audit_history.md](./audit_history.md) for full chronological audit log. |
| @@ -1,109 +0,0 @@ | |||
| 1 | - | # MNW Server AI Anti-Pattern Cleanup | |
| 2 | - | ||
| 3 | - | Audit of MNW server (Rust/Axum backend with HTMX frontend, PostgreSQL, Stripe Connect, S3) for silent error handling and AI-induced anti-patterns. | |
| 4 | - | ||
| 5 | - | **Summary:** 8 MEDIUM, 1 LOW. Zero HIGH. No dead code, no stubs, no string-typing, no `todo!()`/`unimplemented!()`. All 51 `#[allow(dead_code)]` justified (Askama templates, sqlx FromRow, deserialization structs). All 37 `.expect()` justified (initialization, HMAC, static HeaderValues). Zero production `.unwrap()` panics (all 305 in test code or safe `unwrap_or` fallbacks). | |
| 6 | - | ||
| 7 | - | ## Fixes (MEDIUM) | |
| 8 | - | ||
| 9 | - | ### M1. Silent license key creation after free promo claim | |
| 10 | - | ||
| 11 | - | `src/routes/api/promo_codes.rs:427` — `let _ = db::license_keys::create_license_key(...)`. After a free promo code claim with `enable_license_keys` on the item, if the DB insert fails, the customer's claim succeeds but they don't receive a license key. They have no way to know or retry. The creator sees a claim but the buyer has no key. | |
| 12 | - | ||
| 13 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::error!` including item_id, user_id. | |
| 14 | - | ||
| 15 | - | ### M2. Silent session update in AuthUser extractor | |
| 16 | - | ||
| 17 | - | `src/auth.rs:141` — `let _ = session.insert(USER_SESSION_KEY, user.clone()).await`. When the periodic DB refresh detects the user's state has changed (suspended, creator_tier, fan_plus, can_create_projects), the session update is silently dropped. The user continues with stale session data until the next successful refresh cycle. A suspended user may retain access. | |
| 18 | - | ||
| 19 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!` including user_id. | |
| 20 | - | ||
| 21 | - | ### M3. Silent build status updates in build runner | |
| 22 | - | ||
| 23 | - | `src/build_runner.rs:100,171,194,231` — Four `let _ = db::builds::update_build_status(...)`. When the final status update fails (Failed or Succeeded), the build record stays in Running/Pending state indefinitely. Note: the mark-as-Running update (line 125) is already correctly handled with `if let Err(e)` + return. | |
| 24 | - | ||
| 25 | - | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::error!` including build_id and target status. Keep the same control flow (all are at function return points). | |
| 26 | - | ||
| 27 | - | ### M4. Silent build-release association | |
| 28 | - | ||
| 29 | - | `src/build_runner.rs:223` — `let _ = db::builds::set_build_release(...)`. After a successful build with artifacts uploaded and OTA release created, the link between the build record and the release is lost. The dashboard can't show which release came from this build. | |
| 30 | - | ||
| 31 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::error!` including build_id and release_id. | |
| 32 | - | ||
| 33 | - | ### M5. Silent issue tracker updates on git push | |
| 34 | - | ||
| 35 | - | `src/routes/git_issues/push_refs.rs:187,194,200,207,217` — Five `let _ = db::issues::{update_issue_status,create_comment}(...)`. When a developer pushes commits referencing issues (e.g., "Fixes #3", "Reopens #5", "Refs #7"), the status change and reference comment are silently dropped. The developer believes the push updated the issue. | |
| 36 | - | ||
| 37 | - | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including issue_id and the action (close/reopen/reference). | |
| 38 | - | ||
| 39 | - | ### M6. Silent Stripe audit log failures | |
| 40 | - | ||
| 41 | - | 16 instances across `src/routes/stripe/webhook/{checkout.rs,billing.rs,subscriptions.rs}` — `let _ = db::subscriptions::log_subscription_event(...)`. Subscription lifecycle events (purchases, renewals, cancellations, trial starts/ends, payment failures, refunds) are lost when the audit log insert fails. When investigating billing disputes or debugging subscription issues, there's no record of what happened. | |
| 42 | - | ||
| 43 | - | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including the event type. Keep fire-and-forget behavior (the webhook handler must return 200 to Stripe regardless). | |
| 44 | - | ||
| 45 | - | ### M7. Silent onboarding step advancement | |
| 46 | - | ||
| 47 | - | `src/scheduler.rs:193,204,217,228` and `src/routes/pages/public/join_wizard.rs:215` — Five `let _ = db::users::{advance_onboarding_step,batch_advance_onboarding_step}(...)`. If the step advancement fails, users remain at the old step and receive the same onboarding email repeatedly on each scheduler tick. | |
| 48 | - | ||
| 49 | - | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including user_id(s) and step number. | |
| 50 | - | ||
| 51 | - | ### M8. Silent cache invalidation after upload | |
| 52 | - | ||
| 53 | - | `src/routes/storage.rs:360,902` and `src/routes/api/internal.rs:478,711,747` — Five `let _ = db::projects::bump_cache_generation(...)`. After a file upload confirmation or update, the project's cache generation isn't bumped. Browsers and CDN may serve stale content. Users upload new files but see old versions. | |
| 54 | - | ||
| 55 | - | **Fix:** Replace each `let _ =` with `if let Err(e)` + `tracing::warn!` including project_id. | |
| 56 | - | ||
| 57 | - | ## Fixes (LOW) | |
| 58 | - | ||
| 59 | - | ### L1. Silent session tracking deletion on logout | |
| 60 | - | ||
| 61 | - | `src/routes/auth.rs:255` — `let _ = db::sessions::delete_session_by_id(...)`. If the tracking row deletion fails during logout, the row persists in the sessions table. No security impact (the session itself is separately flushed), just stale data. | |
| 62 | - | ||
| 63 | - | **Fix:** Replace `let _ =` with `if let Err(e)` + `tracing::warn!` including tracking_id. | |
| 64 | - | ||
| 65 | - | ## Skipping (intentional design) | |
| 66 | - | ||
| 67 | - | **Session flush on invalid session (auth.rs:123, oauth.rs:242, email_actions.rs:610):** `let _ = session.flush().await` fires before returning Unauthorized or after account deletion. If the flush fails, the session store cleans up expired sessions independently. Benign fire-and-forget. | |
| 68 | - | ||
| 69 | - | **Build log append (build_runner.rs:139,148,160,306,328,364):** `let _ = append_log_bounded(...)` writes build log lines. Log lines are diagnostic, not state. Missing a line doesn't affect build correctness. The function itself already has proper error handling internally. | |
| 70 | - | ||
| 71 | - | **Build cleanup (build_runner.rs:319,323,343,352):** `let _ = run_ssh_command(host, "rm -rf ...")` and `let _ = tokio::fs::remove_file(...)`. Best-effort cleanup of temporary files on remote hosts and local filesystem. Failure means leftover temp files, not data corruption. | |
| 72 | - | ||
| 73 | - | **Invite redeemed notification (join_wizard.rs:156):** `let _ = email_client.send_invite_redeemed(...)` in a spawned task. Fire-and-forget notification to the inviter. Notification failure doesn't affect the new user's registration. | |
| 74 | - | ||
| 75 | - | **S3 object deletion after failed upload (routes/storage.rs:307,318,537,548,733,744,862,873 and routes/api/internal.rs:411,429):** `s3.delete_object(...).await.ok()` cleans up orphaned S3 objects when upload confirmation fails validation. Best-effort cleanup — orphaned objects waste storage but don't affect correctness. | |
| 76 | - | ||
| 77 | - | **Stripe webhook header parsing (webhook/mod.rs:34, webhook_v2.rs:33):** `.and_then(|v| v.to_str().ok())` on `Stripe-Signature` header. Returns 400 Bad Request on next line if None. Correct. | |
| 78 | - | ||
| 79 | - | **CSRF token on Stripe Connect page (stripe/connect.rs:26):** `csrf::get_or_create_token(&session).await.ok()` — best-effort CSRF for the onboarding redirect page. The actual Stripe Connect flow has its own security. | |
| 80 | - | ||
| 81 | - | **Session remove for cleanup (routes/auth.rs:373, two_factor.rs:108-135, dashboard/main.rs:149,381, api/passkeys.rs:94, api/users/sessions.rs:42,61):** All `session.remove::<T>(key).await.ok()` patterns are cleaning up temporary session state after it's been consumed. If removal fails, the stale key expires naturally with the session. | |
| 82 | - | ||
| 83 | - | **Session insert for advisory warnings (api/users/profile.rs:144, email_actions.rs:250):** `session.insert("password_warning", ...).await.ok()` — advisory breach notification that displays once. Non-blocking, non-critical. | |
| 84 | - | ||
| 85 | - | **Session get for tracking ID (api/users/profile.rs:155):** `session.get("tracking_id").ok().flatten()` — getting current session tracking ID to revoke other sessions after password change. If this fails, other sessions aren't revoked, but the password IS changed (security improvement still applied). | |
| 86 | - | ||
| 87 | - | **Header value parsing (~15 instances across routes):** `.and_then(|v| v.to_str().ok())` on HTTP headers (HX-Target, Authorization, etc.). Standard Rust pattern for non-ASCII-safe header values. Fallback is correct (returns None, handled by caller). | |
| 88 | - | ||
| 89 | - | **Query/form parameter parsing (~20 instances across routes):** `.and_then(|s| s.parse().ok())`, `.filter_map(|v| v.parse().ok())` on user input from query strings and form fields. Correct — invalid input defaults to None, handled by caller. | |
| 90 | - | ||
| 91 | - | **User lookup for notification display (~6 instances):** `db::users::get_user_by_id().await.ok().flatten()` in checkout, follows, and library routes. Used to get display names for notification emails or receipt details. If lookup fails, the notification still sends with a generic name or is skipped. Non-critical. | |
| 92 | - | ||
| 93 | - | **Date parsing for display (landing.rs:198, dashboard/tabs.rs:427):** `chrono::DateTime::parse_from_rfc3339(s).ok()` — display-only date parsing. Invalid dates are skipped in UI rendering. | |
| 94 | - | ||
| 95 | - | **Health check HTTP calls (health.rs:249,255,477,478):** `.ok()` on reqwest calls and JSON parsing for the public health dashboard. Display-only data — failure shows "unavailable" status, which is correct. | |
| 96 | - | ||
| 97 | - | **Discover page pagination parsing (discover.rs:90,122,292):** `.and_then(|s| s.parse().ok())` on page/offset parameters. Falls back to default pagination. Correct. | |
| 98 | - | ||
| 99 | - | **`#[allow(dead_code)]` (51 instances):** 14 in `types/mod.rs` (Askama template struct fields), 16 in `db/models.rs` (sqlx FromRow), 10 in `templates/{dashboard,public,partials}.rs` (Askama), 6 in `routes/pages/public/health.rs` (deserialization + display), 1 in `db/patches.rs` (forward-use query), 1 in `db/monitor.rs` (DB snapshot), 1 in `routes/pages/dashboard/mod.rs` (query string deserialization). All justified. | |
| 100 | - | ||
| 101 | - | **`.expect()` (37 instances):** 10 in `main.rs` (initialization), 9 in `email/tokens.rs` (HMAC-SHA256, mathematically infallible), 4 in `helpers.rs` (HMAC + rate limiter), 3 in `mt_client.rs` (HTTP client + HMAC + serde), 6 in route files (static HeaderValue construction), 1 in `routes/api/totp.rs` (HMAC), 1 in `bin/mnw-admin.rs` (CLI env var). All infallible or process-fatal startup conditions. | |
| 102 | - | ||
| 103 | - | **Process exit code fallback (build_runner.rs:397,433):** `.unwrap_or(-1)` on `output.status.code()`. Returns -1 when process was killed by signal (no exit code). Correct. | |
| 104 | - | ||
| 105 | - | ## Verification | |
| 106 | - | ||
| 107 | - | ```sh | |
| 108 | - | cd ~/Code/MNW && cargo check && cargo test | |
| 109 | - | ``` |
| @@ -1,258 +0,0 @@ | |||
| 1 | - | # Content Seed — Product Configuration & Copy | |
| 2 | - | ||
| 3 | - | Pre-generated content for seeding MNW with the three apps. Each section maps to MNW form fields. | |
| 4 | - | ||
| 5 | - | ## Creator Profile | |
| 6 | - | ||
| 7 | - | - **Display Name:** Max Justus | |
| 8 | - | - **Bio:** Independent developer building tools for productivity, reading, and music production. Everything source-available under PolyForm Noncommercial. | |
| 9 | - | ||
| 10 | - | ## Creator Tier | |
| 11 | - | ||
| 12 | - | Small Files ($20/mo) — software downloads, plugins, binaries. | |
| 13 | - | ||
| 14 | - | --- | |
| 15 | - | ||
| 16 | - | ## Project 1: GoingsOn | |
| 17 | - | ||
| 18 | - | ### Project Fields | |
| 19 | - | - **Title:** GoingsOn | |
| 20 | - | - **Slug:** goingson | |
| 21 | - | - **Type:** software | |
| 22 | - | - **Description:** | |
| 23 | - | ||
| 24 | - | A desktop productivity app that puts tasks, email, calendar, and contacts in one place. Built with Rust and Tauri for macOS (Windows and Linux planned). | |
| 25 | - | ||
| 26 | - | GoingsOn is opinionated about simplicity. No AI suggestions, no smart inferences, no engagement tricks. You manage your work, the app stays out of the way. | |
| 27 | - | ||
| 28 | - | Features include subtasks, annotations, snooze and waiting states, recurring tasks, day planning, weekly reviews, saved views, bulk operations, and 16 visual themes. Email is built in (JMAP), not bolted on. | |
| 29 | - | ||
| 30 | - | Fully functional offline. Optional cloud sync keeps your data in sync across machines, encrypted end-to-end so the server never sees your plaintext data. | |
| 31 | - | ||
| 32 | - | Source available at makenot.work/git under PolyForm Noncommercial 1.0.0. | |
| 33 | - | ||
| 34 | - | ### Content Item: App Download | |
| 35 | - | - **Title:** GoingsOn for macOS | |
| 36 | - | - **Item Type:** digital | |
| 37 | - | - **Price:** 0 (free) | |
| 38 | - | - **Description:** | |
| 39 | - | ||
| 40 | - | Signed and notarized DMG for macOS. Includes auto-update — you'll be notified when new versions are available. | |
| 41 | - | ||
| 42 | - | Works standalone with no account required. All data stored locally in SQLite. | |
| 43 | - | ||
| 44 | - | ### Subscription Tier: Cloud Sync | |
| 45 | - | - **Name:** Cloud Sync | |
| 46 | - | - **Price:** 300 ($3/mo) | |
| 47 | - | - **Description:** | |
| 48 | - | ||
| 49 | - | Sync your tasks, projects, events, contacts, and emails across multiple machines. End-to-end encrypted with XChaCha20-Poly1305 — the server stores only encrypted blobs. | |
| 50 | - | ||
| 51 | - | Includes device management (view and revoke connected devices), automatic conflict resolution, and over-the-air app updates. | |
| 52 | - | ||
| 53 | - | Requires a free Makenot.work account. | |
| 54 | - | ||
| 55 | - | ### Tags | |
| 56 | - | - productivity, tasks, email, calendar, macos, desktop, rust | |
| 57 | - | ||
| 58 | - | ### Blog Post: Launch | |
| 59 | - | - **Title:** GoingsOn is available | |
| 60 | - | - **Slug:** goingson-available | |
| 61 | - | - **Body:** | |
| 62 | - | ||
| 63 | - | GoingsOn is now available as a free download for macOS. | |
| 64 | - | ||
| 65 | - | This has been a long build. The goal was straightforward: one app for tasks, email, calendar, and contacts that works offline, respects your data, and doesn't try to be clever. | |
| 66 | - | ||
| 67 | - | What's here today: | |
| 68 | - | ||
| 69 | - | - Full task management with subtasks, annotations, snooze, waiting states, and recurrence | |
| 70 | - | - Day planning and weekly review workflows | |
| 71 | - | - Built-in email (JMAP) with contacts | |
| 72 | - | - 16 visual themes | |
| 73 | - | - Local-first SQLite storage | |
| 74 | - | - Optional cloud sync with end-to-end encryption ($3/mo) | |
| 75 | - | ||
| 76 | - | The source code is available on this site under PolyForm Noncommercial. Builds are signed and notarized for macOS. Auto-update is built in. | |
| 77 | - | ||
| 78 | - | Windows and Linux builds are planned. iOS is built but waiting on TestFlight testing. | |
| 79 | - | ||
| 80 | - | If you run into problems, file an issue on the repo or email support@makenot.work. | |
| 81 | - | ||
| 82 | - | --- | |
| 83 | - | ||
| 84 | - | ## Project 2: audiofiles | |
| 85 | - | ||
| 86 | - | ### Project Fields | |
| 87 | - | - **Title:** audiofiles | |
| 88 | - | - **Slug:** audiofiles | |
| 89 | - | - **Type:** software | |
| 90 | - | - **Description:** | |
| 91 | - | ||
| 92 | - | A sample manager for music producers. Organize, search, preview, and tag your sample library across any folder structure. Available as a CLAP plugin, VST3 plugin, and standalone app. | |
| 93 | - | ||
| 94 | - | audiofiles uses content-addressed storage — files are identified by their content hash, not their path. Move, rename, or reorganize your samples however you want. Duplicates are detected automatically. Nothing breaks. | |
| 95 | - | ||
| 96 | - | The virtual filesystem lets you build curated collections on top of your existing folders without copying files. Tag with your own vocabulary. Search across everything. Preview inline with transport controls. | |
| 97 | - | ||
| 98 | - | Includes a Rhai scripting engine for custom analysis pipelines and tag suggestion rules. 16 visual themes. Runs on macOS (Windows and Linux planned). | |
| 99 | - | ||
| 100 | - | Source available at makenot.work/git under PolyForm Noncommercial 1.0.0. | |
| 101 | - | ||
| 102 | - | ### Content Item: Plugin Bundle | |
| 103 | - | - **Title:** audiofiles — CLAP + VST3 + Standalone | |
| 104 | - | - **Item Type:** plugin | |
| 105 | - | - **Price:** 2900 ($29) | |
| 106 | - | - **Description:** | |
| 107 | - | ||
| 108 | - | All three formats in one download. Signed and notarized for macOS. | |
| 109 | - | ||
| 110 | - | - CLAP plugin (.clap) | |
| 111 | - | - VST3 plugin (.vst3) | |
| 112 | - | - Standalone application (.app) | |
| 113 | - | ||
| 114 | - | Install whichever formats your DAW supports. The standalone app works independently for library management outside your DAW. | |
| 115 | - | ||
| 116 | - | Includes over-the-air updates — you'll be notified when new versions are available. | |
| 117 | - | ||
| 118 | - | ### Tags | |
| 119 | - | - audio, samples, plugin, clap, vst3, music-production, macos, daw | |
| 120 | - | ||
| 121 | - | ### Blog Post: Launch | |
| 122 | - | - **Title:** audiofiles is available | |
| 123 | - | - **Slug:** audiofiles-available | |
| 124 | - | - **Body:** | |
| 125 | - | ||
| 126 | - | audiofiles is now available for macOS as a CLAP plugin, VST3 plugin, and standalone app. | |
| 127 | - | ||
| 128 | - | The pitch is simple: manage your sample library without fighting your folder structure. audiofiles uses content-addressed storage, so files are tracked by what they contain, not where they live. Reorganize freely. Duplicates surface automatically. | |
| 129 | - | ||
| 130 | - | The virtual filesystem lets you build collections without copying anything. Tag with whatever vocabulary makes sense to you. Preview with transport controls. Search across your entire library. | |
| 131 | - | ||
| 132 | - | For the power users: a built-in Rhai scripting engine lets you write custom analysis pipelines and tag suggestion rules. | |
| 133 | - | ||
| 134 | - | Available in CLAP, VST3, and standalone formats. All signed and notarized. Auto-updates included. | |
| 135 | - | ||
| 136 | - | Source code is on this site under PolyForm Noncommercial. Issues and feedback welcome. | |
| 137 | - | ||
| 138 | - | --- | |
| 139 | - | ||
| 140 | - | ## Project 3: Balanced Breakfast | |
| 141 | - | ||
| 142 | - | ### Project Fields | |
| 143 | - | - **Title:** Balanced Breakfast | |
| 144 | - | - **Slug:** balanced-breakfast | |
| 145 | - | - **Type:** software | |
| 146 | - | - **Description:** | |
| 147 | - | ||
| 148 | - | A desktop feed reader that pulls together RSS, Atom, and JSON feeds in one clean interface. Built with Rust and Tauri for macOS (Windows and Linux planned). | |
| 149 | - | ||
| 150 | - | Balanced Breakfast is a reading tool, not a social platform. No algorithmic timeline, no engagement metrics, no read-tracking. Subscribe to feeds, read them, move on. | |
| 151 | - | ||
| 152 | - | Supports feed autodiscovery, configurable refresh intervals, read/unread tracking, full-text search, and 16 visual themes. Plugin system for extending functionality. | |
| 153 | - | ||
| 154 | - | Data stored locally. Optional cloud sync ($3/mo via GoingsOn Cloud Sync) keeps your subscriptions and read state in sync across machines, end-to-end encrypted. | |
| 155 | - | ||
| 156 | - | Source available at makenot.work/git under PolyForm Noncommercial 1.0.0. | |
| 157 | - | ||
| 158 | - | ### Content Item: App Download | |
| 159 | - | - **Title:** Balanced Breakfast for macOS | |
| 160 | - | - **Item Type:** digital | |
| 161 | - | - **Price:** 0 (free, PWYW) | |
| 162 | - | - **PWYW Enabled:** true | |
| 163 | - | - **PWYW Min Cents:** 0 | |
| 164 | - | - **Description:** | |
| 165 | - | ||
| 166 | - | Signed and notarized DMG for macOS. Includes auto-update. | |
| 167 | - | ||
| 168 | - | Free to download and use. Pay what you want if you'd like to support development. | |
| 169 | - | ||
| 170 | - | ### Tags | |
| 171 | - | - rss, feeds, reader, macos, desktop, rust | |
| 172 | - | ||
| 173 | - | ### Blog Post: Launch | |
| 174 | - | - **Title:** Balanced Breakfast is available | |
| 175 | - | - **Slug:** balanced-breakfast-available | |
| 176 | - | - **Body:** | |
| 177 | - | ||
| 178 | - | Balanced Breakfast is now available as a free download for macOS. | |
| 179 | - | ||
| 180 | - | It's a feed reader. RSS, Atom, JSON Feed — subscribe and read. No algorithmic sorting, no engagement tracking, no social features. | |
| 181 | - | ||
| 182 | - | What's included: | |
| 183 | - | ||
| 184 | - | - Feed autodiscovery (paste a URL, it finds the feed) | |
| 185 | - | - Configurable refresh intervals per feed | |
| 186 | - | - Read/unread tracking | |
| 187 | - | - Full-text search across all articles | |
| 188 | - | - Plugin system for custom functionality | |
| 189 | - | - 16 visual themes | |
| 190 | - | - Local SQLite storage | |
| 191 | - | ||
| 192 | - | The app is free and pay-what-you-want. Cloud sync is available through GoingsOn Cloud Sync if you use both apps. | |
| 193 | - | ||
| 194 | - | Source code is on this site under PolyForm Noncommercial. | |
| 195 | - | ||
| 196 | - | --- | |
| 197 | - | ||
| 198 | - | ## Project 4: Makenot.work (Platform Changelog) | |
| 199 | - | ||
| 200 | - | ### Project Fields | |
| 201 | - | - **Title:** Makenot.work | |
| 202 | - | - **Slug:** makenotwork | |
| 203 | - | - **Type:** blog | |
| 204 | - | - **Description:** | |
| 205 | - | ||
| 206 | - | Development log for the Makenot.work platform. Release notes, technical decisions, and the occasional behind-the-scenes post. | |
| 207 | - | ||
| 208 | - | ### Blog Post: Welcome | |
| 209 | - | - **Title:** What this is | |
| 210 | - | - **Slug:** what-this-is | |
| 211 | - | - **Body:** | |
| 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-60/mo depending on what you're hosting). | |
| 214 | - | ||
| 215 | - | No percentage cuts on sales. No lock-in. Full data export anytime. Month-to-month, cancel whenever. | |
| 216 | - | ||
| 217 | - | The platform is source-available under PolyForm Noncommercial. The code is browsable on this site. | |
| 218 | - | ||
| 219 | - | This project page will serve as the development log — release notes, decisions, and updates as things progress. | |
| 220 | - | ||
| 221 | - | ### Tags | |
| 222 | - | - platform, changelog, development | |
| 223 | - | ||
| 224 | - | --- | |
| 225 | - | ||
| 226 | - | ## Collection | |
| 227 | - | ||
| 228 | - | - **Title:** All Apps | |
| 229 | - | - **Description:** GoingsOn, audiofiles, and Balanced Breakfast — the full suite. | |
| 230 | - | - **Items:** GoingsOn for macOS, audiofiles Bundle, Balanced Breakfast for macOS | |
| 231 | - | ||
| 232 | - | --- | |
| 233 | - | ||
| 234 | - | ## Custom Links | |
| 235 | - | - Source code: /git/max/ (relative link to git browser) | |
| 236 | - | - Contact: support@makenot.work | |
| 237 | - | ||
| 238 | - | --- | |
| 239 | - | ||
| 240 | - | ## Feature Coverage Matrix | |
| 241 | - | ||
| 242 | - | | Feature | GO | AF | BB | MNW Blog | | |
| 243 | - | |---------|----|----|----|----| | |
| 244 | - | | Free download | x | | x | | | |
| 245 | - | | PWYW | | | x | | | |
| 246 | - | | One-time purchase | | x | | | | |
| 247 | - | | Subscription | x | | | | | |
| 248 | - | | Text content | | | | x | | |
| 249 | - | | File upload (binary) | x | x | x | | | |
| 250 | - | | Blog posts | x | x | x | x | | |
| 251 | - | | Tags | x | x | x | x | | |
| 252 | - | | Collection | x | x | x | | | |
| 253 | - | | Custom links | x | x | x | | | |
| 254 | - | | RSS feed | x | x | x | x | | |
| 255 | - | | Git source browser | x | x | x | x | | |
| 256 | - | | OTA updates | x | x | x | | | |
| 257 | - | | License keys | | x | | | | |
| 258 | - | | Promo/discount codes | | x | | | |
| @@ -1,54 +0,0 @@ | |||
| 1 | - | # MNW Server — Build & Deploy | |
| 2 | - | ||
| 3 | - | See `_meta/docs/deploy.md` for shared infrastructure (machines, git remotes). | |
| 4 | - | ||
| 5 | - | The server is NOT collected into `~/Dist`. It deploys directly to production via `deploy/deploy.sh`. | |
| 6 | - | ||
| 7 | - | ## Target | |
| 8 | - | ||
| 9 | - | | Platform | Artifact | Build Machine | Destination | | |
| 10 | - | |----------|----------|---------------|-------------| | |
| 11 | - | | Linux x86_64 | Binary | local (macbook, cross-compile via cargo-zigbuild) | alpha-west-1 (Hetzner) | | |
| 12 | - | ||
| 13 | - | ## Prerequisites | |
| 14 | - | ||
| 15 | - | - Rust 1.95+, `cargo-zigbuild`, `zig` (via Homebrew) | |
| 16 | - | - `rustup target add x86_64-unknown-linux-gnu` | |
| 17 | - | - SSH access: root@100.120.174.96 port 2200 | |
| 18 | - | ||
| 19 | - | ## Deploy Commands | |
| 20 | - | ||
| 21 | - | ```bash | |
| 22 | - | cd ~/Code/MNW/server | |
| 23 | - | ||
| 24 | - | # Full deploy (build + config + static + docs + restart): | |
| 25 | - | ./deploy/deploy.sh | |
| 26 | - | ||
| 27 | - | # Quick deploy (binary + restart only): | |
| 28 | - | ./deploy/deploy.sh --quick | |
| 29 | - | ||
| 30 | - | # Config only (Caddyfile, systemd, error pages): | |
| 31 | - | ./deploy/deploy.sh --config | |
| 32 | - | ``` | |
| 33 | - | ||
| 34 | - | ## Build Gate (run before deploying) | |
| 35 | - | ||
| 36 | - | ```bash | |
| 37 | - | ssh pop-os "source ~/.cargo/env && cd ~/Code/MNW && git checkout -- . && git pull && bash build-gate.sh" | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | Runs: `cargo check` → `cargo clippy -D warnings` → `cargo test --lib` → `cargo test --test integration`. Deploy blocked if non-flaky tests fail. | |
| 41 | - | ||
| 42 | - | Note: `git checkout -- .` needed because local `.env` (DATABASE_URL for sqlx) isn't committed. | |
| 43 | - | ||
| 44 | - | ## Version Bumping | |
| 45 | - | ||
| 46 | - | **Always ask the user what version to set before deploying.** Edit `server/Cargo.toml`, commit, push, then deploy. | |
| 47 | - | ||
| 48 | - | ## Notes | |
| 49 | - | ||
| 50 | - | - Deploy sends 30s restart warning to connected users before stopping. | |
| 51 | - | - Uploads: binary, Caddyfile, systemd units, error pages, static assets, minified CSS, docs, rustdoc. | |
| 52 | - | - Verify: script checks HTTP 200 from running service. | |
| 53 | - | - Migrations run automatically on server startup. | |
| 54 | - | - Rollback: see `docs/rollback.md`. Previous binary kept at `/opt/makenotwork/makenotwork.prev`. |
| @@ -1,162 +0,0 @@ | |||
| 1 | - | # Feature-to-Docs Coverage Matrix | |
| 2 | - | ||
| 3 | - | Three documentation levels for every feature. Source: codebase harness DB (727 modules, 638 key files, 49 docs). | |
| 4 | - | ||
| 5 | - | **Legend:** Y = covered, P = partial, N = missing, -- = not applicable | |
| 6 | - | ||
| 7 | - | ## MNW Server (237 modules, 66,815 LOC) | |
| 8 | - | ||
| 9 | - | ### Creator Platform Features | |
| 10 | - | ||
| 11 | - | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 12 | - | |---------|-------------|:---:|:---:|:---:| | |
| 13 | - | | **Auth (login/signup/sessions)** | routes/auth.rs, auth.rs, db/sessions.rs, db/auth.rs | Y | -- | guide/getting-started | | |
| 14 | - | | **Passkeys (WebAuthn)** | routes/api/passkeys.rs, db/passkeys.rs | Y | -- | guide/security | | |
| 15 | - | | **TOTP (2FA)** | routes/api/totp.rs, db/totp.rs | Y | -- | guide/security | | |
| 16 | - | | **User profiles** | routes/api/users/profile.rs, db/users.rs | Y | -- | guide/profile | | |
| 17 | - | | **Projects** | routes/api/projects.rs, db/projects.rs | Y | -- | guide/projects | | |
| 18 | - | | **Items (CRUD)** | routes/api/items/*.rs, db/items.rs | Y | -- | guide/items | | |
| 19 | - | | **Item sections (tabbed content)** | routes/api/items/sections.rs, db/item_sections.rs | Y | -- | N | | |
| 20 | - | | **Chapters** | routes/api/items/chapters.rs, db/chapters.rs | Y | -- | N | | |
| 21 | - | | **Bundles** | routes/api/items/bundles.rs, db/bundles.rs | Y | -- | N | | |
| 22 | - | | **Versions (file releases)** | routes/api/items/versions.rs, db/versions.rs | Y | -- | guide/files | | |
| 23 | - | | **Tags (hierarchical)** | routes/api/tags.rs, db/tags.rs | Y | -- | guide/tags | | |
| 24 | - | | **Collections** | routes/api/collections.rs, db/collections.rs | Y | -- | guide/collections | | |
| 25 | - | | **Blog** | routes/api/blog.rs, db/blog_posts.rs, pages/blog.rs | Y | -- | guide/blog | | |
| 26 | - | | **Custom links** | routes/api/links.rs, db/custom_links.rs | Y | -- | N | | |
| 27 | - | | **Labels** | routes/api/labels.rs, db/labels.rs | Y | -- | N | | |
| 28 | - | | **Categories** | routes/api/categories.rs, db/categories.rs | Y | -- | N | | |
| 29 | - | | **Follows** | routes/api/follows.rs, db/follows.rs | Y | -- | N | | |
| 30 | - | | **Discover** | pages/public/discover.rs, db/discover.rs | Y | -- | N | | |
| 31 | - | | **RSS feeds** | rss.rs, pages/feeds.rs | Y | -- | guide/rss | | |
| 32 | - | ||
| 33 | - | ### Payments & Commerce | |
| 34 | - | ||
| 35 | - | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 36 | - | |---------|-------------|:---:|:---:|:---:| | |
| 37 | - | | **Shopping cart** | routes/api/cart.rs, routes/stripe/checkout/cart.rs, db/cart.rs | Y | -- | guide/cart | | |
| 38 | - | | **Wishlist** | routes/api/wishlists.rs, db/wishlists.rs | Y | -- | guide/wishlist | | |
| 39 | - | | **Creator pause** | routes/api/users/profile.rs, db/users.rs | Y | -- | guide/creator-pause | | |
| 40 | - | | **Stripe Connect** | routes/stripe/*.rs, payments/*.rs | Y | -- | guide/03-selling, guide/payouts | | |
| 41 | - | | **Checkout (items)** | routes/stripe/checkout/item.rs | Y | -- | guide/03-selling | | |
| 42 | - | | **Checkout (subscriptions)** | routes/stripe/checkout/subscriptions.rs | Y | -- | guide/tiers | | |
| 43 | - | | **Subscription tiers** | routes/api/subscriptions.rs, db/subscriptions.rs | Y | -- | guide/tiers | | |
| 44 | - | | **License keys** | routes/api/license_keys.rs, db/license_keys.rs | Y | -- | developer/license-keys | | |
| 45 | - | | **Promo codes** | routes/api/promo_codes.rs, db/promo_codes.rs | Y | -- | guide/promo-codes | | |
| 46 | - | | **Pricing models** | pricing.rs | Y | -- | guide/pricing | | |
| 47 | - | | **Transactions** | db/transactions.rs | Y | -- | guide/03-selling | | |
| 48 | - | | **Fan+ subscription** | db/fan_plus.rs | Y | -- | guide/fan-plus | | |
| 49 | - | | **Creator tiers (platform)** | db/creator_tiers.rs | Y | -- | guide/tiers | | |
| 50 | - | | **Analytics** | db/analytics.rs | Y | -- | guide/analytics | | |
| 51 | - | | **Exports (data portability)** | routes/api/exports.rs | Y | -- | guide/export | | |
| 52 | - | ||
| 53 | - | ### File Storage & Security | |
| 54 | - | ||
| 55 | - | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 56 | - | |---------|-------------|:---:|:---:|:---:| | |
| 57 | - | | **S3 storage (upload/download)** | storage.rs, routes/storage/*.rs | Y | -- | guide/files | | |
| 58 | - | | **Media files** | routes/storage/media.rs, db/media_files.rs | Y | -- | N | | |
| 59 | - | | **Content insertions** | routes/api/content_insertions.rs, db/content_insertions.rs | Y | -- | N | | |
| 60 | - | | **File scanning (ClamAV/YARA)** | scanning/*.rs | Y | -- | tech/content-protection | | |
| 61 | - | | **Content fingerprinting** | fingerprint/*.rs | Y | -- | tech/content-protection | | |
| 62 | - | | **Video upload/playback** | (migration 053, FileType::Video) | Y | -- | N | | |
| 63 | - | ||
| 64 | - | ### Git & Code | |
| 65 | - | ||
| 66 | - | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 67 | - | |---------|-------------|:---:|:---:|:---:| | |
| 68 | - | | **Git source browser** | routes/git/*.rs, git/*.rs | Y | -- | guide/git | | |
| 69 | - | | **Git issues (email-first)** | routes/git_issues/*.rs, db/issues.rs | Y | -- | N | | |
| 70 | - | | **SSH keys** | routes/api/ssh_keys.rs, db/ssh_keys.rs | Y | -- | N | | |
| 71 | - | | **Patches (git send-email)** | routes/postmark/patches.rs, db/patches.rs | Y | -- | N | | |
| 72 | - | | **Git repos** | db/git_repos.rs | Y | -- | N | | |
| 73 | - | ||
| 74 | - | ### Platform Infrastructure | |
| 75 | - | ||
| 76 | - | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 77 | - | |---------|-------------|:---:|:---:|:---:| | |
| 78 | - | | **OAuth2 PKCE provider** | routes/oauth.rs, db/oauth.rs | Y | -- | developer/oauth | | |
| 79 | - | | **Email system** | email/*.rs | Y | -- | N | | |
| 80 | - | | **CSRF protection** | csrf.rs | Y | -- | tech/security | | |
| 81 | - | | **Custom domains** | routes/custom_domain.rs, routes/api/domains.rs, db/custom_domains.rs | Y | -- | guide/custom-domains | | |
| 82 | - | | **Mailing lists** | db/mailing_lists.rs | Y | -- | guide/mailing-lists | | |
| 83 | - | | **Import system** | import/*.rs, routes/api/imports.rs, db/imports.rs | Y | -- | guide/migration | | |
| 84 | - | | **Waitlist** | routes/api/users/waitlist.rs, db/waitlist.rs | Y | -- | N | | |
| 85 | - | | **Invites** | routes/api/users/invites.rs, db/invites.rs | Y | -- | N | | |
| 86 | - | | **Reports/moderation** | routes/api/reports.rs, db/reports.rs | Y | -- | legal/moderation | | |
| 87 | - | | **Health monitoring** | pages/public/health.rs, monitor.rs, db/health.rs, db/monitor.rs | Y | -- | tech/monitoring | | |
| 88 | - | | **Scheduler** | scheduler.rs | Y | -- | N | | |
| 89 | - | | **Admin panel** | routes/admin/*.rs, bin/mnw-admin.rs | Y | -- | N | | |
| 90 | - | | **Onboarding emails** | email/templates/onboarding.rs | Y | -- | N | | |
| 91 | - | | **MT client integration** | mt_client.rs | Y | -- | support/forums | | |
| 92 | - | | **DocEngine rendering** | markdown.rs, routes/pages/public/docs.rs | Y | -- | N | | |
| 93 | - | ||
| 94 | - | ### SyncKit Server | |
| 95 | - | ||
| 96 | - | | Feature | Code Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 97 | - | |---------|-------------|:---:|:---:|:---:| | |
| 98 | - | | **SyncKit auth (JWT)** | synckit_auth.rs, routes/synckit/auth.rs | Y | -- | developer/synckit | | |
| 99 | - | | **SyncKit apps** | routes/synckit/apps.rs, db/synckit.rs | Y | -- | developer/synckit | | |
| 100 | - | | **SyncKit push/pull** | routes/synckit/sync.rs | Y | -- | developer/synckit | | |
| 101 | - | | **SyncKit blobs** | routes/synckit/blobs.rs | Y | -- | developer/synckit | | |
| 102 | - | | **SyncKit SSE push** | routes/synckit/subscribe.rs | Y | -- | developer/sse | | |
| 103 | - | | **OTA updates** | routes/ota.rs, db/ota.rs | Y | -- | developer/ota | | |
| 104 | - | | **Build pipeline** | routes/builds.rs, db/builds.rs, build_runner.rs | Y | -- | N | | |
| 105 | - | ||
| 106 | - | --- | |
| 107 | - | ||
| 108 | - | ## SyncKit Client SDK (13 modules, 5,431 LOC) | |
| 109 | - | ||
| 110 | - | | Feature | Code Module | Inline (//!) | Rustdoc | Public Doc | | |
| 111 | - | |---------|------------|:---:|:---:|:---:| | |
| 112 | - | | **Auth (OAuth PKCE + email/pw)** | client/auth.rs | Y | Y | developer/synckit | | |
| 113 | - | | **Push/pull sync** | client/sync.rs | Y | Y | developer/synckit | | |
| 114 | - | | **Blob upload/download** | client/blob.rs | Y | Y | developer/synckit | | |
| 115 | - | | **E2E encryption** | crypto.rs, client/encryption.rs | Y | Y | developer/synckit | | |
| 116 | - | | **OS keychain** | keystore.rs | Y | Y | N | | |
| 117 | - | | **Conflict resolution** | conflict.rs | Y | Y | N | | |
| 118 | - | | **SSE notifications** | client/subscribe.rs | Y | Y | N | | |
| 119 | - | | **Pull filtering** | types.rs (PullFilter) | Y | Y | N | | |
| 120 | - | ||
| 121 | - | --- | |
| 122 | - | ||
| 123 | - | ## Shared Libraries | |
| 124 | - | ||
| 125 | - | | Library | Modules | Inline (//!) | Rustdoc | Public Doc | | |
| 126 | - | |---------|---------|:---:|:---:|:---:| | |
| 127 | - | | **docengine** | 13 (2,259 LOC) | P (23%) | Y | N | | |
| 128 | - | | **tagtree** | 1 (1,871 LOC) | Y | Y | N | | |
| 129 | - | | **theme-common** | 1 (585 LOC) | Y | Y | N | | |
| 130 | - | | **s3-storage** | 1 (287 LOC) | Y | Y | N | | |
| 131 | - | ||
| 132 | - | --- | |
| 133 | - | ||
| 134 | - | ## Gap Summary | |
| 135 | - | ||
| 136 | - | ### Level 1: Inline Code Docs (//!) | |
| 137 | - | **Overall: 98% module coverage for MNW.** Nearly every module has `//!` headers. Scheduler and monitor expanded from single-line to full 4-5 line descriptions (2026-04-11). All other projects range from 23% (docengine) to 100% (tagtree). | |
| 138 | - | ||
| 139 | - | ### Level 2: Rustdoc | |
| 140 | - | **Generated for 5 crates: synckit-client, docengine, tagtree, s3-storage, theme-common.** Hosted at `/rustdoc`. s3-storage and theme-common added 2026-04-11. MNW server itself is not a library crate so rustdoc is less relevant — it's an application binary. | |
| 141 | - | ||
| 142 | - | ### Level 3: Public-Facing Docs (site-docs/) | |
| 143 | - | **56 docs across 7 categories.** 10 new docs added 2026-04-11. Strong coverage of core creator workflows plus developer integration points. | |
| 144 | - | ||
| 145 | - | **Remaining undocumented features:** | |
| 146 | - | ||
| 147 | - | | Feature | Priority | Reason | | |
| 148 | - | |---------|----------|--------| | |
| 149 | - | | Bundles | Medium | Commerce feature, no guide | | |
| 150 | - | | Item sections | Low | New feature, relatively self-explanatory UI | | |
| 151 | - | | Custom links | Low | Simple feature | | |
| 152 | - | | Build pipeline | Low | Internal/advanced developer feature | | |
| 153 | - | | Video playback | Low | New, deferred post-beta | | |
| 154 | - | | Chapters | Low | Self-explanatory UI | | |
| 155 | - | | Git issues (email-first) | Medium | Unique differentiator, no user-facing docs | | |
| 156 | - | | Discover page | Low | Self-explanatory UI | | |
| 157 | - | ||
| 158 | - | ### Cross-Reference Links (Level Linking) | |
| 159 | - | ||
| 160 | - | 11 source modules now have `//! See also:` lines linking to their corresponding public docs (added 2026-04-11). `developer/synckit.md` links to `/rustdoc/synckit_client/` (Level 3 -> Level 2). | |
| 161 | - | ||
| 162 | - | **Remaining:** Add "Source: `src/routes/...`" notes in public docs for developer-facing pages. |
| @@ -1,230 +0,0 @@ | |||
| 1 | - | # Filetype Transcoding Matrix | |
| 2 | - | ||
| 3 | - | Reference for MNW's ingest pipeline. Defines which formats can be safely converted, what saves space, and how conversions map to creator tiers. | |
| 4 | - | ||
| 5 | - | --- | |
| 6 | - | ||
| 7 | - | ## Design Principle | |
| 8 | - | ||
| 9 | - | - **BigFiles / Everything tiers**: Store original file. Lossless preservation is the feature. | |
| 10 | - | - **SmallFiles tier**: Store a high-quality transparent encode. Humans can't tell the difference. | |
| 11 | - | - Originals are always kept for BigFiles+. SmallFiles stores only the optimized version. | |
| 12 | - | - Never transcode lossy-to-lossy. Never transcode lossy-to-lossless (wastes space, no quality gain). | |
| 13 | - | ||
| 14 | - | --- | |
| 15 | - | ||
| 16 | - | ## Audio Formats | |
| 17 | - | ||
| 18 | - | ### Format Properties | |
| 19 | - | ||
| 20 | - | | Format | Type | MB/min (stereo) | Notes | | |
| 21 | - | |--------|------|-----------------|-------| | |
| 22 | - | | WAV | Uncompressed PCM | ~10 (16-bit/44.1k), ~33 (24-bit/96k) | Baseline reference | | |
| 23 | - | | AIFF | Uncompressed PCM | ~10 (16-bit/44.1k) | WAV equivalent (Apple, big-endian) | | |
| 24 | - | | FLAC | Lossless compressed | ~5-6 (16-bit/44.1k) | ~50% of WAV. Bit-perfect. Open, royalty-free | | |
| 25 | - | | ALAC | Lossless compressed | ~5-6 (16-bit/44.1k) | ~50% of WAV. 5-10% larger than FLAC | | |
| 26 | - | | MP3 | Lossy | ~2.3 (320k), ~1.4 (192k), ~0.9 (128k) | Oldest, most compatible | | |
| 27 | - | | AAC | Lossy | ~1.9 (256k), ~1.0 (128k) | ~20-30% more efficient than MP3 at same bitrate | | |
| 28 | - | | OGG Vorbis | Lossy | ~1.2 (160k), ~1.0 (128k) | Open-source. Comparable to AAC | | |
| 29 | - | | Opus | Lossy | ~1.0 (128k), ~0.7 (96k) | Best lossy codec. Transparent at 128 kbps | | |
| 30 | - | ||
| 31 | - | ### Transparency Thresholds (indistinguishable from lossless) | |
| 32 | - | ||
| 33 | - | | Codec | Transparent bitrate | Conservative bitrate | | |
| 34 | - | |-------|--------------------|--------------------| | |
| 35 | - | | Opus | 128 kbps | 160 kbps | | |
| 36 | - | | AAC | 192 kbps | 256 kbps | | |
| 37 | - | | OGG Vorbis | 160 kbps | 192 kbps | | |
| 38 | - | | MP3 | 256 kbps | 320 kbps | | |
| 39 | - | ||
| 40 | - | ### Audio Conversion Matrix | |
| 41 | - | ||
| 42 | - | Source format on the left, target on the top. Each cell describes what happens. | |
| 43 | - | ||
| 44 | - | | Source | FLAC | Opus 128 | AAC 256 | MP3 320 | | |
| 45 | - | |--------|------|----------|---------|---------| | |
| 46 | - | | **WAV** | Lossless, saves ~50% | Transparent, saves ~90% | Transparent, saves ~81% | Transparent, saves ~77% | | |
| 47 | - | | **AIFF** | Lossless, saves ~50% | Transparent, saves ~90% | Transparent, saves ~81% | Transparent, saves ~77% | | |
| 48 | - | | **FLAC** | No-op | Transparent, saves ~83% | Transparent, saves ~68% | Transparent, saves ~61% | | |
| 49 | - | | **ALAC** | Lossless, saves ~5-10% | Transparent, saves ~83% | Transparent, saves ~68% | Transparent, saves ~61% | | |
| 50 | - | | **MP3** | NEVER (bigger file, no quality gain) | NEVER (lossy-to-lossy) | NEVER (lossy-to-lossy) | No-op | | |
| 51 | - | | **AAC** | NEVER (bigger file, no quality gain) | NEVER (lossy-to-lossy) | No-op | NEVER (lossy-to-lossy) | | |
| 52 | - | | **OGG** | NEVER (bigger file, no quality gain) | NEVER (lossy-to-lossy) | NEVER (lossy-to-lossy) | NEVER (lossy-to-lossy) | | |
| 53 | - | | **Opus** | NEVER (bigger file, no quality gain) | No-op | NEVER (lossy-to-lossy) | NEVER (lossy-to-lossy) | | |
| 54 | - | ||
| 55 | - | ### Audio Decision Tree | |
| 56 | - | ||
| 57 | - | ``` | |
| 58 | - | Is the source lossless (WAV/AIFF/FLAC/ALAC)? | |
| 59 | - | YES: | |
| 60 | - | Tier = BigFiles/Everything? | |
| 61 | - | -> Store as FLAC (lossless, ~50% smaller than WAV/AIFF) | |
| 62 | - | -> Keep original alongside if WAV/AIFF (creator can re-download) | |
| 63 | - | Tier = SmallFiles? | |
| 64 | - | -> Store as Opus 128 kbps (transparent, ~90% smaller than WAV) | |
| 65 | - | -> Alternatively AAC 256 kbps for broader device compat | |
| 66 | - | NO (source is lossy MP3/AAC/OGG/Opus): | |
| 67 | - | -> Store as-is. No conversion possible without degradation. | |
| 68 | - | -> File is already small. No space savings available. | |
| 69 | - | ``` | |
| 70 | - | ||
| 71 | - | --- | |
| 72 | - | ||
| 73 | - | ## Video Formats | |
| 74 | - | ||
| 75 | - | ### Codec Properties | |
| 76 | - | ||
| 77 | - | | Codec | Container | MB/min (1080p, visually lossless) | Encoding speed | Browser support | | |
| 78 | - | |-------|-----------|-----------------------------------|----------------|-----------------| | |
| 79 | - | | H.264 | MP4 | 34-45 | Fast (baseline) | Universal | | |
| 80 | - | | H.265/HEVC | MP4 | 17-27 | 10-20x slower | Safari only (licensing) | | |
| 81 | - | | VP9 | WebM | 18-24 | 10-20x slower | Chrome, Firefox, Edge, Safari 14+ | | |
| 82 | - | | AV1 | MP4/WebM | 11-17 | 10-50x slower | Chrome, Firefox, Edge; Safari M3+ only | | |
| 83 | - | | ProRes | MOV | 100-200 | Fast (decode-optimized) | Safari only | | |
| 84 | - | ||
| 85 | - | ### Video Transparency Thresholds (CRF values, lower = better) | |
| 86 | - | ||
| 87 | - | | Codec | Transparent CRF | Notes | | |
| 88 | - | |-------|----------------|-------| | |
| 89 | - | | H.264 (x264) | CRF 18 | Well-established threshold | | |
| 90 | - | | H.265 (x265) | CRF 24 | Equivalent to x264 CRF 18 | | |
| 91 | - | | VP9 (libvpx) | CRF 18 | Range 0-63 | | |
| 92 | - | | AV1 (SVT-AV1) | CRF 23 | Range 0-63, equivalent to x264 CRF 19 | | |
| 93 | - | ||
| 94 | - | ### Compression Savings (same perceived quality, relative to H.264) | |
| 95 | - | ||
| 96 | - | | Codec | Size vs H.264 | Best for | | |
| 97 | - | |-------|---------------|----------| | |
| 98 | - | | H.265 | 40-50% smaller | Native apps, Apple ecosystem | | |
| 99 | - | | VP9 | 40-50% smaller | Web delivery (royalty-free) | | |
| 100 | - | | AV1 | 60-70% smaller | Web VOD (royalty-free, best compression) | | |
| 101 | - | ||
| 102 | - | ### Video Conversion Matrix | |
| 103 | - | ||
| 104 | - | | Source | H.264 CRF 18 | VP9 CRF 18 | AV1 CRF 23 | | |
| 105 | - | |--------|--------------|------------|-------------| | |
| 106 | - | | **MOV (ProRes)** | Transparent, saves ~80% | Transparent, saves ~85% | Transparent, saves ~90% | | |
| 107 | - | | **MOV (H.264)** | Remux only (no re-encode) | AVOID (lossy-to-lossy) | AVOID (lossy-to-lossy) | | |
| 108 | - | | **MP4 (H.264)** | No-op | AVOID (lossy-to-lossy) | AVOID (lossy-to-lossy) | | |
| 109 | - | | **MP4 (H.265)** | NEVER (bigger, lossy-to-lossy) | AVOID (lossy-to-lossy) | AVOID (lossy-to-lossy) | | |
| 110 | - | | **WebM (VP9)** | NEVER (bigger, lossy-to-lossy) | No-op | AVOID (lossy-to-lossy) | | |
| 111 | - | ||
| 112 | - | Note: "AVOID" means technically possible but always degrades quality. "NEVER" means it also increases file size. | |
| 113 | - | ||
| 114 | - | ### Video Decision Tree | |
| 115 | - | ||
| 116 | - | ``` | |
| 117 | - | Is the source lossless/production (ProRes, uncompressed)? | |
| 118 | - | YES: | |
| 119 | - | Tier = BigFiles/Everything? | |
| 120 | - | -> Re-encode to H.264 CRF 18 in MP4 (universal playback) | |
| 121 | - | -> Optionally also generate VP9/AV1 for web streaming | |
| 122 | - | -> Keep original? Only if tier storage allows it | |
| 123 | - | Tier = SmallFiles? | |
| 124 | - | -> Re-encode to VP9 CRF 18 in WebM (transparent, ~85% smaller) | |
| 125 | - | -> Or AV1 CRF 23 (best compression, but slow encode) | |
| 126 | - | NO (source is already lossy H.264/H.265/VP9): | |
| 127 | - | Can we remux without re-encoding? (e.g., H.264 in MOV -> H.264 in MP4) | |
| 128 | - | YES -> Remux (zero quality loss, trivial CPU cost) | |
| 129 | - | NO -> Store as-is. No conversion possible without degradation. | |
| 130 | - | ``` | |
| 131 | - | ||
| 132 | - | --- | |
| 133 | - | ||
| 134 | - | ## Image Formats (for completeness) | |
| 135 | - | ||
| 136 | - | | Source | WebP (lossy 90) | WebP (lossless) | Notes | | |
| 137 | - | |--------|----------------|-----------------|-------| | |
| 138 | - | | **PNG** | Transparent, saves ~70-90% | Saves ~25-35% | Lossless-to-lossless safe | | |
| 139 | - | | **JPEG** | Marginal savings, quality loss | Larger than original | Store as-is | | |
| 140 | - | | **WebP** | No-op | No-op | Already optimal | | |
| 141 | - | | **GIF** | Transparent, saves ~50-80% | Saves ~20-40% | Can also convert to WebP anim | | |
| 142 | - | ||
| 143 | - | Cover images and media library images are small (10 MB cap). Savings are negligible at platform scale. Not worth the complexity. | |
| 144 | - | ||
| 145 | - | --- | |
| 146 | - | ||
| 147 | - | ## Tier-Based Storage Strategy | |
| 148 | - | ||
| 149 | - | ### SmallFiles ($20/mo) — "Transparent quality, efficient storage" | |
| 150 | - | ||
| 151 | - | | Upload type | Stored as | Savings vs original | | |
| 152 | - | |-------------|-----------|-------------------| | |
| 153 | - | | WAV/AIFF audio | Opus 128 kbps (or AAC 256) | ~90% smaller | | |
| 154 | - | | FLAC/ALAC audio | Opus 128 kbps (or AAC 256) | ~83% smaller | | |
| 155 | - | | Lossy audio (MP3/AAC/OGG) | As-is | None (already small) | | |
| 156 | - | | ProRes/uncompressed video | VP9 CRF 18 | ~85% smaller | | |
| 157 | - | | Lossy video (H.264/VP9) | As-is | None | | |
| 158 | - | ||
| 159 | - | Fan downloads: the stored version (transparent quality). | |
| 160 | - | ||
| 161 | - | ### BigFiles ($30/mo) — "Lossless preservation" | |
| 162 | - | ||
| 163 | - | | Upload type | Stored as | Savings vs original | | |
| 164 | - | |-------------|-----------|-------------------| | |
| 165 | - | | WAV/AIFF audio | FLAC + original preserved | ~50% on the FLAC copy | | |
| 166 | - | | FLAC/ALAC audio | As-is (already lossless compressed) | None needed | | |
| 167 | - | | Lossy audio (MP3/AAC/OGG) | As-is | None | | |
| 168 | - | | ProRes/uncompressed video | H.264 CRF 18 + original preserved | ~80% on the delivery copy | | |
| 169 | - | | Lossy video (H.264/VP9) | As-is | None | | |
| 170 | - | ||
| 171 | - | Fan downloads: choice of original lossless or delivery format. | |
| 172 | - | ||
| 173 | - | ### Everything ($60/mo) — "Lossless + adaptive streaming" | |
| 174 | - | ||
| 175 | - | Same as BigFiles, plus: | |
| 176 | - | - Multiple quality tiers generated for adaptive streaming (HLS/DASH) | |
| 177 | - | - E.g., audio: FLAC + Opus 128 + Opus 64 | |
| 178 | - | - E.g., video: original + 1080p VP9 + 720p VP9 + 480p VP9 | |
| 179 | - | ||
| 180 | - | --- | |
| 181 | - | ||
| 182 | - | ## Storage Impact Estimates | |
| 183 | - | ||
| 184 | - | Per 4-minute stereo song (16-bit/44.1kHz source): | |
| 185 | - | ||
| 186 | - | | Strategy | Size | vs WAV original | | |
| 187 | - | |----------|------|-----------------| | |
| 188 | - | | Store WAV (current, no transcode) | ~40 MB | baseline | | |
| 189 | - | | Store FLAC (BigFiles ingest) | ~22 MB | 45% smaller | | |
| 190 | - | | Store Opus 128 (SmallFiles ingest) | ~3.8 MB | 90% smaller | | |
| 191 | - | | Store AAC 256 (SmallFiles alt) | ~7.7 MB | 81% smaller | | |
| 192 | - | ||
| 193 | - | Per 10-minute 1080p video (H.264 source at 8 Mbps): | |
| 194 | - | ||
| 195 | - | | Strategy | Size | Notes | | |
| 196 | - | |----------|------|-------| | |
| 197 | - | | Store as-is | ~600 MB | Already lossy, no savings | | |
| 198 | - | | Store ProRes original as VP9 | ~200 MB | Only saves if source is ProRes | | |
| 199 | - | ||
| 200 | - | Key insight: **the biggest wins are on lossless audio uploads (WAV/AIFF)**. Lossy uploads (which are likely the majority at launch) can't be improved. | |
| 201 | - | ||
| 202 | - | --- | |
| 203 | - | ||
| 204 | - | ## Implementation Notes | |
| 205 | - | ||
| 206 | - | ### Required tooling | |
| 207 | - | - `ffmpeg` / `ffprobe` on the server for all transcoding | |
| 208 | - | - Probe uploaded file on confirm to detect actual codec (M4A can be AAC or ALAC) | |
| 209 | - | - Transcoding is async (background job after upload confirm, not blocking) | |
| 210 | - | ||
| 211 | - | ### Probing before deciding | |
| 212 | - | - M4A: must probe to distinguish AAC (lossy, store as-is) from ALAC (lossless, can optimize) | |
| 213 | - | - MOV: must probe to distinguish ProRes (lossless, can optimize) from H.264 (lossy, store as-is) | |
| 214 | - | - OGG: almost always Vorbis (lossy), but could theoretically be FLAC-in-OGG | |
| 215 | - | ||
| 216 | - | ### What NOT to do | |
| 217 | - | - Never transcode lossy-to-lossy (MP3 -> AAC, H.264 -> VP9, etc.) | |
| 218 | - | - Never transcode lossy-to-lossless (MP3 -> FLAC) — bigger file, identical quality | |
| 219 | - | - Never re-encode at a higher bitrate thinking it improves quality (it doesn't) | |
| 220 | - | - Never delete the only copy before transcoding completes successfully | |
| 221 | - | ||
| 222 | - | --- | |
| 223 | - | ||
| 224 | - | ## Key Paths | |
| 225 | - | ||
| 226 | - | - Upload/validation logic: `src/storage.rs` | |
| 227 | - | - Upload endpoints: `src/routes/storage/uploads.rs` | |
| 228 | - | - Creator tier enforcement: `src/db/creator_tiers.rs` | |
| 229 | - | - Tier definitions: `src/db/enums.rs` (CreatorTier enum) | |
| 230 | - | - Phase 14D in todo: `docs/todo.md` |
| @@ -1,529 +0,0 @@ | |||
| 1 | - | # Human TODO | |
| 2 | - | ||
| 3 | - | Items requiring manual action, external accounts, legal engagement, design decisions, or physical testing. | |
| 4 | - | ||
| 5 | - | --- | |
| 6 | - | ||
| 7 | - | ## 🚨 LAUNCH BLOCKER — Stripe webhooks not delivering (discovered 2026-05-16) | |
| 8 | - | ||
| 9 | - | **This item is consolidated into the single-sitting Stripe Dashboard knockout session at `~/Code/_meta/human_todo.md` § Stripe Dashboard Knockout Session, Step 1.** Work it from there alongside the other dashboard tasks (founder pricing Price objects, orphan Connect cleanup, Customer Portal activation, etc.) so the dashboard nav is amortized across one sitting. | |
| 10 | - | ||
| 11 | - | Symptom for context: testaccount123 completed a $5 PWYW checkout for "audiofiles Desktop App" at 2026-05-16 21:27 UTC. Stripe redirected to `/stripe/success` (session `cs_live_a1o3Ky7bRCXbnKNUYrGFS1JSmBFYLCxQ8zEk6gtmYfCPsyGAiJJfLNFwGm`). Transaction row is stuck `status=pending`. No item appeared in their library. | |
| 12 | - | ||
| 13 | - | Root cause: Stripe is not delivering webhook events to `POST /stripe/webhook`. | |
| 14 | - | - `webhook_events` table: 0 rows | |
| 15 | - | - `processed_webhook_events`: 3 rows, **all from 2026-05-12** (webhooks worked previously) | |
| 16 | - | - No `/stripe/webhook` hits in prod logs for past 7 days | |
| 17 | - | - `STRIPE_WEBHOOK_SECRET` is set in `/opt/makenotwork/.env` — value not verified against dashboard | |
| 18 | - | ||
| 19 | - | ## External Blockers | |
| 20 | - | ||
| 21 | - | ### Business Formation (Make Creative, LLC) | |
| 22 | - | All complete — see `todo_done.md`. | |
| 23 | - | ||
| 24 | - | ### Platform Accounts | |
| 25 | - | ||
| 26 | - | | Blocker | Status | Blocks | | |
| 27 | - | |---------|--------|--------| | |
| 28 | - | | Microsoft Partner Center account | Blocked by Microsoft trust check — ref 715-123225, contact support | Windows Store distribution (optional) | | |
| 29 | - | | Windows code signing certificate | Not started (individual or traditional cert — Azure Trusted Signing requires 3yr history) | GO/BB/AF Windows builds | | |
| 30 | - | | OAuth Provider Registration (Fastmail) | Need to send registration info to partnerships@fastmailteam.com | GO Fastmail email OAuth | | |
| 31 | - | | Stripe Customer Portal activation | Consolidated into `_meta/human_todo.md` § Stripe Dashboard Knockout Session, Step 4. | Fan+ "Manage billing" button (deployed 2026-05-14 in MNW 0.5.18); Cancel/Resume work without this. | | |
| 32 | - | ||
| 33 | - | --- | |
| 34 | - | ||
| 35 | - | ## DIY Tier — Decisions Needed Before Implementation | |
| 36 | - | ||
| 37 | - | 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. | |
| 38 | - | ||
| 39 | - | - [ ] **Final price confirmation** — $12/yr modeled. Alternative: $10/yr (rounder pitch, ~$0.50/creator/yr loss). Pick one and commit publicly. | |
| 40 | - | - [ ] **Tier name** — "DIY," "Embed," "Self-Hosted," "Lite." Affects positioning vs. Basic. | |
| 41 | - | - [ ] **Stripe product/price IDs** — create $12/yr annual price in Stripe dashboard, capture into `CREATOR_TIER_DIY_PRICE_ID` env var (prod + staging). | |
| 42 | - | - [ ] **"Powered by MNW" attribution policy** — on by default + opt-out, or always-on. Affects marketing value vs. creator pushback. | |
| 43 | - | - [ ] **Country allow-list at signup** — match Stripe Connect Express supported countries. Confirm list with Stripe docs before launch. | |
| 44 | - | - [ ] **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. | |
| 45 | - | - [ ] **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. | |
| 46 | - | - [ ] **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. | |
| 47 | - | - [ ] **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. | |
| 48 | - | - [ ] **Office-hours commitment** — 1 hr/week recurring. Confirm time slot and whether it's Zoom/async/forum-thread. Calendar block before announcing. | |
| 49 | - | - [ ] **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. | |
| 50 | - | - [ ] **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. | |
| 51 | - | ||
| 52 | - | --- | |
| 53 | - | ||
| 54 | - | ## Feature Map & Manual Testing (94 features, 2026-05-02) | |
| 55 | - | ||
| 56 | - | Feature map generated from full codebase walk. Every user-facing feature enumerated. | |
| 57 | - | ||
| 58 | - | ### 1. Authentication & Account Security | |
| 59 | - | ||
| 60 | - | - [ ] **Sign up** — 5-step wizard (username, email, password, profile, complete) | |
| 61 | - | - [ ] Happy path: account created, verification email arrives | |
| 62 | - | - [ ] Breached password advisory warning (non-blocking) | |
| 63 | - | - [ ] Username validation (availability, format, 3-50 chars) | |
| 64 | - | - [ ] Invite code redemption grants instant creator access | |
| 65 | - | - [ ] Welcome email arrives | |
| 66 | - | - [ ] **Email verification** — click link in email | |
| 67 | - | - [ ] Happy path: email marked verified | |
| 68 | - | - [ ] Guest purchases auto-attached to account | |
| 69 | - | - [ ] Resend verification email works | |
| 70 | - | - [ ] **Email/password login** — username or email + password | |
| 71 | - | - [ ] Happy path: redirects to dashboard | |
| 72 | - | - [ ] "Remember me" checkbox persists session | |
| 73 | - | - [ ] Wrong password shows error (no user enumeration) | |
| 74 | - | - [ ] Account lockout after N failures | |
| 75 | - | - [ ] Lockout email with one-time login link arrives | |
| 76 | - | - [ ] **Passkey login (WebAuthn)** — passwordless discoverable credentials | |
| 77 | - | - [ ] Register passkey from dashboard | |
| 78 | - | - [ ] Login without username via passkey | |
| 79 | - | - [ ] Rename passkey | |
| 80 | - | - [ ] Delete passkey | |
| 81 | - | - [ ] Max 20 passkeys enforced | |
| 82 | - | - [ ] **Two-factor authentication (TOTP)** — authenticator app | |
| 83 | - | - [ ] Setup: QR code displayed, backup codes generated | |
| 84 | - | - [ ] Confirm: first TOTP code enables 2FA | |
| 85 | - | - [ ] Login requires 2FA code after password | |
| 86 | - | - [ ] Backup code works as alternative | |
| 87 | - | - [ ] Disable 2FA (requires password) | |
| 88 | - | - [ ] Regenerate backup codes (requires password) | |
| 89 | - | - [ ] **Password reset** — email link flow | |
| 90 | - | - [ ] Forgot password form always returns success (no enumeration) | |
| 91 | - | - [ ] Reset link arrives, form works | |
| 92 | - | - [ ] New password set, all other sessions revoked | |
| 93 | - | - [ ] Link invalidated if password already changed | |
| 94 | - | - [ ] **One-time login link** — email-based single-use login | |
| 95 | - | - [ ] Link works once | |
| 96 | - | - [ ] Link consumed atomically (no reuse) | |
| 97 | - | - [ ] **Change password** — from dashboard | |
| 98 | - | - [ ] Requires current password | |
| 99 | - | - [ ] All other sessions revoked on change | |
| 100 | - | - [ ] **Session management** — view and revoke | |
| 101 | - | - [ ] List active sessions with device info | |
| 102 | - | - [ ] Revoke specific session | |
| 103 | - | - [ ] Revoke all other sessions | |
| 104 | - | - [ ] **New device login alert** — email on new device | |
| 105 | - | - [ ] Alert sent when >1 active session and enabled | |
| 106 | - | - [ ] **Logout** — destroys session, redirects to landing | |
| 107 | - | - [ ] **Email unsubscribe** — signed link in emails | |
| 108 | - | - [ ] One-click unsubscribe (RFC 8058) | |
| 109 | - | - [ ] Per-notification-type opt-out | |
| 110 | - | ||
| 111 | - | ### 2. Profile & Settings | |
| 112 | - | ||
| 113 | - | - [ ] **Update profile** — display name and bio | |
| 114 | - | - [ ] **Notification preferences** — toggle email types (sales, followers, issues, etc.) | |
| 115 | - | - [ ] **Custom profile links** — add/reorder social links on profile | |
| 116 | - | - [ ] Add custom links (source code, support@makenot.work) | |
| 117 | - | - [ ] Reorder links | |
| 118 | - | - [ ] Delete links | |
| 119 | - | - [ ] **SSH key management** — add/remove keys for git push | |
| 120 | - | - [ ] **Stripe tax toggle** — enable/disable automatic tax calculation | |
| 121 | - | - [ ] **Stripe disconnect** — disconnect Stripe account | |
| 122 | - | ||
| 123 | - | ### 3. Account Lifecycle | |
| 124 | - | ||
| 125 | - | - [ ] **Account deactivation** — suspend own account (hides profile/projects) | |
| 126 | - | - [ ] Deactivate works | |
| 127 | - | - [ ] Reactivate restores everything | |
| 128 | - | - [ ] **Account deletion** — request via email confirmation | |
| 129 | - | - [ ] Email confirmation link arrives | |
| 130 | - | - [ ] Confirm deletion page prevents accidental deletion | |
| 131 | - | - [ ] Creator with sales: 90-day content grace period | |
| 132 | - | - [ ] Buyers notified on creator deletion | |
| 133 | - | - [ ] Direct API deletion (no email) also works | |
| 134 | - | - [ ] **Sandbox mode** — 1-hour ephemeral demo, no signup required | |
| 135 | - | - [ ] Sandbox account created and functional | |
| 136 | - | - [ ] Auto-expires after 1 hour | |
| 137 | - | ||
| 138 | - | ### 4. Creator Tier & Stripe Setup | |
| 139 | - | ||
| 140 | - | - [ ] **Creator tier selection** — Basic $10, Small Files $20, Big Files $30, Everything $60 | |
| 141 | - | - [ ] Confirm creator tier is Small Files ($20/mo) | |
| 142 | - | - [ ] Upgrade/downgrade prorated | |
| 143 | - | - [ ] Feature gates enforce tier limits | |
| 144 | - | - [ ] **Stripe Connect onboarding** — connect Stripe to receive payments | |
| 145 | - | - [ ] Confirm Stripe Connect onboarding complete (live mode) | |
| 146 | - | - [ ] Disclaimer page before onboarding | |
| 147 | - | - [ ] Stripe-hosted onboarding completes | |
| 148 | - | - [ ] Return redirect works | |
| 149 | - | ||
| 150 | - | ### 5. Projects & Storefronts | |
| 151 | - | ||
| 152 | - | - [ ] **Project creation wizard** — 5-step guided flow (basics, appearance, monetization, content, preview) | |
| 153 | - | - [ ] Create project with title, slug, category, description | |
| 154 | - | - [ ] Cover image upload | |
| 155 | - | - [ ] Feature selection (which content types to enable) | |
| 156 | - | - [ ] Mailing list auto-created | |
| 157 | - | - [ ] **Project editing** — update all fields | |
| 158 | - | - [ ] Title, description, category, visibility | |
| 159 | - | - [ ] **Project deletion** — permanent, items become inaccessible | |
| 160 | - | - [ ] **Project labels** — apply custom labels for filtering | |
| 161 | - | - [ ] **Project members & revenue splits** — add collaborators, split revenue % | |
| 162 | - | - [ ] Add member | |
| 163 | - | - [ ] Remove member | |
| 164 | - | - [ ] Revenue split recorded | |
| 165 | - | - [ ] **Git repository linking** — link repos to projects | |
| 166 | - | - [ ] Link repo | |
| 167 | - | - [ ] Manage collaborator access | |
| 168 | - | - [ ] Unlink repo | |
| 169 | - | ||
| 170 | - | ### 6. Items (Products) | |
| 171 | - | ||
| 172 | - | - [ ] **Item creation wizard** — 8-step guided flow (type, details, appearance, content, sections, pricing, distribution, preview) | |
| 173 | - | - [ ] Create item of each type: Audio, Text, Video, Digital, Plugin, Bundle | |
| 174 | - | - [ ] Set title, description, price, cover image | |
| 175 | - | - [ ] AI tier disclosure (Handmade/Assisted/Generated) | |
| 176 | - | - [ ] **Item editing** — update all fields | |
| 177 | - | - [ ] Title, description, price, tags, cover, release date, credits | |
| 178 | - | - [ ] **Item deletion** — permanent, buyers lose access | |
| 179 | - | - [ ] **Bulk item operations** — publish, unpublish, delete multiple | |
| 180 | - | - [ ] **Item duplication** — clone existing item | |
| 181 | - | - [ ] **Move item** — between projects | |
| 182 | - | - [ ] **Item tags** — add/remove with autocomplete, set primary tag, suggestions | |
| 183 | - | - [ ] **Item bundles** — group items for combined sale | |
| 184 | - | - [ ] Add child items | |
| 185 | - | - [ ] Unlisted items only available in bundle | |
| 186 | - | - [ ] Bundle listing visibility toggle | |
| 187 | - | - [ ] **Chapters** — timestamp markers for audio/video navigation | |
| 188 | - | - [ ] Create, edit, delete, reorder chapters | |
| 189 | - | - [ ] **Content sections** — reorderable structured sections | |
| 190 | - | - [ ] Create, edit, delete, reorder sections | |
| 191 | - | - [ ] **Item versions/releases** — version numbering with changelogs | |
| 192 | - | - [ ] Create version with changelog and file | |
| 193 | - | - [ ] **Scheduled publishing** — set future publish date | |
| 194 | - | - [ ] **Text content editor** — markdown with live preview | |
| 195 | - | ||
| 196 | - | ### 7. File Storage & Media | |
| 197 | - | ||
| 198 | - | - [ ] **File upload** — presigned S3 URL + confirm pattern | |
| 199 | - | - [ ] Upload file within tier size limit | |
| 200 | - | - [ ] Tier-based size limits enforced | |
| 201 | - | - [ ] Cover image upload (project + item) | |
| 202 | - | - [ ] **File download & streaming** — authenticated URLs | |
| 203 | - | - [ ] Stream audio in browser player | |
| 204 | - | - [ ] Download version file | |
| 205 | - | - [ ] **Guest download** — token-based post-purchase download | |
| 206 | - | - [ ] Download works without account | |
| 207 | - | - [ ] **Media library** — reusable clips/files organized by folder | |
| 208 | - | - [ ] Upload media | |
| 209 | - | - [ ] List by folder | |
| 210 | - | - [ ] Delete media | |
| 211 | - | - [ ] **Content insertions** — pre/mid/post roll placement in items | |
| 212 | - | - [ ] Add insertion to item | |
| 213 | - | - [ ] Remove insertion | |
| 214 | - | - [ ] Position-based placement works | |
| 215 | - | - [ ] **Malware scanning** — ClamAV + YARA + hash lookup before availability | |
| 216 | - | ||
| 217 | - | ### 8. Monetization & Payments | |
| 218 | - | ||
| 219 | - | - [ ] **One-time item purchase** — fixed or PWYW price via Stripe, 0% platform fee | |
| 220 | - | - [ ] Purchase flow (AF): fixed price item checkout completes; **buyer lands on `/l/{item_id}?purchase=success`** (not `/library`) | |
| 221 | - | - [ ] PWYW flow (BB): pay-what-you-want with minimum; buyer lands on `/l/{item_id}?purchase=success` | |
| 222 | - | - [ ] Free download flow (GO): free item claimed; `/l/{item_id}` becomes accessible | |
| 223 | - | - [ ] After purchase, revisiting `/i/{item_id}` shows "View in library →" CTA (not Buy) | |
| 224 | - | - [ ] Cart purchase (multi-item) still lands on `/library?purchase=success` | |
| 225 | - | - [ ] **Guest checkout** — purchase without account | |
| 226 | - | - [ ] Email receipt with download link arrives | |
| 227 | - | - [ ] CORS works for embedded checkouts | |
| 228 | - | - [ ] **Direct purchase link** — minimal /buy/ page for link-in-bio | |
| 229 | - | - [ ] **Subscription tiers** — per-project monthly recurring | |
| 230 | - | - [ ] Create subscription tier | |
| 231 | - | - [ ] Subscription flow (GO): subscriber can access gated content | |
| 232 | - | - [ ] Create subscription tier: "Cloud Sync" ($3/mo) on GoingsOn | |
| 233 | - | - [ ] Toggle tier active/inactive | |
| 234 | - | - [ ] **Fan+ subscription** — platform-wide monthly subscription | |
| 235 | - | - [ ] Fan+ checkout completes | |
| 236 | - | - [ ] Fan+ status visible in session | |
| 237 | - | - [ ] **Tips / donations** — $1-$10,000 one-time tip with optional message | |
| 238 | - | - [ ] Tip flow completes | |
| 239 | - | - [ ] Tip shows in creator dashboard with sender/amount/message | |
| 240 | - | - [ ] **Promo codes** — discount (%, $), free-access, free-trial | |
| 241 | - | - [ ] Create discount code (e.g. LAUNCH50, 50% off) | |
| 242 | - | - [ ] Apply discount code on AF purchase | |
| 243 | - | - [ ] Free-access code claim works | |
| 244 | - | - [ ] Free-trial code works on subscription | |
| 245 | - | - [ ] Usage limits enforced | |
| 246 | - | - [ ] Expiry enforced | |
| 247 | - | - [ ] Item/project/platform scoping works | |
| 248 | - | - [ ] **License keys** — generate, validate, activate per machine | |
| 249 | - | - [ ] Enable license keys on audiofiles | |
| 250 | - | - [ ] License key delivery after AF purchase | |
| 251 | - | - [ ] Validate key (public API) | |
| 252 | - | - [ ] Activate on machine | |
| 253 | - | - [ ] Deactivation works | |
| 254 | - | - [ ] Activation limit enforced | |
| 255 | - | - [ ] Revoke key (creator) | |
| 256 | - | - [ ] Key status check (public API) | |
| 257 | - | - [ ] **Creator payout & balance** — real-time Stripe balance | |
| 258 | - | - [ ] Available vs pending display | |
| 259 | - | - [ ] Transaction history visible | |
| 260 | - | ||
| 261 | - | ### 9. Discovery & Browsing | |
| 262 | - | ||
| 263 | - | - [ ] **Discover page** — search + faceted filters | |
| 264 | - | - [ ] Keyword search returns results | |
| 265 | - | - [ ] Filter by item type | |
| 266 | - | - [ ] Filter by tag/category/label | |
| 267 | - | - [ ] Filter by price range | |
| 268 | - | - [ ] Filter by AI tier | |
| 269 | - | - [ ] Sort: featured, newest, popular, price | |
| 270 | - | - [ ] Pagination works | |
| 271 | - | - [ ] Mode switch: items vs projects | |
| 272 | - | - [ ] **Tag tree browser** — hierarchical tag navigation with breadcrumbs | |
| 273 | - | - [ ] **Personalized feed** — activity from followed creators/projects | |
| 274 | - | - [ ] Feed shows items from followed creators | |
| 275 | - | - [ ] Pagination works | |
| 276 | - | - [ ] **Creator profile page** — /u/{username} | |
| 277 | - | - [ ] Bio, avatar, projects, links, follower count displayed | |
| 278 | - | - [ ] Tip button visible (if Stripe connected) | |
| 279 | - | - [ ] **Project storefront page** — /p/{slug} | |
| 280 | - | - [ ] Items, blog, subscription tiers, git repos, community link visible | |
| 281 | - | - [ ] Follow button works | |
| 282 | - | - [ ] **Item detail page** — /i/{item_id} | |
| 283 | - | - [ ] Audio player with chapters works | |
| 284 | - | - [ ] Text reader renders markdown | |
| 285 | - | - [ ] Purchase button with promo code entry | |
| 286 | - | - [ ] Version history visible | |
| 287 | - | - [ ] **Collection page** — /c/{username}/{slug} | |
| 288 | - | - [ ] Curated items displayed | |
| 289 | - | ||
| 290 | - | ### 10. Content Marketing & Community | |
| 291 | - | ||
| 292 | - | - [ ] **Blog publishing** — markdown posts per project | |
| 293 | - | - [ ] Create blog post | |
| 294 | - | - [ ] Edit blog post | |
| 295 | - | - [ ] Delete blog post | |
| 296 | - | - [ ] Scheduled publishing | |
| 297 | - | - [ ] Web-only option (skip email) | |
| 298 | - | - [ ] Follower announcement sent | |
| 299 | - | - [ ] **RSS feeds** — 4 types | |
| 300 | - | - [ ] Creator feed: /u/{username}/rss | |
| 301 | - | - [ ] Project feed: /p/{slug}/rss | |
| 302 | - | - [ ] Blog feed: /p/{slug}/blog/feed.xml | |
| 303 | - | - [ ] Personal signed feed: /feed/{user_id}?sig=... | |
| 304 | - | - [ ] **Follower system** — follow users/projects/tags | |
| 305 | - | - [ ] Follow creator | |
| 306 | - | - [ ] Follow project | |
| 307 | - | - [ ] Unfollow works | |
| 308 | - | - [ ] Follower notifications arrive | |
| 309 | - | - [ ] **Mailing lists & broadcasts** — auto-created per project | |
| 310 | - | - [ ] Broadcast message to all followers | |
| 311 | - | - [ ] **Collections** — curated item groupings | |
| 312 | - | - [ ] Create collection (up to 100) | |
| 313 | - | - [ ] Add items (up to 1,000 per collection) | |
| 314 | - | - [ ] Reorder items | |
| 315 | - | - [ ] Public/private toggle | |
| 316 | - | - [ ] Delete collection (items unaffected) | |
| 317 | - | ||
| 318 | - | ### 11. Embeds & Distribution | |
| 319 | - | ||
| 320 | - | - [ ] **Embed widgets** — self-contained HTML for external sites | |
| 321 | - | - [ ] Item button embed: /embed/i/{id}/button | |
| 322 | - | - [ ] Item card embed: /embed/i/{id}/card | |
| 323 | - | - [ ] Item player embed: /embed/i/{id}/player | |
| 324 | - | - [ ] Project card embed: /embed/p/{slug}/card | |
| 325 | - | - [ ] Tip button embed: /embed/u/{username}/tip | |
| 326 | - | - [ ] **Custom domains** — point domain to creator storefront | |
| 327 | - | - [ ] Add custom domain | |
| 328 | - | - [ ] DNS TXT record verification | |
| 329 | - | - [ ] TLS via Caddy works | |
| 330 | - | - [ ] Fallback routing to user/project/item pages | |
| 331 | - | ||
| 332 | - | ### 12. Analytics & Data | |
| 333 | - | ||
| 334 | - | - [ ] **Analytics dashboard** — 3 levels (user, project, item) | |
| 335 | - | - [ ] User-level: aggregated revenue, sales, followers | |
| 336 | - | - [ ] Project-level: revenue chart, top items, time range selector | |
| 337 | - | - [ ] Item-level: play count, download count | |
| 338 | - | - [ ] Time ranges: 7d / 30d / 90d / all | |
| 339 | - | - [ ] **Data export** — no lock-in guarantee | |
| 340 | - | - [ ] Export projects (JSON) | |
| 341 | - | - [ ] Export sales (CSV) | |
| 342 | - | - [ ] Export purchases (CSV) | |
| 343 | - | - [ ] Export splits (CSV) | |
| 344 | - | - [ ] Export followers (CSV) | |
| 345 | - | - [ ] Export subscriptions (CSV) | |
| 346 | - | - [ ] Export content (ZIP) | |
| 347 | - | - [ ] **CSV import** — bulk item creation | |
| 348 | - | - [ ] Start import job | |
| 349 | - | - [ ] Progress tracking | |
| 350 | - | - [ ] Error handling (partial success) | |
| 351 | - | - [ ] **Contact sharing & revocation** — buyer-side control | |
| 352 | - | - [ ] Buyer can revoke contact sharing with a creator | |
| 353 | - | ||
| 354 | - | ### 13. Git Source Browser | |
| 355 | - | ||
| 356 | - | - [ ] **Repository browser** — web UI for git repos | |
| 357 | - | - [ ] Repo overview with README | |
| 358 | - | - [ ] File tree navigation with breadcrumbs | |
| 359 | - | - [ ] File viewer with syntax highlighting | |
| 360 | - | - [ ] Commit log (paginated) | |
| 361 | - | - [ ] Commit detail with inline diffs | |
| 362 | - | - [ ] Blame view | |
| 363 | - | - [ ] File history | |
| 364 | - | - [ ] Raw file download | |
| 365 | - | - [ ] Ref selector (branches/tags) | |
| 366 | - | - [ ] **Smart HTTP clone** — `git clone https://makenot.work/git/owner/repo.git` | |
| 367 | - | - [ ] **Email-based issue tracker** — create/reply via email, read-only web UI | |
| 368 | - | - [ ] Issue list with open/closed filter | |
| 369 | - | - [ ] Issue detail with comments | |
| 370 | - | - [ ] Create issue via email (owner+repo@issues.makenot.work) | |
| 371 | - | - [ ] Reply via email | |
| 372 | - | - [ ] Commit message references (closes #N) | |
| 373 | - | - [ ] Repo issue settings | |
| 374 | - | - [ ] **Email patch submission** — git send-email to MT threads | |
| 375 | - | - [ ] Patch email routed to discussion thread | |
| 376 | - | ||
| 377 | - | ### 14. SyncKit (E2E Encrypted Cloud Sync) | |
| 378 | - | ||
| 379 | - | - [ ] **Push/pull sync** — remaining sub-items | |
| 380 | - | - [ ] Table name filter for selective pull | |
| 381 | - | - [ ] Idempotent push via batch_id | |
| 382 | - | - [ ] **Device management** — register, list, delete devices | |
| 383 | - | - [ ] **E2E key storage** — remaining sub-items | |
| 384 | - | - [ ] Version conflict returns 409 | |
| 385 | - | - [ ] **Blob storage** — encrypted blobs with hash dedup | |
| 386 | - | - [ ] Upload blob | |
| 387 | - | - [ ] Download blob | |
| 388 | - | - [ ] Duplicate hash skips re-upload | |
| 389 | - | - [ ] **App management** — remaining sub-items | |
| 390 | - | - [ ] Regenerate API key | |
| 391 | - | - [ ] Link app to project/item | |
| 392 | - | - [ ] Set custom slug | |
| 393 | - | - [ ] Delete app | |
| 394 | - | - [ ] **SSE push notifications** — real-time sync events | |
| 395 | - | - [ ] Connect to SSE endpoint | |
| 396 | - | - [ ] Receive "changed" events | |
| 397 | - | - [ ] **Key rotation** — rotate master encryption key | |
| 398 | - | - [ ] Begin rotation, re-encrypt entries in batches, complete | |
| 399 | - | - [ ] Verify other device can pull mixed-key entries during rotation | |
| 400 | - | - [ ] Verify new device setup after rotation uses new key | |
| 401 | - | - [ ] **BB SyncKit production test** — needs `synckit.toml` (GO + AF complete on live server 2026-05-11; see `todo_done.md`) | |
| 402 | - | ||
| 403 | - | ### 15. OTA Updates | |
| 404 | - | ||
| 405 | - | - [ ] **Release management** — create releases with semver, signatures, multi-platform | |
| 406 | - | - [ ] Create release with artifact | |
| 407 | - | - [ ] List releases | |
| 408 | - | - [ ] Delete release | |
| 409 | - | - [ ] **Update check (public)** — Tauri-compatible endpoint | |
| 410 | - | - [ ] Build signed GO release, upload artifact, verify auto-update check returns 200 | |
| 411 | - | - [ ] 204 when already on latest version | |
| 412 | - | - [ ] Download artifact via presigned URL | |
| 413 | - | ||
| 414 | - | ### 16. OAuth2 Authorization Server | |
| 415 | - | ||
| 416 | - | - [ ] **OAuth2 PKCE flow** — third-party app authentication | |
| 417 | - | - [ ] Authorize page renders | |
| 418 | - | - [ ] Grant authorization | |
| 419 | - | - [ ] Token exchange with PKCE verifier | |
| 420 | - | - [ ] Userinfo returns username, display_name, avatar | |
| 421 | - | - [ ] Localhost redirect URIs auto-allowed | |
| 422 | - | ||
| 423 | - | ### 17. Admin Tools | |
| 424 | - | ||
| 425 | - | - [ ] **Waitlist management** — approve/spam-mark applications, lottery | |
| 426 | - | - [ ] **User moderation** — warn, suspend, unsuspend, terminate | |
| 427 | - | - [ ] Trust/untrust for auto-upload approval | |
| 428 | - | - [ ] Per-user file size override | |
| 429 | - | - [ ] **Upload review queue** — approve/reject items and versions | |
| 430 | - | - [ ] **Report resolution & appeals** — resolve reports, item removal/restoration, decide appeals | |
| 431 | - | - [ ] **Platform labels** — create/manage labels for curation | |
| 432 | - | - [ ] **Metrics dashboard** — uptime, request counts, error rates, DB pool stats | |
| 433 | - | - [ ] **MT community provisioning** — batch-provision communities for projects | |
| 434 | - | ||
| 435 | - | ### 18. Public Pages & Documentation | |
| 436 | - | ||
| 437 | - | - [ ] **Landing page** — platform overview, creator count, item count | |
| 438 | - | - [ ] **Pricing calculator** — interactive comparison with competitors | |
| 439 | - | - [ ] **Use cases page** — creator type showcase | |
| 440 | - | - [ ] **Health/status page** — public uptime at /health | |
| 441 | - | - [ ] **Creator waves page** — invitation wave history at /creators | |
| 442 | - | - [ ] **Documentation system** — searchable docs at /docs | |
| 443 | - | - [ ] Section/subsection navigation | |
| 444 | - | - [ ] Search index (JSON) | |
| 445 | - | - [ ] **Changelog** — platform changelog at /changelog | |
| 446 | - | - [ ] **Content reporting** — report problematic content | |
| 447 | - | - [ ] Report submission | |
| 448 | - | - [ ] Appeal suspension | |
| 449 | - | - [ ] **Support tickets** — submit from dashboard | |
| 450 | - | ||
| 451 | - | ### 19. Buyer Features | |
| 452 | - | ||
| 453 | - | - [ ] **Buyer library** — purchases, subscriptions, collections, contacts, communities | |
| 454 | - | - [ ] Purchases tab: download links | |
| 455 | - | - [ ] Subscriptions tab: active subs, manage, cancel | |
| 456 | - | - [ ] Collections tab: personal curated lists | |
| 457 | - | - [ ] Contacts tab: shared creators | |
| 458 | - | - [ ] Communities tab: MT memberships | |
| 459 | - | - [ ] **Waitlist application** — apply to join creator waitlist | |
| 460 | - | - [ ] **Invite code redemption** — use invite code during signup | |
| 461 | - | ||
| 462 | - | ### 20–21. Documentation Gap & Documented-But-Not-Implemented | |
| 463 | - | ||
| 464 | - | Closed — see `todo_done.md`. §21 items are roadmap features documented as such; no doc revision needed pre-launch. | |
| 465 | - | ||
| 466 | - | ### Sign-Off | |
| 467 | - | ||
| 468 | - | - [ ] MNW `deploy/human_testing.md` sign-off table filled | |
| 469 | - | - [ ] GO `docs/human_testing.md` sign-off table filled | |
| 470 | - | - [ ] AF `human_testing.md` sign-off table filled | |
| 471 | - | - [ ] All P0 items pass across all projects | |
| 472 | - | - [ ] No panics or 500s in MNW server logs | |
| 473 | - | - [ ] Backup verified within last 24 hours | |
| 474 | - | - [ ] Capture screenshots for docs (dashboard, audio player, discover, pricing, git browser) — or replace with sandbox links | |
| 475 | - | ||
| 476 | - | --- | |
| 477 | - | ||
| 478 | - | ## Launch & Outreach | |
| 479 | - | ||
| 480 | - | - [ ] Human testing: complete sign-off table in `deploy/human_testing.md` (code verified, needs manual walkthrough) | |
| 481 | - | - [ ] Content seeding: at least one real creator with published content on discover page | |
| 482 | - | - [ ] 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) | |
| 483 | - | - [ ] 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. | |
| 484 | - | - [ ] Generate creator invite codes | |
| 485 | - | - [ ] Prepare 5-10 invite emails with signup link, GO DMG instructions, what to test, how to report bugs | |
| 486 | - | - [ ] Send invites | |
| 487 | - | - See `docs/internal/outreach/index.md` for broader community engagement plan | |
| 488 | - | ||
| 489 | - | --- | |
| 490 | - | ||
| 491 | - | ## Legal & Compliance | |
| 492 | - | ||
| 493 | - | - [ ] **Legal/tax professional review** — prep doc at `docs/internal/legal_review_prep.md` with 41 specific questions across ToS, privacy, DMCA, payments, tax. Recommended: split engagement (internet attorney 3h + tax professional 1-2h) | |
| 494 | - | - [ ] liability.md legal review (has [PENDING LEGAL REVIEW] placeholders) — rolled into legal review prep | |
| 495 | - | - [ ] dmca-counter.md designated agent address (needs DMCA agent registration) — rolled into legal review prep | |
| 496 | - | - [ ] **GDPR SCC execution** — Confirm SCCs are in place with Hetzner, AWS (S3), Stripe, Postmark. Part of legal review engagement. | |
| 497 | - | - [ ] **COPPA/GDPR child consent** — Fan accounts allow 13+. EU sets digital consent at 16 in some member states. No parental consent mechanism exists. Part of legal review. | |
| 498 | - | - [ ] **Indemnification clause** — ToS lacks mutual indemnification. Flagged in legal_review_prep.md. Part of legal review engagement. | |
| 499 | - | - [ ] **Independent appeals review** — Planned guarantee (guarantees.md). Requires second person. Track which admin made original decision, enforce different reviewer for appeals. | |
| 500 | - |
Lines truncated
| @@ -1,297 +0,0 @@ | |||
| 1 | - | # Import Research — Creator Platform Migration to MNW | |
| 2 | - | ||
| 3 | - | Phase 13D feasibility analysis. For each source platform: what data is extractable, how, and what maps to MNW's schema. | |
| 4 | - | ||
| 5 | - | ## MNW Target Schema (relevant tables) | |
| 6 | - | ||
| 7 | - | - `users` — email, username, display_name | |
| 8 | - | - `projects` — creator's page (name, description, category) | |
| 9 | - | - `items` — individual products/posts (title, description, price, file_type, content_data) | |
| 10 | - | - `subscription_tiers` — name, description, price_cents, project_id | |
| 11 | - | - `subscriptions` — subscriber_id, tier_id, stripe_subscription_id, status | |
| 12 | - | - `transactions` — buyer_id, item_id, amount_cents, currency, status | |
| 13 | - | - `mailing_list_subscribers` — email subscribers | |
| 14 | - | - `tags` / `item_tags` — taxonomy | |
| 15 | - | ||
| 16 | - | Import creates MNW entities; does NOT migrate active Stripe billing (subscribers must re-subscribe). | |
| 17 | - | ||
| 18 | - | --- | |
| 19 | - | ||
| 20 | - | ## 1. Generic CSV (Ko-fi, Itch.io, Sellfy) | |
| 21 | - | ||
| 22 | - | ### Approach | |
| 23 | - | Column-mapping UI with presets. Creator uploads a CSV, picks which columns map to which MNW fields. Presets auto-detect common formats. | |
| 24 | - | ||
| 25 | - | ### Feasibility: HIGH | |
| 26 | - | - Simplest to implement, most flexible | |
| 27 | - | - Covers any platform that exports CSV (almost all do) | |
| 28 | - | - Primary use: import subscriber/customer email lists + transaction history | |
| 29 | - | ||
| 30 | - | ### MNW mapping | |
| 31 | - | ||
| 32 | - | | CSV column (typical) | MNW target | Notes | | |
| 33 | - | |---|---|---| | |
| 34 | - | | email | `users.email` or `mailing_list_subscribers.email` | Dedup by email | | |
| 35 | - | | name | `users.display_name` | | | |
| 36 | - | | amount | `transactions.amount_cents` | Parse currency, convert to cents | | |
| 37 | - | | date | `transactions.created_at` | Flexible date parser needed | | |
| 38 | - | | product/item | `items.title` | Auto-create items if not found | | |
| 39 | - | | tier | `subscription_tiers.name` | Match or create | | |
| 40 | - | | status | `subscriptions.status` | Map active/cancelled/etc | | |
| 41 | - | ||
| 42 | - | ### Implementation | |
| 43 | - | - Migration 055: `import_jobs` table (user_id, source, status, progress, error_log, created_at) | |
| 44 | - | - `POST /api/users/me/import` multipart upload | |
| 45 | - | - Background `tokio::spawn` for processing | |
| 46 | - | - Progress polling endpoint | |
| 47 | - | - Dedup subscribers by email, rate-limit re-confirmation at 100/hr | |
| 48 | - | ||
| 49 | - | --- | |
| 50 | - | ||
| 51 | - | ## 2. Ko-fi | |
| 52 | - | ||
| 53 | - | ### Data access | |
| 54 | - | - **No REST API.** Webhook-only for future events. | |
| 55 | - | - **CSV exports from dashboard** (desktop only): Members CSV, Payments & Orders CSV, Supporters CSV | |
| 56 | - | - Webhooks: donation, subscription, shop order, commission events | |
| 57 | - | ||
| 58 | - | ### What's extractable | |
| 59 | - | ||
| 60 | - | | Data | Method | Fields | | |
| 61 | - | |---|---|---| | |
| 62 | - | | Supporter emails + amounts | Members CSV | username, email, membership status, total amount, payment processor | | |
| 63 | - | | Transaction history | Payments & Orders CSV | all payments with tax breakdown | | |
| 64 | - | | Supporter email list | My Supporters CSV | emails for campaigns | | |
| 65 | - | | Tier names | Webhook `tier_name` field | only for future events, not historical | | |
| 66 | - | | Shop products | None | manual re-creation required | | |
| 67 | - | | Posts/content | None | manual copy required | | |
| 68 | - | ||
| 69 | - | ### Feasibility: MEDIUM (CSV-only) | |
| 70 | - | - Use the Generic CSV importer with a Ko-fi preset | |
| 71 | - | - Preset auto-maps: email, name, amount, status columns from Members CSV | |
| 72 | - | - No API means no automated pull — creator must download CSVs manually | |
| 73 | - | - Cannot enumerate tiers programmatically; parse from CSV data or manual entry | |
| 74 | - | ||
| 75 | - | ### Ko-fi CSV preset columns | |
| 76 | - | Members CSV: `Username, Email, Membership Status, Total Amount, Payment Processor` | |
| 77 | - | (Exact column names need verification from a real export) | |
| 78 | - | ||
| 79 | - | --- | |
| 80 | - | ||
| 81 | - | ## 3. Substack | |
| 82 | - | ||
| 83 | - | ### Data access | |
| 84 | - | - **No public API.** Unofficial reverse-engineered wrappers exist but are fragile. | |
| 85 | - | - **Built-in export** (Settings > Exports): ZIP containing HTML posts + subscriber CSV | |
| 86 | - | ||
| 87 | - | ### What's extractable | |
| 88 | - | ||
| 89 | - | | Data | Method | Fields | | |
| 90 | - | |---|---|---| | |
| 91 | - | | Posts | ZIP export | HTML files (one per post), title in filename. No images (linked to Substack CDN) | | |
| 92 | - | | Subscribers | CSV in ZIP | id, email, name, subscribed_to_emails, complimentary_plan, stripe_customer_id, created_at, labels | | |
| 93 | - | | Free vs paid status | CSV | plan type column | | |
| 94 | - | | Engagement metrics | CSV (optional columns) | last email opened, post views | | |
| 95 | - | | Comments | None | not included in any export | | |
| 96 | - | | Images/media | None | referenced by URL only, must re-upload | | |
| 97 | - | ||
| 98 | - | ### Feasibility: HIGH | |
| 99 | - | - ZIP import: parse subscriber CSV → `mailing_list_subscribers` + `users` | |
| 100 | - | - HTML posts → convert to markdown (html2md), create as `items` with `FileType::Text` | |
| 101 | - | - Images: extract `<img>` URLs from HTML, re-upload to MNW S3, rewrite URLs | |
| 102 | - | - Stripe customer ID in CSV enables matching if creator uses same Stripe account on MNW | |
| 103 | - | ||
| 104 | - | ### Implementation notes | |
| 105 | - | - Accept `.zip` upload, detect Substack format (contains `posts/` dir + CSV) | |
| 106 | - | - HTML-to-markdown conversion for post bodies | |
| 107 | - | - Image pipeline: fetch external URLs → upload to S3 → rewrite in content | |
| 108 | - | - Tag extraction from CSV `labels` column | |
| 109 | - | ||
| 110 | - | --- | |
| 111 | - | ||
| 112 | - | ## 4. Ghost | |
| 113 | - | ||
| 114 | - | ### Data access | |
| 115 | - | - **Two APIs**: Content API (public data, safe for browsers) + Admin API (members, write access) | |
| 116 | - | - **Built-in export**: JSON file (posts, tags, authors) + separate Members CSV | |
| 117 | - | ||
| 118 | - | ### What's extractable | |
| 119 | - | ||
| 120 | - | | Data | Method | Fields | | |
| 121 | - | |---|---|---| | |
| 122 | - | | Posts + pages | JSON export or Admin API | title, slug, html/lexical content, status, visibility, feature_image, dates, custom_excerpt, SEO fields | | |
| 123 | - | | Tags | JSON export | name, slug, description | | |
| 124 | - | | Authors | JSON export | name, email, bio | | |
| 125 | - | | Members | CSV export or Admin API | email, name, subscribed, complimentary, stripe_customer_id, labels, created_at | | |
| 126 | - | | Tiers | Admin API | name, description, monthly/yearly price | | |
| 127 | - | | Images | Referenced by URL | must download + re-upload | | |
| 128 | - | ||
| 129 | - | ### Feasibility: HIGH | |
| 130 | - | - Best-structured export of all platforms | |
| 131 | - | - JSON is directly parseable; post content in HTML or Lexical JSON | |
| 132 | - | - Ghost → MNW mapping is clean: posts → items, tags → tags, tiers → subscription_tiers, members → users + subscriptions | |
| 133 | - | - Ghost has an official migration doc format that other platforms target | |
| 134 | - | ||
| 135 | - | ### Implementation notes | |
| 136 | - | - Accept `.json` upload (Ghost export format) | |
| 137 | - | - Parse `data.posts` → create MNW items (html content → markdown optional) | |
| 138 | - | - Parse `data.tags` + `data.posts_tags` → create MNW tags | |
| 139 | - | - Separate Members CSV → users + mailing_list_subscribers | |
| 140 | - | - Handle both `html` (legacy) and `lexical` (newer) content formats | |
| 141 | - | - Feature images: download + re-upload to S3 | |
| 142 | - | ||
| 143 | - | --- | |
| 144 | - | ||
| 145 | - | ## 5. Gumroad | |
| 146 | - | ||
| 147 | - | ### Data access | |
| 148 | - | - **REST API** (`api.gumroad.com/v2/`): products, sales, subscribers, licenses | |
| 149 | - | - **OAuth2** or personal access token | |
| 150 | - | - **CSV export**: sales analytics (emailed as download link, not instant) | |
| 151 | - | ||
| 152 | - | ### What's extractable | |
| 153 | - | ||
| 154 | - | | Data | Method | Fields | | |
| 155 | - | |---|---|---| | |
| 156 | - | | Products | `GET /v2/products` | name, price, description, custom_fields, variants | | |
| 157 | - | | Sales/customers | `GET /v2/sales` | email, amount, date, license_key, product_id. Filterable by email/date/product | | |
| 158 | - | | Subscribers | `GET /v2/products/:id/subscribers` | email, recurrence (monthly/quarterly/annual), status | | |
| 159 | - | | License keys | `GET /v2/licenses/verify` | key, uses_count, product | | |
| 160 | - | | Sales CSV | Dashboard export | customer emails, amounts, dates (emailed) | | |
| 161 | - | | Digital files | None via API | must manually re-upload | | |
| 162 | - | ||
| 163 | - | ### Feasibility: HIGH | |
| 164 | - | - Full API access with personal token (no OAuth dance needed for single-creator) | |
| 165 | - | - Products → MNW items (name, description, price mapping) | |
| 166 | - | - Sales → transactions (email matching for buyer_id) | |
| 167 | - | - Subscribers → subscription records (status, recurrence) | |
| 168 | - | - License keys → MNW license_keys table (direct mapping) | |
| 169 | - | ||
| 170 | - | ### Implementation notes | |
| 171 | - | - Two import modes: API pull (live, paginated) or CSV upload (offline) | |
| 172 | - | - API mode: creator enters access token, importer fetches all products + sales + subscribers | |
| 173 | - | - Rate limits undocumented — use conservative 60 req/min with exponential backoff on 429 | |
| 174 | - | - Pagination via `page_key` parameter (not page numbers) | |
| 175 | - | - Cannot download product files — creator must re-upload to MNW | |
| 176 | - | ||
| 177 | - | --- | |
| 178 | - | ||
| 179 | - | ## 6. Bandcamp | |
| 180 | - | ||
| 181 | - | ### Data access | |
| 182 | - | - **Very limited API** (sales endpoint only, partially deprecated) | |
| 183 | - | - **CSV exports from dashboard**: sales report + fan mailing list | |
| 184 | - | ||
| 185 | - | ### What's extractable | |
| 186 | - | ||
| 187 | - | | Data | Method | Fields | | |
| 188 | - | |---|---|---| | |
| 189 | - | | Sales history | Sales CSV | buyer name, email, item name, price, revenue share, tax, shipping, discount codes | | |
| 190 | - | | Fan emails | Mailing list CSV | email, country, zip, signup date | | |
| 191 | - | | Album/track metadata | None via API | no structured export | | |
| 192 | - | | Audio files | None | manual download from dashboard | | |
| 193 | - | | Revenue/payouts | Sales CSV | revenue breakdown, payout info | | |
| 194 | - | ||
| 195 | - | ### Feasibility: MEDIUM (CSV-only) | |
| 196 | - | - Use Generic CSV importer with Bandcamp preset | |
| 197 | - | - Sales CSV → transactions + auto-create items by name | |
| 198 | - | - Mailing list CSV → mailing_list_subscribers (email, country) | |
| 199 | - | - No metadata API means album/track info must be entered manually or scraped (not recommended) | |
| 200 | - | ||
| 201 | - | ### Bandcamp Sales CSV columns | |
| 202 | - | `sale_date, buyer_name, buyer_email, item_name, item_url, quantity, unit_price, sub_total, assessed_revenue_share, collected_revenue_share, net_revenue, currency, shipping, discount_code` | |
| 203 | - | (Approximate — verify from real export) | |
| 204 | - | ||
| 205 | - | --- | |
| 206 | - | ||
| 207 | - | ## 7. Lemon Squeezy | |
| 208 | - | ||
| 209 | - | ### Data access | |
| 210 | - | - **Full REST API** (`api.lemonsqueezy.com/v1/`): products, orders, subscriptions, customers, files | |
| 211 | - | - API key auth (bearer token) | |
| 212 | - | - JSON:API format | |
| 213 | - | ||
| 214 | - | ### What's extractable | |
| 215 | - | ||
| 216 | - | | Data | Method | Fields | | |
| 217 | - | |---|---|---| | |
| 218 | - | | Customers | `GET /v1/customers` | email, name, billing address, tax info | | |
| 219 | - | | Orders | `GET /v1/orders` | customer_id, items, total, currency, status, dates | | |
| 220 | - | | Subscriptions | `GET /v1/subscriptions` | customer, product, variant, status, billing dates, card info | | |
| 221 | - | | Products | `GET /v1/products` | name, description, status, pricing, tax category | | |
| 222 | - | | Files | `GET /v1/files` | signed download URL (1hr expiry, 10 dl/day/IP), size, version | | |
| 223 | - | | License keys | `GET /v1/license-keys` | key, status, activation limit | | |
| 224 | - | ||
| 225 | - | ### Feasibility: HIGH | |
| 226 | - | - Best API of all platforms (full REST, well-documented, rate-limited at 300 req/min) | |
| 227 | - | - Clean JSON:API format with relationships and includes | |
| 228 | - | - Products → items, customers → users, orders → transactions, subscriptions → subscriptions | |
| 229 | - | - Can even download digital files (signed URLs with rate limits) | |
| 230 | - | ||
| 231 | - | ### Implementation notes | |
| 232 | - | - API-only import (no CSV needed) | |
| 233 | - | - Creator enters API key → importer fetches everything | |
| 234 | - | - Paginate through all resources | |
| 235 | - | - File download: fetch signed URL → stream to MNW S3 (respect 10 dl/day/IP limit) | |
| 236 | - | - License keys: direct mapping to MNW license_keys table | |
| 237 | - | ||
| 238 | - | --- | |
| 239 | - | ||
| 240 | - | ## 8. Patreon | |
| 241 | - | ||
| 242 | - | ### Data access | |
| 243 | - | - **API v2** (JSON:API format): campaigns, members, posts, tiers, benefits | |
| 244 | - | - **OAuth2** with creator access token (all scopes for own campaign) | |
| 245 | - | - **CSV exports from dashboard**: Relationship Manager (patrons), Earnings, Posts Insights | |
| 246 | - | ||
| 247 | - | ### What's extractable | |
| 248 | - | ||
| 249 | - | | Data | Method | Fields | | |
| 250 | - | |---|---|---| | |
| 251 | - | | Patrons/members | API `GET /campaigns/{id}/members` | email (needs scope), name, patron_status, pledge amount, lifetime_support_cents, join date, tier | | |
| 252 | - | | Tiers | API `GET /campaigns/{id}?include=tiers` | title, description, amount_cents, patron_count, image_url | | |
| 253 | - | | Posts | API `GET /campaigns/{id}/posts` | title, content (HTML), published_at, is_public, url | | |
| 254 | - | | Patron CSV | Dashboard export | name, email, tier, pledge, lifetime amount, start date, charge dates, Discord handle, address | | |
| 255 | - | | Earnings | Dashboard CSV | transaction-level detail | | |
| 256 | - | | Post attachments | None | no structured API for media/files | | |
| 257 | - | ||
| 258 | - | ### Feasibility: MEDIUM-HIGH | |
| 259 | - | - API is powerful but complex (JSON:API with explicit field/include params) | |
| 260 | - | - **Critical**: must request `campaigns.members[email]` scope for email access | |
| 261 | - | - Posts have HTML content but no structured attachment access — images are inline URLs | |
| 262 | - | - Pagination: cursor-based, max 1000 per page for members | |
| 263 | - | - Rate limits exist but are undocumented | |
| 264 | - | ||
| 265 | - | ### Implementation notes | |
| 266 | - | - OAuth flow: creator authorizes MNW to read their Patreon data | |
| 267 | - | - Scopes needed: `campaigns`, `campaigns.members`, `campaigns.members[email]`, `campaigns.posts` | |
| 268 | - | - OR: creator provides Creator Access Token (has all scopes, simpler) | |
| 269 | - | - Members → users + subscriptions (match by email, create tiers) | |
| 270 | - | - Posts → items (HTML content, fetch inline images → re-upload to S3) | |
| 271 | - | - Patron CSV as fallback: more financial detail than API provides | |
| 272 | - | - Hardest importer due to JSON:API complexity + OAuth + undocumented rate limits | |
| 273 | - | ||
| 274 | - | --- | |
| 275 | - | ||
| 276 | - | ## Build Order (recommended) | |
| 277 | - | ||
| 278 | - | | Priority | Importer | Effort | Why | | |
| 279 | - | |---|---|---|---| | |
| 280 | - | | 1 | Generic CSV + presets | Medium | Foundation for Ko-fi, Bandcamp, Itch.io, Sellfy, any CSV | | |
| 281 | - | | 2 | Substack ZIP | Medium | Large addressable market (writers), structured ZIP format | | |
| 282 | - | | 3 | Ghost JSON | Low-Medium | Clean export format, direct field mapping, bloggers | | |
| 283 | - | | 4 | Gumroad API/CSV | Medium | Large creator base, good API, product + sales + license data | | |
| 284 | - | | 5 | Bandcamp CSV | Low | Preset on top of Generic CSV, musicians | | |
| 285 | - | | 6 | Lemon Squeezy API | Medium | Best API, growing platform, can even fetch files | | |
| 286 | - | | 7 | Patreon OAuth API | High | Hardest (OAuth + JSON:API + undocumented limits), but biggest market | | |
| 287 | - | ||
| 288 | - | ## Shared Infrastructure (build first) | |
| 289 | - | ||
| 290 | - | - [ ] Migration: `import_jobs` table (id, user_id, source, status, progress_pct, total_rows, processed_rows, error_log, created_at, completed_at) | |
| 291 | - | - [ ] `POST /api/users/me/import` — multipart upload endpoint | |
| 292 | - | - [ ] Background processor: `tokio::spawn`, chunked processing, progress updates | |
| 293 | - | - [ ] Progress polling: `GET /api/users/me/import/{id}` → status + progress | |
| 294 | - | - [ ] Subscriber pipeline: dedup by email, opt-in re-confirmation, rate-limit 100/hr | |
| 295 | - | - [ ] Content helpers: HTML-to-markdown, image re-upload to S3, tag normalization | |
| 296 | - | - [ ] Stripe customer ID mapping (when creator uses same Stripe account on MNW) | |
| 297 | - | - [ ] Column mapping UI: drag-drop or select for CSV columns → MNW fields, with platform presets |
| @@ -1,109 +0,0 @@ | |||
| 1 | - | # Liability & Disputes | |
| 2 | - | ||
| 3 | - | **Note: This document requires legal review before launch. Contents are draft only.** | |
| 4 | - | ||
| 5 | - | --- | |
| 6 | - | ||
| 7 | - | ## Service Disclaimers | |
| 8 | - | ||
| 9 | - | Makenot.work is provided "as is" without warranties of any kind. We don't guarantee: | |
| 10 | - | ||
| 11 | - | - Uninterrupted service availability | |
| 12 | - | - That the platform will meet all your needs | |
| 13 | - | - That content will remain accessible forever | |
| 14 | - | - Specific revenue or audience outcomes | |
| 15 | - | ||
| 16 | - | We do our best to provide reliable service, but we're a small team and things sometimes break. | |
| 17 | - | ||
| 18 | - | --- | |
| 19 | - | ||
| 20 | - | ## Limitation of Liability | |
| 21 | - | ||
| 22 | - | Our liability is limited to the amount you've paid us in the 12 months before any claim. | |
| 23 | - | ||
| 24 | - | We are not liable for: | |
| 25 | - | ||
| 26 | - | - Indirect, incidental, or consequential damages | |
| 27 | - | - Lost profits or revenue | |
| 28 | - | - Data loss (you're responsible for backups) | |
| 29 | - | - Actions of other users | |
| 30 | - | - Third-party services (Stripe, CDN providers, etc.) | |
| 31 | - | ||
| 32 | - | --- | |
| 33 | - | ||
| 34 | - | ## Your Responsibilities | |
| 35 | - | ||
| 36 | - | You are responsible for: | |
| 37 | - | ||
| 38 | - | - Content you upload (including copyright compliance) | |
| 39 | - | - Your interactions with fans | |
| 40 | - | - Your tax and legal obligations | |
| 41 | - | - Backing up your content | |
| 42 | - | - Maintaining account security | |
| 43 | - | ||
| 44 | - | --- | |
| 45 | - | ||
| 46 | - | ## Disputes Between Users | |
| 47 | - | ||
| 48 | - | We don't mediate disputes between creators and fans, or between creators. We provide the platform; relationship management is yours. | |
| 49 | - | ||
| 50 | - | If someone violates our policies, report it. But business disputes, creative disagreements, and personal conflicts are not our domain. | |
| 51 | - | ||
| 52 | - | --- | |
| 53 | - | ||
| 54 | - | ## Dispute Resolution | |
| 55 | - | ||
| 56 | - | *Note: This section is pending formal legal review.* | |
| 57 | - | ||
| 58 | - | ### Governing Law | |
| 59 | - | ||
| 60 | - | This agreement is governed by the laws of the State of Colorado, without regard to conflict of law principles. | |
| 61 | - | ||
| 62 | - | ### Informal Resolution | |
| 63 | - | ||
| 64 | - | Before filing any formal claim, you agree to contact us at legal@makenot.work and attempt to resolve the dispute informally for at least thirty (30) days. | |
| 65 | - | ||
| 66 | - | ### Binding Arbitration | |
| 67 | - | ||
| 68 | - | Any dispute arising from or relating to this agreement or your use of the platform that cannot be resolved informally shall be resolved by binding arbitration administered by the American Arbitration Association (AAA) under its Commercial Arbitration Rules. The arbitration shall be conducted in the State of Colorado. The arbitrator's decision shall be final and binding and may be entered as a judgment in any court of competent jurisdiction. | |
| 69 | - | ||
| 70 | - | ### Class Action Waiver | |
| 71 | - | ||
| 72 | - | You agree that any disputes will be resolved on an individual basis. You waive any right to participate in a class action, class arbitration, or representative proceeding. | |
| 73 | - | ||
| 74 | - | ### Small Claims Exception | |
| 75 | - | ||
| 76 | - | Either party may bring an individual action in small claims court in the State of Colorado if the claim qualifies. | |
| 77 | - | ||
| 78 | - | --- | |
| 79 | - | ||
| 80 | - | ## Indemnification | |
| 81 | - | ||
| 82 | - | You agree to indemnify and hold harmless Make Creative, LLC from claims arising from: | |
| 83 | - | ||
| 84 | - | - Your content | |
| 85 | - | - Your use of the platform | |
| 86 | - | - Your violation of these terms | |
| 87 | - | - Your violation of any third-party rights | |
| 88 | - | ||
| 89 | - | --- | |
| 90 | - | ||
| 91 | - | ## Changes to This Document | |
| 92 | - | ||
| 93 | - | We may update these terms. Material changes will be communicated with advance notice. Continued use after changes constitutes acceptance. | |
| 94 | - | ||
| 95 | - | --- | |
| 96 | - | ||
| 97 | - | ## Questions | |
| 98 | - | ||
| 99 | - | Contact legal@makenot.work for questions about liability or disputes. | |
| 100 | - | ||
| 101 | - | --- | |
| 102 | - | ||
| 103 | - | **Reminder: This document is a draft. Have a lawyer review before publishing.** | |
| 104 | - | ||
| 105 | - | ## See Also | |
| 106 | - | ||
| 107 | - | - [Terms of Service](../../public/legal/terms-of-service.md) — Full legal terms | |
| 108 | - | - [Payments & Revenue](./payments.md) — Stripe, chargebacks, and tax | |
| 109 | - | - [Acceptable Use Policy](../../public/legal/acceptable-use.md) — Content rules |
| @@ -1,197 +0,0 @@ | |||
| 1 | - | # async-stripe 0.37 → 1.0.0-rc.5 migration | |
| 2 | - | ||
| 3 | - | Status as of 2026-05-16: Phases 0–2 complete and committed on branch | |
| 4 | - | `worktree-stripe-sdk-1.0-rc.5` at `MNW/.claude/worktrees/stripe-sdk-1.0-rc.5/`. | |
| 5 | - | ||
| 6 | - | ## Why this exists | |
| 7 | - | ||
| 8 | - | Stripe deprecated all API versions that async-stripe 0.37 understands. Webhook | |
| 9 | - | payloads (api_version `2026-01-28.clover`) fail to deserialize because: | |
| 10 | - | ||
| 11 | - | - `Subscription.current_period_{start,end}` moved to `items.data[0]` | |
| 12 | - | - `Invoice.subscription` moved to `parent.subscription_details.subscription` | |
| 13 | - | - `InvoiceLineItem.proration` was removed (was required in 0.37's struct) | |
| 14 | - | - Stripe Dashboard no longer lets endpoints pin to old API versions | |
| 15 | - | ||
| 16 | - | Every paid transaction since 2026-05-12 is stuck `status='pending'` in our DB | |
| 17 | - | because the `checkout.session.completed` webhook never parses. | |
| 18 | - | ||
| 19 | - | There is one real stuck transaction: testaccount123's $5 PWYW for "Audiofiles | |
| 20 | - | Desktop App", session `cs_live_a1o3Ky7bRCXbnKNUYrGFS1JSmBFYLCxQ8zEk6gtmYfCPsyGAiJJfLNFwGm`, | |
| 21 | - | event `evt_1TXpkh0AcRNJbwd4J9O7c5Up` on max's connected account | |
| 22 | - | `acct_1T8oxK0AcRNJbwd4`. The charge succeeded at Stripe; the library entry | |
| 23 | - | is missing on our side. After this migration deploys, resend that event. | |
| 24 | - | ||
| 25 | - | ## Key findings that shape the migration | |
| 26 | - | ||
| 27 | - | 1. **rc.5 splits the SDK across per-domain crates.** The umbrella `async-stripe` | |
| 28 | - | provides only the HTTP client. Resource types live in `async-stripe-shared`, | |
| 29 | - | `async-stripe-billing`, `async-stripe-checkout`, `async-stripe-connect`, | |
| 30 | - | `async-stripe-core`, `async-stripe-payment`, `async-stripe-product`, | |
| 31 | - | `async-stripe-types`. Crate names in code are `stripe_shared`, `stripe_billing`, | |
| 32 | - | etc. (package name uses dashes, library name uses underscores). | |
| 33 | - | ||
| 34 | - | 2. **`Deserialize` is feature-gated** behind `feature = "deserialize"` on each | |
| 35 | - | resource crate. Cargo.toml already enables this on every sub-crate we depend | |
| 36 | - | on. Without it, `serde_json::from_value::<Subscription>(...)` won't compile. | |
| 37 | - | ||
| 38 | - | 3. **rc.5 has NO built-in webhook helper.** No `Webhook::construct_event`, no | |
| 39 | - | typed `Event`/`EventObject`/`EventType`. We keep our existing | |
| 40 | - | `payments::webhooks::verify_signature` HMAC implementation. For the event | |
| 41 | - | envelope, write a thin local struct (id, type as String, data.object as | |
| 42 | - | `serde_json::Value`) — rc.5's `stripe_shared::Event` requires | |
| 43 | - | `previous_attributes` which isn't present on `*.completed` events, so the | |
| 44 | - | SDK's Event struct is unusable for inbound webhooks. | |
| 45 | - | ||
| 46 | - | 4. **Connect account header is per-request, not per-client.** In 0.37 we did | |
| 47 | - | `client.clone().with_stripe_account(...)`. In rc.5 the pattern is to pass | |
| 48 | - | `Stripe-Account` as a request header on each call. Check the rc.5 docs for | |
| 49 | - | the exact pattern (`RequestStrategy` / per-call headers). | |
| 50 | - | ||
| 51 | - | 5. **Currency moved to `stripe_types::Currency`.** | |
| 52 | - | ||
| 53 | - | 6. **Connect events live on a separate endpoint** from platform events. | |
| 54 | - | Stripe forces one endpoint per scope: "Your account" vs "Connected accounts". | |
| 55 | - | We now run **two destinations** both pointing at `https://makenot.work/stripe/webhook`: | |
| 56 | - | - `mnw-connect` → Events from connected accounts (fan→creator flow, | |
| 57 | - | creator onboarding, refunds). testaccount123's stuck event lives here. | |
| 58 | - | - `mnw-you` → Events from MNW's own account (Fan+ subs, creator tier subs, | |
| 59 | - | app sync subs, MNW's own subscription). | |
| 60 | - | Each endpoint has its own signing secret. `STRIPE_WEBHOOK_SECRET` accepts | |
| 61 | - | a comma-separated list; `verify_signature` tries each in turn. | |
| 62 | - | ||
| 63 | - | 7. **Smoke test proved rc.5 parses our fixtures.** See `/tmp/stripe-rc5-parse/` | |
| 64 | - | (scratch crate, can be regenerated; main.rs in that crate is the working | |
| 65 | - | reference for how to deserialize each event payload). | |
| 66 | - | ||
| 67 | - | ## What's already done (committed on this branch) | |
| 68 | - | ||
| 69 | - | ``` | |
| 70 | - | 12a3512 build(server): swap async-stripe 0.37.3 → 1.0.0-rc.5 sub-crates ← intentionally non-compiling | |
| 71 | - | d107447 test: capture 2026-01-28 Stripe webhook fixtures | |
| 72 | - | ``` | |
| 73 | - | ||
| 74 | - | - `server/Cargo.toml`: rc.5 sub-crates added with `deserialize` feature | |
| 75 | - | - `server/tests/fixtures/webhooks/*.json`: 7 fixtures captured live via Stripe API | |
| 76 | - | including the testaccount123 stuck Connect event | |
| 77 | - | ||
| 78 | - | ## Remaining phases | |
| 79 | - | ||
| 80 | - | ### Phase 3a — Migrate `server/src/payments/` (~5 files) | |
| 81 | - | ||
| 82 | - | Order matters because later modules import from earlier: | |
| 83 | - | ||
| 84 | - | 1. `payments/mod.rs` — `use stripe::Client` stays; remove `pub use webhooks::*` | |
| 85 | - | exports of stripe-typed extractors that no longer exist; the `PaymentProvider` | |
| 86 | - | trait's `verify_webhook` return type changes from `stripe::Event` to our new | |
| 87 | - | `UntypedEvent` struct. | |
| 88 | - | 2. `payments/webhooks.rs` — biggest rewrite. Replace SDK `Webhook::construct_event` | |
| 89 | - | with raw signature verify (existing `verify_signature` fn) + manual | |
| 90 | - | `serde_json::from_str` to a local `UntypedEvent { id, type_, data_object }`. | |
| 91 | - | Replace all `extract_*` functions to take `&UntypedEvent` and return the rc.5 | |
| 92 | - | typed objects (`stripe_billing::Subscription`, `stripe_checkout::CheckoutSession`, | |
| 93 | - | etc.) parsed via `serde_json::from_value(&event.data_object)`. Keep | |
| 94 | - | `AccountUpdate` and `ChargeRefundData` view structs as-is. | |
| 95 | - | 3. `payments/checkout_metadata.rs` — the `is_*_checkout` guards take | |
| 96 | - | `&CheckoutSession`. Just swap the import to `stripe_checkout::CheckoutSession`. | |
| 97 | - | Field access (`session.metadata`, `session.mode`, etc.) is mostly identical. | |
| 98 | - | 4. `payments/checkout.rs` — Request struct shapes changed. `CreateCheckoutSession`, | |
| 99 | - | `CreateCheckoutSessionLineItems`, etc. now live in `stripe_checkout` and have | |
| 100 | - | builder-style construction. Reference rc.5 docs and examples in | |
| 101 | - | `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/async-stripe-checkout-1.0.0-rc.5/examples/` | |
| 102 | - | if any. | |
| 103 | - | 5. `payments/connect.rs` — `CreateAccount`, `CreateAccountLink`, `Balance` — all | |
| 104 | - | moved to `stripe_connect` / `stripe_shared`. `with_stripe_account` is gone; | |
| 105 | - | pass the account header per-request. | |
| 106 | - | ||
| 107 | - | ### Phase 3b — `server/src/routes/stripe/` checkout handlers | |
| 108 | - | ||
| 109 | - | `routes/stripe/checkout/{item,project,subscriptions,cart}.rs` and | |
| 110 | - | `routes/stripe/connect.rs`. Each constructs `CreateCheckoutSession` params and | |
| 111 | - | calls into `payments::checkout`. Update imports + builder syntax. | |
| 112 | - | ||
| 113 | - | ### Phase 3c — `server/src/routes/stripe/webhook/` (the bug-fix path) | |
| 114 | - | ||
| 115 | - | `routes/stripe/webhook/mod.rs` dispatcher: change `event.type_` (enum) to | |
| 116 | - | `event.type_` (String) match. Each handler in `webhook/{checkout,subscriptions,billing}.rs` | |
| 117 | - | takes a typed rc.5 object now. The subscription/invoice handlers must read | |
| 118 | - | `current_period_*` from `items.data[0]` / `parent.subscription_details.subscription` | |
| 119 | - | instead of top-level — the new rc.5 structs already model this correctly, so | |
| 120 | - | field paths just change. | |
| 121 | - | ||
| 122 | - | ### Phase 3d — `server/src/scheduler/webhooks.rs` and any remaining `stripe::` refs | |
| 123 | - | ||
| 124 | - | `scheduler/webhooks.rs` is the retry queue worker; it reparses stored payloads. | |
| 125 | - | Update to use the same `UntypedEvent` flow. | |
| 126 | - | ||
| 127 | - | Grep for stragglers: `grep -rln "stripe::" server/src/ | grep -v test`. | |
| 128 | - | ||
| 129 | - | ### Phase 4 — Local smoke test | |
| 130 | - | ||
| 131 | - | ```bash | |
| 132 | - | cd server | |
| 133 | - | cargo check # must be green | |
| 134 | - | cargo test | |
| 135 | - | # In one terminal: | |
| 136 | - | stripe listen --forward-to localhost:3000/stripe/webhook | |
| 137 | - | # In another: | |
| 138 | - | cargo run | |
| 139 | - | # Trigger each event type: | |
| 140 | - | stripe trigger checkout.session.completed | |
| 141 | - | stripe trigger customer.subscription.updated | |
| 142 | - | stripe trigger invoice.payment_succeeded | |
| 143 | - | stripe trigger account.updated | |
| 144 | - | ``` | |
| 145 | - | ||
| 146 | - | Confirm `received webhook event` logs for each, no parse errors. Walk a real | |
| 147 | - | PWYW checkout in browser at localhost using test card `4242…`. | |
| 148 | - | ||
| 149 | - | ### Phase 5 — Deploy | |
| 150 | - | ||
| 151 | - | 1. Patch-bump `server/Cargo.toml` version (per `MNW/CLAUDE.md` rule, ask user | |
| 152 | - | for version number before bumping). | |
| 153 | - | 2. Merge `worktree-stripe-sdk-1.0-rc.5` to main via fast-forward or PR-style | |
| 154 | - | merge (no GitHub). | |
| 155 | - | 3. `cd server && ./deploy/deploy.sh root@100.120.174.96`. | |
| 156 | - | 4. Watch `journalctl -u makenotwork -f` for `received webhook event` lines. | |
| 157 | - | 5. In Stripe Dashboard, find event `evt_1TXpkh0AcRNJbwd4J9O7c5Up` on max's | |
| 158 | - | connected account, click "Resend". Verify the transaction in DB flips | |
| 159 | - | from `pending` to `completed` and testaccount123 sees the item in their | |
| 160 | - | library. | |
| 161 | - | 6. Trigger one fresh test purchase end-to-end as the final smoke. | |
| 162 | - | ||
| 163 | - | ### Pre-invite gates (separate, post-deploy) | |
| 164 | - | ||
| 165 | - | - **A2.6**: Add an integration test against Stripe test mode that exercises | |
| 166 | - | every outgoing API call (create session × 3 modes, create subscription, | |
| 167 | - | fetch balance, fetch account, fetch subscription, refund). Run in CI. | |
| 168 | - | Catches future schema breaks before prod sees them. | |
| 169 | - | - **A2.7** (resolved 2026-05-17): Two endpoints configured — `mnw-connect` | |
| 170 | - | (Events from Connected accounts, 7 events) and `mnw-you` (Events from Your | |
| 171 | - | account, same 7 events). Both deliver to `https://makenot.work/stripe/webhook`. | |
| 172 | - | ||
| 173 | - | ## State of related infrastructure (2026-05-17) | |
| 174 | - | ||
| 175 | - | - Old `mnw-alpha` endpoint was deleted during the back-pin attempt. Replaced | |
| 176 | - | with two new endpoints (see A2.7 above), each with its own signing secret. | |
| 177 | - | - `STRIPE_WEBHOOK_SECRET` is now a comma-separated list of the two secrets; | |
| 178 | - | `verify_signature` iterates and accepts a match against any of them. | |
| 179 | - | - v0.6.0 deployed to prod 2026-05-17. Service active, health 200. | |
| 180 | - | ||
| 181 | - | ## Other bugs filed during this session (for reference, not migration scope) | |
| 182 | - | ||
| 183 | - | In `server/docs/todo.md` § Global UX: | |
| 184 | - | - Library page scroll-in-scroll + `...` dropdown UX (open) | |
| 185 | - | - Stuck "Verbing..." buttons + slow-redirect feedback (closed 2026-05-18) | |
| 186 | - | - Checkout error messages not surfaced to user (closed 2026-05-18 via | |
| 187 | - | `error.html` typography rebalance) | |
| 188 | - | - Misleading webhook error message (closed 2026-05-18 — `payments/webhooks.rs` | |
| 189 | - | now produces distinct bodies "Invalid webhook signature: <reason>", | |
| 190 | - | "Webhook envelope JSON parse failed: <serde error>", "Webhook envelope | |
| 191 | - | missing required field: <name>", and the typed-struct dispatcher still | |
| 192 | - | produces "Failed to parse <Subscription|Invoice|…>: <serde error>") | |
| 193 | - | ||
| 194 | - | In `MNW/server/deploy/human_testing.md`: | |
| 195 | - | - P0 sections done: Signup→Verify→Login→Logout (11/11), Account Lockout | |
| 196 | - | + Recovery (4/5, 1 N/A), Password Reset (7/7), Free Item Claim (4/4). | |
| 197 | - | Remaining P0 sections gated on this migration shipping. |
| @@ -1,298 +0,0 @@ | |||
| 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. |
| @@ -1,351 +0,0 @@ | |||
| 1 | - | # Plan: Guest Checkout | |
| 2 | - | ||
| 3 | - | Allow fans to purchase items without creating an MNW account. This is a core platform feature — not limited to embeds. Guest checkout is available everywhere: item pages, project pages, embeds, direct links. | |
| 4 | - | ||
| 5 | - | ## Why | |
| 6 | - | ||
| 7 | - | Every extra step between "I want this" and "I have this" costs conversions. The current flow requires: create account → verify email → log in → find item → buy. Guest checkout reduces this to: click buy → enter payment → done. This is how Gumroad and Bandcamp work, and it's what creators expect from a platform that claims to stay out of the way. | |
| 8 | - | ||
| 9 | - | ## Scope | |
| 10 | - | ||
| 11 | - | Guest checkout is a **first-class purchase path**, not an embed-only feature: | |
| 12 | - | - Item pages show guest checkout as the primary option for logged-out visitors | |
| 13 | - | - Embeds use it for zero-friction purchases | |
| 14 | - | - Direct links (shared by creators) support it | |
| 15 | - | - The logged-in purchase flow remains unchanged for users who prefer it | |
| 16 | - | ||
| 17 | - | ## How It Works (Fan Perspective) | |
| 18 | - | ||
| 19 | - | 1. Fan clicks "Buy" (on embed, on item page, anywhere) | |
| 20 | - | 2. Stripe Checkout opens (popup for embeds, redirect for on-site) | |
| 21 | - | 3. Fan enters email + card details on Stripe's hosted checkout | |
| 22 | - | 4. Payment completes | |
| 23 | - | 5. Fan receives email with: | |
| 24 | - | - Download link (signed, long-lived) | |
| 25 | - | - Claim link to attach purchase to an MNW account (optional) | |
| 26 | - | 6. Fan can download immediately — no account needed | |
| 27 | - | ||
| 28 | - | If the fan's email matches an existing MNW account, the purchase is automatically attached to that account (visible in their library next time they log in). | |
| 29 | - | ||
| 30 | - | ## Data Model Changes | |
| 31 | - | ||
| 32 | - | ### Migration 078: Guest purchases | |
| 33 | - | ||
| 34 | - | ```sql | |
| 35 | - | -- Allow transactions without a registered buyer account. | |
| 36 | - | -- guest_email stores the buyer's email from Stripe for guest checkouts. | |
| 37 | - | -- claim_token allows the buyer to later attach the purchase to an account. | |
| 38 | - | ALTER TABLE transactions ADD COLUMN guest_email VARCHAR(255); | |
| 39 | - | ALTER TABLE transactions ADD COLUMN claim_token UUID; | |
| 40 | - | ALTER TABLE transactions ADD COLUMN claimed_by UUID REFERENCES users(id) ON DELETE SET NULL; | |
| 41 | - | ALTER TABLE transactions ADD COLUMN download_token UUID DEFAULT gen_random_uuid(); | |
| 42 | - | ||
| 43 | - | -- Make buyer_id nullable for guest purchases. | |
| 44 | - | ALTER TABLE transactions ALTER COLUMN buyer_id DROP NOT NULL; | |
| 45 | - | ||
| 46 | - | -- Index for claim token lookup. | |
| 47 | - | CREATE INDEX idx_transactions_claim_token ON transactions(claim_token) WHERE claim_token IS NOT NULL; | |
| 48 | - | ||
| 49 | - | -- Index for guest email lookup (to auto-attach when they create an account). | |
| 50 | - | CREATE INDEX idx_transactions_guest_email ON transactions(guest_email) WHERE guest_email IS NOT NULL; | |
| 51 | - | ||
| 52 | - | -- Index for download token (signed download links). | |
| 53 | - | CREATE UNIQUE INDEX idx_transactions_download_token ON transactions(download_token) WHERE download_token IS NOT NULL; | |
| 54 | - | ``` | |
| 55 | - | ||
| 56 | - | ### Fields explained | |
| 57 | - | ||
| 58 | - | | Column | Purpose | | |
| 59 | - | |--------|---------| | |
| 60 | - | | `guest_email` | Buyer's email from Stripe (NULL for logged-in purchases) | | |
| 61 | - | | `claim_token` | UUID sent in post-purchase email; fan uses it to attach purchase to account | | |
| 62 | - | | `claimed_by` | User ID after claim (NULL until claimed) | | |
| 63 | - | | `download_token` | Unique token for signed download links (no auth required) | | |
| 64 | - | ||
| 65 | - | ### Constraint change | |
| 66 | - | ||
| 67 | - | `buyer_id` becomes nullable. For guest purchases, `buyer_id = NULL` and `guest_email` is set. For logged-in purchases, `buyer_id` is set and `guest_email` is NULL. | |
| 68 | - | ||
| 69 | - | Update the partial unique index to handle both cases: | |
| 70 | - | ```sql | |
| 71 | - | -- Prevent duplicate pending checkouts: logged-in buyers | |
| 72 | - | CREATE UNIQUE INDEX idx_transactions_pending_buyer_item | |
| 73 | - | ON transactions(buyer_id, item_id) | |
| 74 | - | WHERE status = 'pending' AND buyer_id IS NOT NULL; | |
| 75 | - | ||
| 76 | - | -- Prevent duplicate pending checkouts: guest buyers | |
| 77 | - | CREATE UNIQUE INDEX idx_transactions_pending_guest_item | |
| 78 | - | ON transactions(guest_email, item_id) | |
| 79 | - | WHERE status = 'pending' AND guest_email IS NOT NULL; | |
| 80 | - | ``` | |
| 81 | - | ||
| 82 | - | (Drop the old partial unique index if one exists on `(buyer_id, item_id) WHERE status = 'pending'`.) | |
| 83 | - | ||
| 84 | - | ## New Endpoints | |
| 85 | - | ||
| 86 | - | ### 1. Guest checkout session creation | |
| 87 | - | ||
| 88 | - | ``` | |
| 89 | - | POST /api/checkout/guest/{item_id} | |
| 90 | - | Content-Type: application/json (or form) | |
| 91 | - | ||
| 92 | - | No authentication required. | |
| 93 | - | CORS: Allow-Origin * (for embed usage) | |
| 94 | - | ``` | |
| 95 | - | ||
| 96 | - | Request body (optional): | |
| 97 | - | ```json | |
| 98 | - | { | |
| 99 | - | "amount_cents": 1500, // Only for PWYW items | |
| 100 | - | "promo_code": "LAUNCH20" // Optional | |
| 101 | - | } | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | Response: | |
| 105 | - | ```json | |
| 106 | - | { | |
| 107 | - | "checkout_url": "https://checkout.stripe.com/c/pay/cs_live_..." | |
| 108 | - | } | |
| 109 | - | ``` | |
| 110 | - | ||
| 111 | - | Handler logic: | |
| 112 | - | 1. Fetch item (404 if not found, not public, or not purchasable) | |
| 113 | - | 2. Validate price (PWYW minimum check, fixed price override) | |
| 114 | - | 3. Apply promo code if provided (reserve use_count) | |
| 115 | - | 4. Create Stripe Checkout Session: | |
| 116 | - | - `mode: "payment"` | |
| 117 | - | - `customer_creation: "always"` (Stripe creates a customer record with email) | |
| 118 | - | - `payment_intent_data.transfer_data.destination: seller_stripe_account_id` | |
| 119 | - | - `metadata: { item_id, seller_id, checkout_type: "guest", promo_code_id }` | |
| 120 | - | - No `customer` field (guest — Stripe collects email) | |
| 121 | - | - `success_url`: `/purchase/success?session_id={CHECKOUT_SESSION_ID}` | |
| 122 | - | - `cancel_url`: `/i/{item_id}` | |
| 123 | - | 5. Create pending transaction with `buyer_id = NULL`, `guest_email = NULL` (populated on webhook) | |
| 124 | - | 6. Return checkout URL | |
| 125 | - | ||
| 126 | - | ### 2. Guest download endpoint | |
| 127 | - | ||
| 128 | - | ``` | |
| 129 | - | GET /download/{download_token} | |
| 130 | - | ||
| 131 | - | No authentication required. | |
| 132 | - | ``` | |
| 133 | - | ||
| 134 | - | Handler logic: | |
| 135 | - | 1. Look up transaction by `download_token` | |
| 136 | - | 2. Verify status = 'completed' | |
| 137 | - | 3. Fetch item (if still exists) | |
| 138 | - | 4. Generate presigned S3 URL for the content | |
| 139 | - | 5. Redirect to presigned URL (or return JSON with URL for API consumers) | |
| 140 | - | ||
| 141 | - | This endpoint allows email download links to work without login. | |
| 142 | - | ||
| 143 | - | ### 3. Claim endpoint | |
| 144 | - | ||
| 145 | - | ``` | |
| 146 | - | POST /api/purchases/claim | |
| 147 | - | Authentication: Required (logged-in user) | |
| 148 | - | ||
| 149 | - | Body: { "claim_token": "uuid-here" } | |
| 150 | - | ``` | |
| 151 | - | ||
| 152 | - | Handler logic: | |
| 153 | - | 1. Find transaction by claim_token | |
| 154 | - | 2. Verify not already claimed | |
| 155 | - | 3. Set `buyer_id = current_user.id`, `claimed_by = current_user.id` | |
| 156 | - | 4. Clear claim_token (one-time use) | |
| 157 | - | 5. Purchase now appears in user's library | |
| 158 | - | ||
| 159 | - | ## Webhook Changes | |
| 160 | - | ||
| 161 | - | ### Modified: `handle_purchase_checkout_completed()` | |
| 162 | - | ||
| 163 | - | Current: Expects `buyer_id` in metadata, updates existing pending transaction. | |
| 164 | - | ||
| 165 | - | New logic branch for `checkout_type == "guest"`: | |
| 166 | - | 1. Extract `customer_details.email` from Stripe session | |
| 167 | - | 2. Look up pending transaction by `stripe_checkout_session_id` | |
| 168 | - | 3. Set `guest_email = email` on transaction | |
| 169 | - | 4. Generate `claim_token` and `download_token` | |
| 170 | - | 5. Check if email matches existing MNW user: | |
| 171 | - | - **Yes**: Set `buyer_id` to that user (auto-claim). Skip claim email, purchase appears in library. | |
| 172 | - | - **No**: Leave `buyer_id = NULL`. Send guest purchase email with download + claim links. | |
| 173 | - | 6. Complete transaction (set status = 'completed', payment_intent_id) | |
| 174 | - | 7. Normal secondary effects: increment sales_count, generate license keys, revenue splits | |
| 175 | - | ||
| 176 | - | ### Email: Guest purchase confirmation | |
| 177 | - | ||
| 178 | - | New email template with: | |
| 179 | - | - "Your purchase of {item_title}" subject | |
| 180 | - | - Download link: `https://makenot.work/download/{download_token}` | |
| 181 | - | - Claim link: `https://makenot.work/claim?token={claim_token}` | |
| 182 | - | - "Want to keep all your purchases in one place? Create an account to claim this purchase." | |
| 183 | - | - Receipt details (amount, date, seller) | |
| 184 | - | ||
| 185 | - | ## Embed Integration | |
| 186 | - | ||
| 187 | - | The embed buy button changes from: | |
| 188 | - | ```html | |
| 189 | - | <a href="/i/{item_id}" target="_blank">Buy</a> | |
| 190 | - | ``` | |
| 191 | - | to triggering a popup checkout: | |
| 192 | - | ```html | |
| 193 | - | <a href="javascript:void(0)" onclick="mnwBuy('{item_id}')">Buy</a> | |
| 194 | - | <script> | |
| 195 | - | function mnwBuy(itemId) { | |
| 196 | - | fetch('https://makenot.work/api/checkout/guest/' + itemId, { method: 'POST' }) | |
| 197 | - | .then(r => r.json()) | |
| 198 | - | .then(data => { | |
| 199 | - | // Open Stripe Checkout in popup | |
| 200 | - | const popup = window.open(data.checkout_url, 'mnw-checkout', | |
| 201 | - | 'width=500,height=700,scrollbars=yes'); | |
| 202 | - | if (!popup) window.location.href = data.checkout_url; // fallback | |
| 203 | - | }); | |
| 204 | - | } | |
| 205 | - | </script> | |
| 206 | - | ``` | |
| 207 | - | ||
| 208 | - | For the simpler iframe-only embeds (v1), the buy button can still link to `/i/{item_id}` in a new tab where the item page offers both logged-in and guest checkout. The popup variant is an enhancement for v2/overlay embeds. | |
| 209 | - | ||
| 210 | - | ## CORS Configuration | |
| 211 | - | ||
| 212 | - | New middleware (or layer) for specific routes: | |
| 213 | - | ||
| 214 | - | ```rust | |
| 215 | - | // Routes that need CORS for embed cross-origin requests | |
| 216 | - | let cors_routes = ["/api/checkout/guest/"]; | |
| 217 | - | ||
| 218 | - | // In middleware: | |
| 219 | - | if path matches cors_routes { | |
| 220 | - | response.headers_mut().insert("Access-Control-Allow-Origin", "*"); | |
| 221 | - | response.headers_mut().insert("Access-Control-Allow-Methods", "POST, OPTIONS"); | |
| 222 | - | response.headers_mut().insert("Access-Control-Allow-Headers", "Content-Type"); | |
| 223 | - | } | |
| 224 | - | ``` | |
| 225 | - | ||
| 226 | - | Also handle `OPTIONS` preflight requests on these routes. | |
| 227 | - | ||
| 228 | - | ## On-Site Guest Checkout (Primary Purchase Path) | |
| 229 | - | ||
| 230 | - | Guest checkout is the default purchase experience for logged-out visitors everywhere on the platform — not a secondary option behind "log in first." | |
| 231 | - | ||
| 232 | - | **Item page** (`/i/{item_id}`): | |
| 233 | - | - Logged out: "Buy" button triggers guest checkout (Stripe). Small "or log in" link below. | |
| 234 | - | - Logged in: Normal purchase flow (no change). | |
| 235 | - | ||
| 236 | - | **Project page** (`/p/{slug}`): | |
| 237 | - | - Same pattern for project-level purchases and subscriptions. | |
| 238 | - | ||
| 239 | - | **Direct purchase links** (shared by creators): | |
| 240 | - | - `/buy/{item_id}` — lightweight page with item summary + immediate guest checkout. No navigation chrome. Optimized for link-in-bio, social media, newsletters. | |
| 241 | - | ||
| 242 | - | This benefits all creators immediately. The embed integration is just one surface that uses the same underlying guest checkout API. | |
| 243 | - | ||
| 244 | - | ## Account Creation After Guest Purchase | |
| 245 | - | ||
| 246 | - | When a guest later creates an MNW account with the same email: | |
| 247 | - | ||
| 248 | - | 1. During signup (after email verification), check for unclaimed transactions matching the email | |
| 249 | - | 2. Auto-attach: set `buyer_id` on all matching guest transactions | |
| 250 | - | 3. Show "We found N purchases with this email — they're now in your library" message | |
| 251 | - | ||
| 252 | - | This means fans who buy first and sign up later lose nothing. | |
| 253 | - | ||
| 254 | - | ## Library Changes | |
| 255 | - | ||
| 256 | - | The library query currently filters `WHERE buyer_id = $1`. Update to: | |
| 257 | - | ```sql | |
| 258 | - | WHERE buyer_id = $1 OR claimed_by = $1 | |
| 259 | - | ``` | |
| 260 | - | ||
| 261 | - | ## Download Links (Future: One-Time Downloads) | |
| 262 | - | ||
| 263 | - | v1 download tokens are permanent and unlimited-use (like a personal download link). In a future iteration, creators can generate **one-time-download links** for gifting, press copies, or limited distribution: | |
| 264 | - | ||
| 265 | - | - Add `downloads_remaining INTEGER` column to transactions (NULL = unlimited) | |
| 266 | - | - Each download decrements the counter | |
| 267 | - | - When counter hits 0, link returns 410 Gone | |
| 268 | - | - Creator dashboard: "Generate download link" with configurable download count (1, 3, 5, unlimited) | |
| 269 | - | - These work alongside claim tokens — a recipient can claim the purchase to their account AND has a finite number of downloads available | |
| 270 | - | ||
| 271 | - | This is deferred to keep v1 simple but the token infrastructure supports it directly. | |
| 272 | - | ||
| 273 | - | ## Security Considerations | |
| 274 | - | ||
| 275 | - | - **Download tokens are long-lived** (no expiry). The token is unguessable (UUID v4 = 122 bits entropy). Acceptable risk for digital content. | |
| 276 | - | - **Claim tokens are one-time use.** Cleared after claim. | |
| 277 | - | - **Rate limit guest checkout endpoint** to prevent abuse (e.g., 10 req/min per IP). | |
| 278 | - | - **Promo code abuse**: Guest checkout doesn't tie to a user, so someone could use a single-use promo code from multiple emails. Mitigation: rate limit by IP, flag suspicious patterns. | |
| 279 | - | - **Email validation**: Stripe validates the email (requires real payment method). No fake emails getting free content. | |
| 280 | - | - **No contact sharing for guests**: `share_contact` requires an account. Guests can claim later if they want to share contact info. | |
| 281 | - | ||
| 282 | - | ## Migration Path | |
| 283 | - | ||
| 284 | - | This is backwards-compatible: | |
| 285 | - | - Existing transactions: `guest_email = NULL`, `buyer_id` set → logged-in purchase (unchanged) | |
| 286 | - | - New guest transactions: `guest_email` set, `buyer_id = NULL` → guest purchase | |
| 287 | - | - After auto-attach or claim: both `guest_email` and `buyer_id` set | |
| 288 | - | ||
| 289 | - | No data migration needed. Only new transactions use the new columns. | |
| 290 | - | ||
| 291 | - | ## Implementation Order | |
| 292 | - | ||
| 293 | - | 1. Migration 078 (schema changes) | |
| 294 | - | 2. `CreateTransactionParams` update — make `buyer_id` optional | |
| 295 | - | 3. Guest checkout endpoint (`/api/checkout/guest/{item_id}`) | |
| 296 | - | 4. Webhook handler branch for `checkout_type: "guest"` | |
| 297 | - | 5. Download token endpoint (`/download/{download_token}`) | |
| 298 | - | 6. Guest purchase email template | |
| 299 | - | 7. Claim endpoint (`/api/purchases/claim`) | |
| 300 | - | 8. Auto-attach on signup | |
| 301 | - | 9. Library query update | |
| 302 | - | 10. Item page UI (guest checkout option for logged-out visitors) | |
| 303 | - | 11. CORS middleware for guest checkout endpoint | |
| 304 | - | 12. Embed integration (update buy button to use guest checkout) | |
| 305 | - | 13. Tests | |
| 306 | - | ||
| 307 | - | ## Tests | |
| 308 | - | ||
| 309 | - | - `guest_checkout_creates_session` — returns valid Stripe checkout URL | |
| 310 | - | - `guest_checkout_private_item_404` | |
| 311 | - | - `guest_checkout_free_item` — works (amount_cents = 0, Stripe setup mode) | |
| 312 | - | - `guest_checkout_pwyw_validates_minimum` | |
| 313 | - | - `guest_checkout_promo_code_applies` | |
| 314 | - | - `guest_webhook_completes_transaction` — sets guest_email, generates tokens | |
| 315 | - | - `guest_webhook_auto_attaches_existing_user` — email match sets buyer_id | |
| 316 | - | - `guest_download_token_works` — returns presigned URL | |
| 317 | - | - `guest_download_invalid_token_404` | |
| 318 | - | - `guest_claim_attaches_to_account` | |
| 319 | - | - `guest_claim_already_claimed_error` | |
| 320 | - | - `signup_auto_attaches_guest_purchases` | |
| 321 | - | - `guest_checkout_cors_headers` — preflight and response headers correct | |
| 322 | - | - `guest_checkout_rate_limited` | |
| 323 | - | ||
| 324 | - | ## Estimated Scope | |
| 325 | - | ||
| 326 | - | - Migration: ~20 lines SQL | |
| 327 | - | - Checkout endpoint: ~120 lines | |
| 328 | - | - Webhook changes: ~80 lines | |
| 329 | - | - Download endpoint: ~50 lines | |
| 330 | - | - Claim endpoint: ~40 lines | |
| 331 | - | - Auto-attach on signup: ~30 lines | |
| 332 | - | - Email template: ~50 lines | |
| 333 | - | - CORS middleware: ~30 lines | |
| 334 | - | - Item page UI changes: ~40 lines | |
| 335 | - | - Library query update: ~10 lines | |
| 336 | - | - Tests: ~250 lines | |
| 337 | - | - **Total: ~720 lines** | |
| 338 | - | ||
| 339 | - | ## Free Items | |
| 340 | - | ||
| 341 | - | For free items (price = 0), Stripe Checkout doesn't work (can't charge $0). Two options: | |
| 342 | - | ||
| 343 | - | **Option A**: Use Stripe's `setup` mode to collect email without charging. Overkill. | |
| 344 | - | ||
| 345 | - | **Option B**: Skip Stripe entirely for free items. The guest checkout endpoint for $0 items: | |
| 346 | - | 1. Collect email via a simple form (in the embed or on-site) | |
| 347 | - | 2. Create completed transaction immediately (no Stripe involved) | |
| 348 | - | 3. Send download + claim email | |
| 349 | - | 4. Rate limit aggressively (prevent scraping all free content) | |
| 350 | - | ||
| 351 | - | Option B is simpler and faster. Implement as a branch in the guest checkout handler. |
| @@ -1,128 +0,0 @@ | |||
| 1 | - | # Plan: Buy Button Embed | |
| 2 | - | ||
| 3 | - | The simplest embed — a horizontal strip showing item title, price, and a buy button. Proves the routing, CORS, and iframe pattern that all other embeds build on. | |
| 4 | - | ||
| 5 | - | ## Output | |
| 6 | - | ||
| 7 | - | `/embed/i/{item_id}/button` — a self-contained HTML page (~300x60px) rendered inside an iframe on any external site. Clicking "Buy" opens the MNW purchase page in a new tab. | |
| 8 | - | ||
| 9 | - | ## Steps | |
| 10 | - | ||
| 11 | - | ### 1. Route module (`src/routes/embed/mod.rs`) | |
| 12 | - | ||
| 13 | - | Create new route module with: | |
| 14 | - | - `pub fn router() -> Router<AppState>` registering all embed routes | |
| 15 | - | - Mount at `/embed` in `src/routes/mod.rs` | |
| 16 | - | ||
| 17 | - | ### 2. Security headers override | |
| 18 | - | ||
| 19 | - | In `src/lib.rs` security headers middleware, add a path check: | |
| 20 | - | - If request path starts with `/embed/`, set: | |
| 21 | - | - `X-Frame-Options: ALLOWALL` | |
| 22 | - | - `Content-Security-Policy: frame-ancestors *` | |
| 23 | - | - `Cache-Control: public, max-age=300` | |
| 24 | - | - Otherwise, keep existing `X-Frame-Options: DENY` and current CSP. | |
| 25 | - | ||
| 26 | - | ### 3. Buy button handler (`src/routes/embed/item.rs`) | |
| 27 | - | ||
| 28 | - | ```rust | |
| 29 | - | /// GET /embed/i/{item_id}/button | |
| 30 | - | pub async fn item_button( | |
| 31 | - | State(state): State<AppState>, | |
| 32 | - | Path(item_id): Path<ItemId>, | |
| 33 | - | ) -> Result<Response> | |
| 34 | - | ``` | |
| 35 | - | ||
| 36 | - | Logic: | |
| 37 | - | - Fetch item by ID (return 404 if not found or not public) | |
| 38 | - | - Fetch parent project (for the purchase URL) | |
| 39 | - | - Render `templates/embeds/button.html` with: title, price_display, purchase_url, cover_image_url (optional, for small icon) | |
| 40 | - | ||
| 41 | - | ### 4. Template (`templates/embeds/button.html`) | |
| 42 | - | ||
| 43 | - | Full standalone HTML page. No `{% extends %}`. Structure: | |
| 44 | - | ||
| 45 | - | ```html | |
| 46 | - | <!doctype html> | |
| 47 | - | <html lang="en"> | |
| 48 | - | <head> | |
| 49 | - | <meta charset="UTF-8"> | |
| 50 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 51 | - | <title>{{ title }} — Makenot.work</title> | |
| 52 | - | <style> | |
| 53 | - | /* Inline styles only — MNW brand colors */ | |
| 54 | - | * { margin: 0; padding: 0; box-sizing: border-box; } | |
| 55 | - | body { | |
| 56 | - | font-family: Lato, -apple-system, sans-serif; | |
| 57 | - | background: #ede8e1; | |
| 58 | - | color: #3d3530; | |
| 59 | - | display: flex; | |
| 60 | - | align-items: center; | |
| 61 | - | height: 100vh; | |
| 62 | - | padding: 8px 12px; | |
| 63 | - | } | |
| 64 | - | .embed-button { | |
| 65 | - | display: flex; | |
| 66 | - | align-items: center; | |
| 67 | - | gap: 10px; | |
| 68 | - | width: 100%; | |
| 69 | - | } | |
| 70 | - | .cover { width: 40px; height: 40px; border-radius: 4px; object-fit: cover; } | |
| 71 | - | .info { flex: 1; min-width: 0; } | |
| 72 | - | .title { | |
| 73 | - | font-size: 13px; font-weight: 600; | |
| 74 | - | white-space: nowrap; overflow: hidden; text-overflow: ellipsis; | |
| 75 | - | } | |
| 76 | - | .price { font-size: 12px; opacity: 0.7; font-family: 'IBM Plex Mono', monospace; } | |
| 77 | - | .buy-btn { | |
| 78 | - | background: #6c5ce7; color: #fff; | |
| 79 | - | border: none; border-radius: 4px; | |
| 80 | - | padding: 8px 14px; font-size: 12px; font-weight: 600; | |
| 81 | - | cursor: pointer; text-decoration: none; | |
| 82 | - | white-space: nowrap; | |
| 83 | - | } | |
| 84 | - | .buy-btn:hover { background: #5a4bd6; } | |
| 85 | - | </style> | |
| 86 | - | </head> | |
| 87 | - | <body> | |
| 88 | - | <div class="embed-button"> | |
| 89 | - | {% if let Some(url) = cover_image_url %} | |
| 90 | - | <img class="cover" src="{{ url }}" alt=""> | |
| 91 | - | {% endif %} | |
| 92 | - | <div class="info"> | |
| 93 | - | <div class="title">{{ title }}</div> | |
| 94 | - | <div class="price">{{ price_display }}</div> | |
| 95 | - | </div> | |
| 96 | - | <a class="buy-btn" href="{{ purchase_url }}" target="_blank" rel="noopener">Buy</a> | |
| 97 | - | </div> | |
| 98 | - | </body> | |
| 99 | - | </html> | |
| 100 | - | ``` | |
| 101 | - | ||
| 102 | - | ### 5. Price display logic | |
| 103 | - | ||
| 104 | - | - Free items: "Free" (button text becomes "Get") | |
| 105 | - | - Fixed price: "$X.XX" | |
| 106 | - | - PWYW with $0 min: "Name your price" (button: "Get") | |
| 107 | - | - PWYW with min > 0: "$X.XX+" | |
| 108 | - | ||
| 109 | - | ### 6. Register route | |
| 110 | - | ||
| 111 | - | In `src/routes/mod.rs`, add `.nest("/embed", embed::router())`. | |
| 112 | - | ||
| 113 | - | ### 7. Tests (`tests/embeds/button.rs`) | |
| 114 | - | ||
| 115 | - | - `embed_button_returns_html` — valid item returns 200 with text/html | |
| 116 | - | - `embed_button_private_item_404` — non-public items return 404 | |
| 117 | - | - `embed_button_nonexistent_404` — bad ID returns 404 | |
| 118 | - | - `embed_button_frame_headers` — response has `X-Frame-Options: ALLOWALL` and correct CSP | |
| 119 | - | - `embed_button_cache_headers` — response has `Cache-Control: public, max-age=300` | |
| 120 | - | - `other_routes_still_deny_frames` — normal pages still have `X-Frame-Options: DENY` | |
| 121 | - | ||
| 122 | - | ## Dependencies | |
| 123 | - | ||
| 124 | - | None — this is the foundation embed. No ffmpeg, no JS, no new DB columns. | |
| 125 | - | ||
| 126 | - | ## Estimated scope | |
| 127 | - | ||
| 128 | - | ~200 lines of Rust (route module + handler + registration), ~60 lines HTML template, ~80 lines tests. |
| @@ -1,106 +0,0 @@ | |||
| 1 | - | # Plan: Product Card Embed | |
| 2 | - | ||
| 3 | - | A richer embed showing cover art, title, creator name, price, description excerpt, and buy button. Supports vertical (~350x400px) and horizontal (~600x200px) layouts. | |
| 4 | - | ||
| 5 | - | ## Output | |
| 6 | - | ||
| 7 | - | `/embed/i/{item_id}/card?layout=vertical|horizontal` — a self-contained HTML page rendered inside an iframe. Clicking through opens MNW purchase page. | |
| 8 | - | ||
| 9 | - | ## Prerequisites | |
| 10 | - | ||
| 11 | - | - Buy button embed shipped (route module, CORS setup, frame header override all exist) | |
| 12 | - | ||
| 13 | - | ## Steps | |
| 14 | - | ||
| 15 | - | ### 1. Handler (`src/routes/embed/item.rs`) | |
| 16 | - | ||
| 17 | - | ```rust | |
| 18 | - | /// GET /embed/i/{item_id}/card | |
| 19 | - | pub async fn item_card( | |
| 20 | - | State(state): State<AppState>, | |
| 21 | - | Path(item_id): Path<ItemId>, | |
| 22 | - | Query(params): Query<CardParams>, | |
| 23 | - | ) -> Result<Response> | |
| 24 | - | ``` | |
| 25 | - | ||
| 26 | - | `CardParams`: `layout: Option<String>` (default "vertical", accepts "horizontal") | |
| 27 | - | ||
| 28 | - | Logic: | |
| 29 | - | - Fetch item (404 if not found/not public) | |
| 30 | - | - Fetch parent project + creator user (for creator display name, username) | |
| 31 | - | - Truncate description to ~120 chars for vertical, ~200 for horizontal | |
| 32 | - | - Render `templates/embeds/card.html` | |
| 33 | - | ||
| 34 | - | ### 2. Template (`templates/embeds/card.html`) | |
| 35 | - | ||
| 36 | - | Two layout variants in one template, switched with a CSS class on body. | |
| 37 | - | ||
| 38 | - | **Vertical layout** (~350x400px): | |
| 39 | - | ``` | |
| 40 | - | ┌─────────────────────────┐ | |
| 41 | - | │ Cover Image │ | |
| 42 | - | │ (200px tall) │ | |
| 43 | - | ├─────────────────────────┤ | |
| 44 | - | │ Title │ | |
| 45 | - | │ by @username │ | |
| 46 | - | │ Description excerpt... │ | |
| 47 | - | │ │ | |
| 48 | - | │ $12.00 [Buy] │ | |
| 49 | - | └─────────────────────────┘ | |
| 50 | - | ``` | |
| 51 | - | ||
| 52 | - | **Horizontal layout** (~600x200px): | |
| 53 | - | ``` | |
| 54 | - | ┌────────┬────────────────────────────────────┐ | |
| 55 | - | │ │ Title │ | |
| 56 | - | │ Cover │ by @username │ | |
| 57 | - | │ (150px)│ Description excerpt... │ | |
| 58 | - | │ │ │ | |
| 59 | - | │ │ $12.00 [Buy] │ | |
| 60 | - | └────────┴────────────────────────────────────┘ | |
| 61 | - | ``` | |
| 62 | - | ||
| 63 | - | Styling: | |
| 64 | - | - Cover image with `object-fit: cover`, rounded corners | |
| 65 | - | - Title in Lato semibold, 15px | |
| 66 | - | - Creator name in IBM Plex Mono, 12px, linked to profile (target=_blank) | |
| 67 | - | - Description in Lato regular, 13px, 2-3 lines max with ellipsis | |
| 68 | - | - Price in IBM Plex Mono | |
| 69 | - | - Buy button matches button embed style (violet #6c5ce7) | |
| 70 | - | - Subtle border: `1px solid rgba(61,53,48,0.1)` | |
| 71 | - | - Items with no cover: show a placeholder gradient or just collapse the image area | |
| 72 | - | ||
| 73 | - | ### 3. Context struct | |
| 74 | - | ||
| 75 | - | ```rust | |
| 76 | - | struct CardContext { | |
| 77 | - | title: String, | |
| 78 | - | creator_display_name: String, | |
| 79 | - | creator_username: String, | |
| 80 | - | profile_url: String, | |
| 81 | - | cover_image_url: Option<String>, | |
| 82 | - | price_display: String, | |
| 83 | - | description_excerpt: String, | |
| 84 | - | purchase_url: String, | |
| 85 | - | button_text: String, // "Buy" / "Get" / "Subscribe" | |
| 86 | - | layout: String, // "vertical" / "horizontal" | |
| 87 | - | item_type_label: String, // "Audio" / "Video" / "Text" / etc. | |
| 88 | - | } | |
| 89 | - | ``` | |
| 90 | - | ||
| 91 | - | ### 4. Register route | |
| 92 | - | ||
| 93 | - | Add `GET /embed/i/{item_id}/card` to the embed router. | |
| 94 | - | ||
| 95 | - | ### 5. Tests | |
| 96 | - | ||
| 97 | - | - `embed_card_vertical_default` — no layout param returns vertical | |
| 98 | - | - `embed_card_horizontal` — `?layout=horizontal` returns different dimensions hint | |
| 99 | - | - `embed_card_no_cover` — items without cover still render cleanly | |
| 100 | - | - `embed_card_long_description_truncated` — description doesn't overflow | |
| 101 | - | - `embed_card_free_item` — shows "Free" and "Get" button | |
| 102 | - | - `embed_card_private_404` | |
| 103 | - | ||
| 104 | - | ## Estimated scope | |
| 105 | - | ||
| 106 | - | ~80 lines handler, ~120 lines template (both layouts), ~60 lines tests. |
| @@ -1,169 +0,0 @@ | |||
| 1 | - | # Plan: Audio Preview Player Embed | |
| 2 | - | ||
| 3 | - | The highest-value embed for musician creators. Shows cover art, title, a 30-second preview with play/pause and progress bar, and a buy button. Audio plays directly in the embed without redirect. | |
| 4 | - | ||
| 5 | - | ## Output | |
| 6 | - | ||
| 7 | - | - `/embed/i/{item_id}/player` — HTML page (~350x120px) with inline audio player | |
| 8 | - | - `/embed/i/{item_id}/preview.mp3` — 30-second MP3 clip (128kbps) | |
| 9 | - | ||
| 10 | - | ## Prerequisites | |
| 11 | - | ||
| 12 | - | - Buy button embed shipped (route module, CORS, headers exist) | |
| 13 | - | - ffmpeg available on server (already installed for other media processing) | |
| 14 | - | ||
| 15 | - | ## Steps | |
| 16 | - | ||
| 17 | - | ### 1. Preview audio generation | |
| 18 | - | ||
| 19 | - | **On first request to `/embed/i/{item_id}/preview.mp3`:** | |
| 20 | - | ||
| 21 | - | Handler logic: | |
| 22 | - | 1. Check if item exists, is public, and is an audio item (404 otherwise) | |
| 23 | - | 2. Check S3 for cached preview: `{audio_s3_key}.preview.mp3` | |
| 24 | - | 3. If cached, redirect to presigned S3 URL (or serve via CDN) | |
| 25 | - | 4. If not cached: | |
| 26 | - | a. Download original audio from S3 to temp file | |
| 27 | - | b. Run ffmpeg: `ffmpeg -i input -ss {start} -t 30 -ab 128k -f mp3 output.mp3` | |
| 28 | - | c. Upload preview to S3 at `{audio_s3_key}.preview.mp3` | |
| 29 | - | d. Redirect to presigned URL of the new preview | |
| 30 | - | 5. Set `Cache-Control: public, max-age=86400` | |
| 31 | - | ||
| 32 | - | **Start offset**: Default 0 seconds. Future enhancement: `preview_start_seconds` column on items table lets creators choose. For v1, always start at 0. | |
| 33 | - | ||
| 34 | - | **Edge cases**: | |
| 35 | - | - Audio shorter than 30 seconds: use full duration | |
| 36 | - | - Item has no audio_s3_key: return 404 | |
| 37 | - | - ffmpeg fails: return 500, log error, don't cache | |
| 38 | - | ||
| 39 | - | ### 2. Player embed handler (`src/routes/embed/item.rs`) | |
| 40 | - | ||
| 41 | - | ```rust | |
| 42 | - | /// GET /embed/i/{item_id}/player | |
| 43 | - | pub async fn item_player( | |
| 44 | - | State(state): State<AppState>, | |
| 45 | - | Path(item_id): Path<ItemId>, | |
| 46 | - | ) -> Result<Response> | |
| 47 | - | ``` | |
| 48 | - | ||
| 49 | - | Logic: | |
| 50 | - | - Fetch item (404 if not public or not audio type) | |
| 51 | - | - Render `templates/embeds/player.html` with: title, creator name, cover_image_url, preview_url, purchase_url, price_display, duration_display (of full track) | |
| 52 | - | ||
| 53 | - | ### 3. Template (`templates/embeds/player.html`) | |
| 54 | - | ||
| 55 | - | Layout (~350x120px): | |
| 56 | - | ``` | |
| 57 | - | ┌──────┬──────────────────────────────────────┐ | |
| 58 | - | │ │ Title $9.99 │ | |
| 59 | - | │ Cover│ by @artist │ | |
| 60 | - | │ 80px │ [▶] ━━━━━━━━━━━━━━━━ 0:00/0:30 │ | |
| 61 | - | │ │ 30-sec preview [Buy] │ | |
| 62 | - | └──────┴──────────────────────────────────────┘ | |
| 63 | - | ``` | |
| 64 | - | ||
| 65 | - | **JavaScript (minimal, inline)**: | |
| 66 | - | ```javascript | |
| 67 | - | const audio = new Audio(); | |
| 68 | - | const playBtn = document.getElementById('play'); | |
| 69 | - | const progress = document.getElementById('progress'); | |
| 70 | - | const time = document.getElementById('time'); | |
| 71 | - | ||
| 72 | - | // Lazy-load audio on first play | |
| 73 | - | let loaded = false; | |
| 74 | - | playBtn.onclick = () => { | |
| 75 | - | if (!loaded) { | |
| 76 | - | audio.src = '{{ preview_url }}'; | |
| 77 | - | loaded = true; | |
| 78 | - | } | |
| 79 | - | if (audio.paused) { audio.play(); playBtn.textContent = '⏸'; } | |
| 80 | - | else { audio.pause(); playBtn.textContent = '▶'; } | |
| 81 | - | }; | |
| 82 | - | ||
| 83 | - | audio.ontimeupdate = () => { | |
| 84 | - | const pct = (audio.currentTime / audio.duration) * 100; | |
| 85 | - | progress.style.width = pct + '%'; | |
| 86 | - | time.textContent = fmt(audio.currentTime) + '/' + fmt(audio.duration); | |
| 87 | - | }; | |
| 88 | - | ||
| 89 | - | audio.onended = () => { playBtn.textContent = '▶'; }; | |
| 90 | - | ||
| 91 | - | // Click-to-seek on progress bar | |
| 92 | - | document.getElementById('progress-bar').onclick = (e) => { | |
| 93 | - | const rect = e.currentTarget.getBoundingClientRect(); | |
| 94 | - | audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration; | |
| 95 | - | }; | |
| 96 | - | ||
| 97 | - | function fmt(s) { | |
| 98 | - | const m = Math.floor(s/60), sec = Math.floor(s%60); | |
| 99 | - | return m + ':' + (sec < 10 ? '0' : '') + sec; | |
| 100 | - | } | |
| 101 | - | ``` | |
| 102 | - | ||
| 103 | - | **Styling**: | |
| 104 | - | - Play button: circular, violet background, white icon | |
| 105 | - | - Progress bar: thin horizontal bar, beige track, violet fill | |
| 106 | - | - Cover: 80x80px rounded | |
| 107 | - | - "30-sec preview" label in 10px muted text below progress bar | |
| 108 | - | - No volume control (keep it simple — 30 seconds, they can adjust system volume) | |
| 109 | - | ||
| 110 | - | ### 4. Preview audio handler (`src/routes/embed/item.rs`) | |
| 111 | - | ||
| 112 | - | ```rust | |
| 113 | - | /// GET /embed/i/{item_id}/preview.mp3 | |
| 114 | - | pub async fn item_preview_audio( | |
| 115 | - | State(state): State<AppState>, | |
| 116 | - | Path(item_id): Path<ItemId>, | |
| 117 | - | ) -> Result<Response> | |
| 118 | - | ``` | |
| 119 | - | ||
| 120 | - | Implementation: | |
| 121 | - | - Needs `tokio::process::Command` to shell out to ffmpeg | |
| 122 | - | - Temp file in `/tmp/mnw-previews/` (cleaned up after upload) | |
| 123 | - | - S3 operations for check/upload/presign | |
| 124 | - | ||
| 125 | - | ### 5. ffmpeg utility (`src/media/preview.rs` or `src/routes/embed/preview.rs`) | |
| 126 | - | ||
| 127 | - | ```rust | |
| 128 | - | pub async fn generate_preview( | |
| 129 | - | s3: &S3Client, | |
| 130 | - | audio_s3_key: &str, | |
| 131 | - | start_seconds: u32, | |
| 132 | - | duration_seconds: u32, // 30 | |
| 133 | - | ) -> Result<String> // returns S3 key of preview | |
| 134 | - | ``` | |
| 135 | - | ||
| 136 | - | Steps: | |
| 137 | - | 1. `s3.download_to_file(audio_s3_key, &tmp_input)` | |
| 138 | - | 2. Run ffmpeg command | |
| 139 | - | 3. `s3.upload_file(&tmp_output, &preview_key, "audio/mpeg")` | |
| 140 | - | 4. Clean up temp files | |
| 141 | - | 5. Return preview_key | |
| 142 | - | ||
| 143 | - | ### 6. Register routes | |
| 144 | - | ||
| 145 | - | ```rust | |
| 146 | - | .route("/embed/i/:item_id/player", get(item_player)) | |
| 147 | - | .route("/embed/i/:item_id/preview.mp3", get(item_preview_audio)) | |
| 148 | - | ``` | |
| 149 | - | ||
| 150 | - | ### 7. Tests | |
| 151 | - | ||
| 152 | - | - `embed_player_audio_item_200` — audio item returns player HTML | |
| 153 | - | - `embed_player_non_audio_404` — text/video items return 404 | |
| 154 | - | - `embed_player_private_404` | |
| 155 | - | - `embed_preview_generates_mp3` — mock ffmpeg, verify S3 upload | |
| 156 | - | - `embed_preview_serves_cached` — second request hits S3 cache, no ffmpeg | |
| 157 | - | - `embed_preview_short_audio` — audio < 30s uses full duration | |
| 158 | - | - `embed_preview_headers` — correct CORS, cache-control, content-type | |
| 159 | - | ||
| 160 | - | ## Performance considerations | |
| 161 | - | ||
| 162 | - | - Preview generation is expensive (~2-5s). First request for each item is slow. | |
| 163 | - | - After first request, preview is cached in S3 indefinitely (until source changes). | |
| 164 | - | - Could add a pre-generation step: when an audio item is published, queue preview generation in background. For v1, lazy generation on first request is simpler. | |
| 165 | - | - Rate-limit the preview endpoint to prevent abuse (generating previews for every item at once). | |
| 166 | - | ||
| 167 | - | ## Estimated scope | |
| 168 | - | ||
| 169 | - | ~150 lines handler code, ~100 lines ffmpeg utility, ~130 lines template (HTML+CSS+JS), ~100 lines tests. Most complex embed due to audio generation. |
| @@ -1,62 +0,0 @@ | |||
| 1 | - | # Plan: Tip Button Embed | |
| 2 | - | ||
| 3 | - | A minimal "Support this creator" button for embedding on personal websites, blogs, and portfolios. Shows creator avatar, display name, and a tip button. | |
| 4 | - | ||
| 5 | - | ## Output | |
| 6 | - | ||
| 7 | - | `/embed/u/{username}/tip` — a self-contained HTML page (~250x50px) with creator info and a support button linking to their tip page on MNW. | |
| 8 | - | ||
| 9 | - | ## Prerequisites | |
| 10 | - | ||
| 11 | - | - Buy button embed shipped (route module, CORS, headers exist) | |
| 12 | - | - Tips feature implemented (already live — `tips_enabled` on users) | |
| 13 | - | ||
| 14 | - | ## Steps | |
| 15 | - | ||
| 16 | - | ### 1. Handler (`src/routes/embed/user.rs`) | |
| 17 | - | ||
| 18 | - | ```rust | |
| 19 | - | /// GET /embed/u/{username}/tip | |
| 20 | - | pub async fn tip_button( | |
| 21 | - | State(state): State<AppState>, | |
| 22 | - | Path(username): Path<String>, | |
| 23 | - | ) -> Result<Response> | |
| 24 | - | ``` | |
| 25 | - | ||
| 26 | - | Logic: | |
| 27 | - | - Fetch user by username (404 if not found, deactivated, or suspended) | |
| 28 | - | - Check `tips_enabled` (404 if tips disabled — don't expose a broken widget) | |
| 29 | - | - Render `templates/embeds/tip_button.html` with: display_name, username, avatar_url, tip_url | |
| 30 | - | ||
| 31 | - | ### 2. Template (`templates/embeds/tip_button.html`) | |
| 32 | - | ||
| 33 | - | Layout (~250x50px): | |
| 34 | - | ``` | |
| 35 | - | ┌────┬─────────────────────┬─────────────┐ | |
| 36 | - | │ AV │ Support @username │ [Support] │ | |
| 37 | - | └────┴─────────────────────┴─────────────┘ | |
| 38 | - | ``` | |
| 39 | - | ||
| 40 | - | - Avatar: 32x32px circle | |
| 41 | - | - "Support @username" in Lato, 13px | |
| 42 | - | - Button: violet, "Support" text | |
| 43 | - | - Clicking button opens `/u/{username}/tip` in new tab | |
| 44 | - | - Clicking name opens `/u/{username}` in new tab | |
| 45 | - | ||
| 46 | - | ### 3. Register route | |
| 47 | - | ||
| 48 | - | ```rust | |
| 49 | - | .route("/embed/u/:username/tip", get(tip_button)) | |
| 50 | - | ``` | |
| 51 | - | ||
| 52 | - | ### 4. Tests | |
| 53 | - | ||
| 54 | - | - `embed_tip_button_200` — creator with tips enabled returns HTML | |
| 55 | - | - `embed_tip_button_tips_disabled_404` — creator without tips returns 404 | |
| 56 | - | - `embed_tip_button_nonexistent_user_404` | |
| 57 | - | - `embed_tip_button_suspended_404` | |
| 58 | - | - `embed_tip_button_frame_headers` | |
| 59 | - | ||
| 60 | - | ## Estimated scope | |
| 61 | - | ||
| 62 | - | ~50 lines handler, ~50 lines template, ~40 lines tests. Simplest embed after buy button. |
| @@ -1,87 +0,0 @@ | |||
| 1 | - | # Plan: Project Card Embed | |
| 2 | - | ||
| 3 | - | Shows a project's cover art, title, creator, item count, description excerpt, and a "View" button linking to the project page. | |
| 4 | - | ||
| 5 | - | ## Output | |
| 6 | - | ||
| 7 | - | `/embed/p/{project_slug}/card` — a self-contained HTML page (~350x300px vertical card) rendered in an iframe. | |
| 8 | - | ||
| 9 | - | ## Prerequisites | |
| 10 | - | ||
| 11 | - | - Product card embed shipped (template pattern and card styling established) | |
| 12 | - | ||
| 13 | - | ## Steps | |
| 14 | - | ||
| 15 | - | ### 1. Handler (`src/routes/embed/project.rs`) | |
| 16 | - | ||
| 17 | - | ```rust | |
| 18 | - | /// GET /embed/p/{project_slug}/card | |
| 19 | - | pub async fn project_card( | |
| 20 | - | State(state): State<AppState>, | |
| 21 | - | Path(project_slug): Path<String>, | |
| 22 | - | ) -> Result<Response> | |
| 23 | - | ``` | |
| 24 | - | ||
| 25 | - | Logic: | |
| 26 | - | - Fetch project by slug (404 if not found or not public) | |
| 27 | - | - Fetch creator user | |
| 28 | - | - Count public items in the project | |
| 29 | - | - Truncate description to ~150 chars | |
| 30 | - | - Render `templates/embeds/project_card.html` | |
| 31 | - | ||
| 32 | - | ### 2. Template (`templates/embeds/project_card.html`) | |
| 33 | - | ||
| 34 | - | Layout (~350x300px): | |
| 35 | - | ``` | |
| 36 | - | ┌─────────────────────────┐ | |
| 37 | - | │ Cover Image │ | |
| 38 | - | │ (160px tall) │ | |
| 39 | - | ├─────────────────────────┤ | |
| 40 | - | │ Project Title │ | |
| 41 | - | │ by @username │ | |
| 42 | - | │ 12 items · Audio │ | |
| 43 | - | │ Description excerpt... │ | |
| 44 | - | │ │ | |
| 45 | - | │ [View] │ | |
| 46 | - | └─────────────────────────┘ | |
| 47 | - | ``` | |
| 48 | - | ||
| 49 | - | Styling: | |
| 50 | - | - Matches product card visual language (same border, radius, fonts) | |
| 51 | - | - Item count + category shown as metadata line | |
| 52 | - | - "View" button instead of "Buy" (links to `/p/{slug}`, target=_blank) | |
| 53 | - | - No cover: collapse image area, card becomes shorter | |
| 54 | - | ||
| 55 | - | ### 3. Context struct | |
| 56 | - | ||
| 57 | - | ```rust | |
| 58 | - | struct ProjectCardContext { | |
| 59 | - | title: String, | |
| 60 | - | creator_display_name: String, | |
| 61 | - | creator_username: String, | |
| 62 | - | profile_url: String, | |
| 63 | - | cover_image_url: Option<String>, | |
| 64 | - | description_excerpt: String, | |
| 65 | - | project_url: String, | |
| 66 | - | item_count: i64, | |
| 67 | - | category_label: String, | |
| 68 | - | } | |
| 69 | - | ``` | |
| 70 | - | ||
| 71 | - | ### 4. Register route | |
| 72 | - | ||
| 73 | - | ```rust | |
| 74 | - | .route("/embed/p/:project_slug/card", get(project_card)) | |
| 75 | - | ``` | |
| 76 | - | ||
| 77 | - | ### 5. Tests | |
| 78 | - | ||
| 79 | - | - `embed_project_card_200` — public project renders card | |
| 80 | - | - `embed_project_card_private_404` | |
| 81 | - | - `embed_project_card_no_cover` — renders without image section | |
| 82 | - | - `embed_project_card_item_count` — shows correct count of public items only | |
| 83 | - | - `embed_project_card_frame_headers` | |
| 84 | - | ||
| 85 | - | ## Estimated scope | |
| 86 | - | ||
| 87 | - | ~60 lines handler, ~90 lines template, ~50 lines tests. |
| @@ -1,176 +0,0 @@ | |||
| 1 | - | # Plan: Embed Code Generator UI | |
| 2 | - | ||
| 3 | - | Add "Get embed code" sections to the creator dashboard so creators can copy embed snippets for their items, projects, and profile. | |
| 4 | - | ||
| 5 | - | ## Output | |
| 6 | - | ||
| 7 | - | Embed code sections in: | |
| 8 | - | - Item dashboard page — shows Buy Button, Product Card, and (if audio) Audio Player embed codes | |
| 9 | - | - Project dashboard page — shows Project Card embed code | |
| 10 | - | - Creator settings/profile — shows Tip Button embed code (if tips enabled) | |
| 11 | - | ||
| 12 | - | ## Prerequisites | |
| 13 | - | ||
| 14 | - | - All embed endpoints shipped and working | |
| 15 | - | - Templates rendering correctly | |
| 16 | - | ||
| 17 | - | ## Steps | |
| 18 | - | ||
| 19 | - | ### 1. Item dashboard embed section | |
| 20 | - | ||
| 21 | - | In `templates/pages/dashboard/item.html` (or the item edit page), add an "Embed" collapsible section below the existing content: | |
| 22 | - | ||
| 23 | - | ```html | |
| 24 | - | <details class="embed-section"> | |
| 25 | - | <summary>Embed codes</summary> | |
| 26 | - | <div class="embed-options"> | |
| 27 | - | <!-- Buy Button --> | |
| 28 | - | <div class="embed-option"> | |
| 29 | - | <h4>Buy Button</h4> | |
| 30 | - | <p>Minimal strip with title, price, and buy button. 300x60px.</p> | |
| 31 | - | <div class="embed-preview"> | |
| 32 | - | <iframe src="/embed/i/{{ item.id }}/button" width="300" height="60" frameborder="0"></iframe> | |
| 33 | - | </div> | |
| 34 | - | <div class="embed-code"> | |
| 35 | - | <code><iframe src="https://makenot.work/embed/i/{{ item.id }}/button" width="300" height="60" frameborder="0" title="Buy {{ item.title }} on Makenot.work"></iframe></code> | |
| 36 | - | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 37 | - | </div> | |
| 38 | - | </div> | |
| 39 | - | ||
| 40 | - | <!-- Product Card --> | |
| 41 | - | <div class="embed-option"> | |
| 42 | - | <h4>Product Card</h4> | |
| 43 | - | <p>Rich card with cover art, description, and buy button.</p> | |
| 44 | - | <div class="embed-layout-toggle"> | |
| 45 | - | <label><input type="radio" name="card-layout" value="vertical" checked> Vertical (350x400)</label> | |
| 46 | - | <label><input type="radio" name="card-layout" value="horizontal"> Horizontal (600x200)</label> | |
| 47 | - | </div> | |
| 48 | - | <div class="embed-preview"> | |
| 49 | - | <iframe id="card-preview" src="/embed/i/{{ item.id }}/card" width="350" height="400" frameborder="0"></iframe> | |
| 50 | - | </div> | |
| 51 | - | <div class="embed-code"> | |
| 52 | - | <code id="card-code">...</code> | |
| 53 | - | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 54 | - | </div> | |
| 55 | - | </div> | |
| 56 | - | ||
| 57 | - | <!-- Audio Player (only for audio items) --> | |
| 58 | - | {% if item.item_type == "audio" %} | |
| 59 | - | <div class="embed-option"> | |
| 60 | - | <h4>Audio Preview</h4> | |
| 61 | - | <p>30-second preview player with cover art and buy button. 350x120px.</p> | |
| 62 | - | <div class="embed-preview"> | |
| 63 | - | <iframe src="/embed/i/{{ item.id }}/player" width="350" height="120" frameborder="0"></iframe> | |
| 64 | - | </div> | |
| 65 | - | <div class="embed-code"> | |
| 66 | - | <code>...</code> | |
| 67 | - | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 68 | - | </div> | |
| 69 | - | </div> | |
| 70 | - | {% endif %} | |
| 71 | - | </div> | |
| 72 | - | </details> | |
| 73 | - | ``` | |
| 74 | - | ||
| 75 | - | ### 2. Project dashboard embed section | |
| 76 | - | ||
| 77 | - | In the project edit/view page, add a similar collapsible: | |
| 78 | - | ||
| 79 | - | ```html | |
| 80 | - | <details class="embed-section"> | |
| 81 | - | <summary>Embed code</summary> | |
| 82 | - | <div class="embed-option"> | |
| 83 | - | <h4>Project Card</h4> | |
| 84 | - | <div class="embed-preview"> | |
| 85 | - | <iframe src="/embed/p/{{ project.slug }}/card" width="350" height="300" frameborder="0"></iframe> | |
| 86 | - | </div> | |
| 87 | - | <div class="embed-code"> | |
| 88 | - | <code>...</code> | |
| 89 | - | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 90 | - | </div> | |
| 91 | - | </div> | |
| 92 | - | </details> | |
| 93 | - | ``` | |
| 94 | - | ||
| 95 | - | ### 3. Tip button in creator settings | |
| 96 | - | ||
| 97 | - | In the profile/settings page (where tips are configured), add: | |
| 98 | - | ||
| 99 | - | ```html | |
| 100 | - | {% if user.tips_enabled %} | |
| 101 | - | <details class="embed-section"> | |
| 102 | - | <summary>Tip button embed</summary> | |
| 103 | - | <div class="embed-preview"> | |
| 104 | - | <iframe src="/embed/u/{{ user.username }}/tip" width="250" height="50" frameborder="0"></iframe> | |
| 105 | - | </div> | |
| 106 | - | <div class="embed-code"> | |
| 107 | - | <code>...</code> | |
| 108 | - | <button onclick="copyEmbed(this)" class="copy-btn">Copy</button> | |
| 109 | - | </div> | |
| 110 | - | </details> | |
| 111 | - | {% endif %} | |
| 112 | - | ``` | |
| 113 | - | ||
| 114 | - | ### 4. Copy-to-clipboard JavaScript | |
| 115 | - | ||
| 116 | - | Small inline script (reusable across all embed sections): | |
| 117 | - | ||
| 118 | - | ```javascript | |
| 119 | - | function copyEmbed(btn) { | |
| 120 | - | const code = btn.previousElementSibling.textContent; | |
| 121 | - | navigator.clipboard.writeText(code).then(() => { | |
| 122 | - | btn.textContent = 'Copied!'; | |
| 123 | - | setTimeout(() => btn.textContent = 'Copy', 1500); | |
| 124 | - | }); | |
| 125 | - | } | |
| 126 | - | ``` | |
| 127 | - | ||
| 128 | - | ### 5. Layout toggle JavaScript (product card) | |
| 129 | - | ||
| 130 | - | ```javascript | |
| 131 | - | document.querySelectorAll('input[name="card-layout"]').forEach(radio => { | |
| 132 | - | radio.onchange = () => { | |
| 133 | - | const layout = radio.value; | |
| 134 | - | const iframe = document.getElementById('card-preview'); | |
| 135 | - | const code = document.getElementById('card-code'); | |
| 136 | - | const w = layout === 'horizontal' ? 600 : 350; | |
| 137 | - | const h = layout === 'horizontal' ? 200 : 400; | |
| 138 | - | iframe.src = `/embed/i/{{ item.id }}/card?layout=${layout}`; | |
| 139 | - | iframe.width = w; | |
| 140 | - | iframe.height = h; | |
| 141 | - | code.textContent = `<iframe src="https://makenot.work/embed/i/{{ item.id }}/card?layout=${layout}" width="${w}" height="${h}" frameborder="0" title="{{ item.title }} on Makenot.work"></iframe>`; | |
| 142 | - | }; | |
| 143 | - | }); | |
| 144 | - | ``` | |
| 145 | - | ||
| 146 | - | ### 6. Styling | |
| 147 | - | ||
| 148 | - | ```css | |
| 149 | - | .embed-section { margin-top: 1.5rem; } | |
| 150 | - | .embed-section summary { cursor: pointer; font-weight: 600; } | |
| 151 | - | .embed-option { margin: 1rem 0; padding: 1rem; border: 1px solid var(--border); border-radius: 6px; } | |
| 152 | - | .embed-option h4 { margin-bottom: 0.5rem; } | |
| 153 | - | .embed-preview { margin: 0.75rem 0; } | |
| 154 | - | .embed-code { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; } | |
| 155 | - | .embed-code code { | |
| 156 | - | flex: 1; padding: 0.5rem; background: var(--light-background); | |
| 157 | - | border-radius: 4px; font-size: 11px; overflow-x: auto; | |
| 158 | - | white-space: nowrap; font-family: 'IBM Plex Mono', monospace; | |
| 159 | - | } | |
| 160 | - | .copy-btn { | |
| 161 | - | padding: 6px 12px; font-size: 12px; | |
| 162 | - | background: var(--primary); color: #fff; border: none; | |
| 163 | - | border-radius: 4px; cursor: pointer; | |
| 164 | - | } | |
| 165 | - | ``` | |
| 166 | - | ||
| 167 | - | ## Implementation notes | |
| 168 | - | ||
| 169 | - | - The embed sections use `<details>` so they're collapsed by default (non-intrusive) | |
| 170 | - | - Live preview iframes load the actual embed so creators see exactly what fans will see | |
| 171 | - | - Only show embed section for public items/projects (no point embedding private content) | |
| 172 | - | - Item must be published for embed to render — show "Publish your item first" message if draft | |
| 173 | - | ||
| 174 | - | ## Estimated scope | |
| 175 | - | ||
| 176 | - | ~150 lines template additions across 3 pages, ~30 lines JS, ~40 lines CSS. |