Skip to main content

max / makenotwork

chore: move internal server/docs files to private store Move 17 top-level docs (audits, todos, deploy, smoke/test plans, doc matrices, liability draft, testnot.work plan, import research, content seed, stripe-rc5 migration note) and 9 plans/* roadmap docs to private store. Public docs retained: api_reference, architecture, cli, design-system, frontend, oauth_integration, patterns, performance_philosophy, schema, troubleshooting, cicd, scaling.
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-21 01:58 UTC
Commit: 12537c4cd36adb77b96f513bba7330d37f7d2cd7
Parent: 3883890
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>&lt;iframe src="https://makenot.work/embed/i/{{ item.id }}/button" width="300" height="60" frameborder="0" title="Buy {{ item.title }} on Makenot.work"&gt;&lt;/iframe&gt;</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.