Skip to main content

max / makenotwork

Add guest checkout, embeddable widgets, and trust audit fixes Guest checkout: fans can purchase without an MNW account. Stripe collects email, download link sent immediately, purchases auto-attach when they later create an account. Free item guest claiming included. Direct purchase page at /buy/{item_id} for link-in-bio sharing. Embeddable widgets: five iframe-based embed types (buy button, product card, audio player, tip button, project card) served from /embed/ with permissive frame headers. Dashboard "Embed" tab with live previews and copy-to-clipboard code snippets. Trust audit fixes: IP scrubbing bug (wrong column name), encryption docs clarified as infrastructure-provided, streaming tier blocked from purchase, 90-day buyer access grace period on creator account deletion, video docs updated to reflect implemented state, chargeback fee documented, sandbox linked from creators page, fan guide updated with discovery and guest checkout sections. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-27 03:17 UTC
Commit: bd0e8e721a63a48b26654c40e23c1717c153f92f
Parent: 528e7a7
60 files changed, +3602 insertions, -48 deletions
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.4.2"
3 + version = "0.4.3"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -0,0 +1,351 @@
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.
@@ -0,0 +1,128 @@
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.
@@ -0,0 +1,106 @@
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.
@@ -0,0 +1,169 @@
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.
@@ -0,0 +1,62 @@
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.
@@ -0,0 +1,87 @@
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.
@@ -0,0 +1,176 @@
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.
@@ -0,0 +1,73 @@
1 + # Creator Trust Audit — Findings & Fixes
2 +
3 + Audit date: 2026-04-26. Perspective: skeptical creator evaluating MNW.
4 +
5 + ## Deal Breakers
6 +
7 + - [x] **IP scrubbing bug**: `scheduler.rs` line 872 queries `downloaded_at` which doesn't exist (column is `created_at`). IPs never deleted. Violates privacy policy's 30-day claim.
8 + - [x] **Streaming tier sold but unimplemented**: $40/mo tier on `/creators` pricing table has zero code (no RTMP, no chat, no streaming). Blocked from purchase in checkout + labeled "coming soon" on pricing table.
9 +
10 + ## Trust Gaps
11 +
12 + - [x] **Encryption docs overstate implementation**: `tech/security.md` says "AES-256 disk encryption" without clarifying it's infrastructure-provided (Hetzner), not application-level. Misleading to security-conscious creators.
13 + - [x] **Streaming tier on creators page lacks "coming soon"**: Pricing table at `/creators` lists Streaming at $40 without any indication it's unimplemented.
14 + - [x] **Fan access lost on creator account deletion**: 90-day content grace period implemented. Creators with sales get deactivated (not deleted) for 90 days; buyers can still download. Guarantee language updated.
15 + - [x] **Git repos excluded from data export**: Non-issue. Git repos are inherently exportable (`git clone`). Not "uploaded content" in the same sense as media files. Export README already notes how to clone.
16 + - [x] **Video features in Big Files tier not functional**: Was actually implemented (upload, player, access control, tests). Docs were stale. Updated tiers.md — only transcoding/adaptive streaming remain planned.
17 + - [ ] **Content archive guarantee unimplemented**: 12-month content preservation listed under "Guarantees" (albeit in "Planned" subsection). Could be mistaken for current feature.
18 +
19 + ## Missing Information (docs additions needed)
20 +
21 + - [x] **Chargeback fee not documented**: Added dispute/chargeback section to payouts.md.
22 + - [ ] **Custom domains not prominently documented**: Feature exists but hard to find from getting-started flow.
23 + - [ ] **No creator storefront preview/demo**: First-time visitors can't see what a page looks like.
24 + - [x] **Sandbox not linked from /creators page**: Added "Try sandbox mode" link above the CTA.
25 +
26 + ## Competitive Weaknesses (product decisions, not bugs)
27 +
28 + - [ ] No free creator tier ($10/mo minimum vs. $0 on Bandcamp/itch.io/Gumroad)
29 + - [x] No public discovery/browse mechanism: Already implemented (/discover, /discover/tags, /feed, tag filters, search). Added discovery section to fan-guide.md.
30 + - [ ] No mobile fan apps
31 + - [x] Embeddable widgets — all 5 embed types + dashboard UI + guest checkout shipped
32 + - [ ] Low social proof (unknown creator count shown on page)
33 +
34 + ## Embeds (plans at `docs/plans/embed-*.md`)
35 +
36 + - [x] Guest checkout (embed-0) — complete: schema, paid checkout, free claim, buy page, purchase page UI, emails, auto-attach on signup, integration tests.
37 + - [x] Buy button embed (embed-1) — route, CORS, inline HTML, 300x60 strip
38 + - [x] Product card embed (embed-2) — vertical/horizontal layouts with cover, creator, description
39 + - [x] Audio player embed (embed-3) — inline player with play/pause/seek/progress (uses stream endpoint; ffmpeg preview generation deferred)
40 + - [x] Tip button embed (embed-4) — avatar + "Support @username" + button
41 + - [x] Project card embed (embed-5) — cover, title, creator, item count, category
42 + - [x] Dashboard UI (embed-6) — "Embed" tab on item dashboard with live previews, copy buttons, layout toggle, and direct purchase link
43 +
44 + ## Contradictions Found
45 +
46 + | Claim | Reality | Severity | Status |
47 + |-------|---------|----------|--------|
48 + | "IPs deleted after 30 days" (privacy policy) | Query references wrong column; never executes | High | Fixed |
49 + | "All uploaded content in original quality" (guarantees) | Git repos excluded from export | Medium | Non-issue (git clone) |
50 + | "AES-256 encryption at rest" (security.md) | Infrastructure-only, no app-level encryption | Medium | Fixed (docs) |
51 + | "Creators own fan relationships" (guarantees) | Account deletion CASCADE removes fan access | High | Fixed (90-day grace) |
52 + | Streaming tier $40/mo (creators page) | Zero implementation | High | Fixed (blocked + labeled) |
53 +
54 + ## Vaporware (all honestly labeled in docs)
55 +
56 + | Feature | Status |
57 + |---------|--------|
58 + | Live streaming (RTMP, chat, clips) | Not implemented |
59 + | Fan+ subscription | DB only, no UI |
60 + | Video transcoding/adaptive | Partial (upload works, playback missing) |
61 + | Content Archive (12-month preservation) | Not implemented |
62 + | Independent moderation appeals | Not implemented (one-person team) |
63 + | 99.9% uptime | Not implemented (currently 99.5% target) |
64 +
65 + ## Key Paths
66 +
67 + - `server/src/scheduler.rs` — IP scrubbing job
68 + - `server/site-docs/public/tech/security.md` — encryption claims
69 + - `server/site-docs/public/guide/tiers.md` — tier descriptions
70 + - `server/site-docs/public/about/guarantees.md` — binding commitments
71 + - `server/templates/pages/creators.html` — creator pricing table
72 + - `server/src/routes/stripe/` — payment implementation
73 + - `server/src/routes/api/export.rs` — export endpoints
@@ -0,0 +1,6 @@
1 + -- When a creator with purchased content deletes their account, we keep
2 + -- their items accessible for 90 days so buyers can download their files.
3 + -- After content_removal_at passes, the scheduler removes S3 objects and
4 + -- CASCADE-deletes the user row.
5 +
6 + ALTER TABLE users ADD COLUMN content_removal_at TIMESTAMPTZ;
@@ -0,0 +1,26 @@
1 + -- Guest checkout: allow purchases without an MNW account.
2 + -- buyer_id becomes nullable; guest purchases store the buyer's email from Stripe.
3 + -- download_token provides authenticated-less download links sent via email.
4 + -- claim_token allows attaching the purchase to an account later.
5 +
6 + ALTER TABLE transactions ALTER COLUMN buyer_id DROP NOT NULL;
7 +
8 + ALTER TABLE transactions ADD COLUMN guest_email VARCHAR(255);
9 + ALTER TABLE transactions ADD COLUMN claim_token UUID;
10 + ALTER TABLE transactions ADD COLUMN claimed_by UUID REFERENCES users(id) ON DELETE SET NULL;
11 + ALTER TABLE transactions ADD COLUMN download_token UUID DEFAULT gen_random_uuid();
12 +
13 + -- Look up transactions by claim token (one-time claim flow).
14 + CREATE INDEX idx_transactions_claim_token ON transactions(claim_token) WHERE claim_token IS NOT NULL;
15 +
16 + -- Look up guest purchases by email (auto-attach on signup).
17 + CREATE INDEX idx_transactions_guest_email ON transactions(guest_email) WHERE guest_email IS NOT NULL;
18 +
19 + -- Look up transactions by download token (email download links).
20 + CREATE UNIQUE INDEX idx_transactions_download_token ON transactions(download_token) WHERE download_token IS NOT NULL;
21 +
22 + -- The existing partial unique index on (buyer_id, item_id) WHERE status = 'pending'
23 + -- still works for logged-in purchases. Add a separate one for guest deduplication.
24 + CREATE UNIQUE INDEX idx_transactions_pending_guest_item
25 + ON transactions(guest_email, item_id)
26 + WHERE status = 'pending' AND guest_email IS NOT NULL;
@@ -46,7 +46,20 @@ Exports include:
46 46
47 47 When fans subscribe or follow you, they can share contact information directly with you. We facilitate the connection; you own it.
48 48
49 - If you leave, your fans come with you.
49 + If you leave, your fan contact list is yours (export it anytime). See Buyer Access below for what happens to purchased content.
50 +
51 + ---
52 +
53 + ## Buyer Access
54 +
55 + **Guarantee:** If a creator deletes their account, fans who purchased content retain download access for 90 days.
56 +
57 + - Purchased items remain accessible for 90 days after the creator leaves.
58 + - After 90 days, content is removed from our servers.
59 + - Buyers are notified when the grace period begins so they can download their files.
60 + - Transaction records are preserved indefinitely (receipts remain valid).
61 +
62 + This gives buyers a reasonable window to retrieve content they paid for, while still allowing creators to fully remove their data from the platform.
50 63
51 64 ---
52 65
@@ -133,7 +133,7 @@ After your first publish, here's what to focus on:
133 133
134 134 1. **Fill out your profile.** Bio, avatar, header image, links. This is your storefront. See [Profile](./profile.md).
135 135 2. **Set up security.** Enable two-factor authentication and save your backup codes. See [Security](../tech/security.md).
136 - 3. **Share your link.** Post your profile URL or project URL wherever your audience is.
136 + 3. **Share your link.** Post your profile URL, project URL, or direct purchase link (`/buy/{item_id}`) wherever your audience is. Direct purchase links are minimal, focused pages optimized for social media and link-in-bio — fans can buy in one step without an account.
137 137 4. **Set up RSS cross-posting.** Connect your RSS feed to social media or newsletter tools. See [RSS](./rss.md).
138 138 5. **Fill in metadata.** Good titles, descriptions, tags, and cover art make your content discoverable and shareable. See [Metadata](./metadata.md). Per-file size limits and supported formats depend on your tier — see [Pricing Tiers](./tiers.md) for specifics.
139 139 6. **Join the forum.** Say hello at [forums.makenot.work](https://forums.makenot.work). It's where platform feedback, feature requests, and creator-to-creator discussion happen.
@@ -47,6 +47,34 @@ Generate single-use codes for free access:
47 47 - **Optional expiration** — Codes expire after a date
48 48 - **Useful for** — Press copies, review access, promotional giveaways
49 49
50 + ## Guest Checkout
51 +
52 + Fans can purchase your content without creating an account. This removes the biggest friction point between "I want this" and "I have this."
53 +
54 + **How it works for fans:**
55 + 1. Fan clicks "Buy Now" on your item page, embed, or direct purchase link
56 + 2. Stripe checkout collects their email and payment
57 + 3. Fan receives a download link via email immediately
58 + 4. Optionally, they can create an account later to manage all purchases in one place
59 +
60 + **What this means for you:**
61 + - Higher conversion rates — no sign-up barrier between fan and purchase
62 + - Fans who buy first and create accounts later have all purchases automatically attached
63 + - Works everywhere: your item pages, embedded buy buttons, and direct purchase links
64 +
65 + Guest checkout is the default for logged-out visitors. Fans with accounts still see the normal purchase flow with library integration.
66 +
67 + ## Direct Purchase Links
68 +
69 + Every item has a dedicated purchase page at `/buy/{item_id}` — a clean, focused page with no navigation chrome. Use it for:
70 +
71 + - **Link-in-bio** — A single purchase link on Instagram, TikTok, Twitter, etc.
72 + - **Social media posts** — Share a direct "buy here" link
73 + - **Email newsletters** — Link directly to checkout
74 + - **QR codes** — Print on physical media (posters, merch, liner notes)
75 +
76 + The page shows your item's cover art, title, price, and a "Buy Now" button. Fans purchase in one step without navigating the rest of the platform.
77 +
50 78 ## Payment Flow
51 79
52 80 All payments go through the payment processor:
@@ -99,6 +99,18 @@ When fans purchase from you and opt in to share their email, you get a direct li
99 99
100 100 See [Contact Sharing](./contact-sharing.md) for the full details.
101 101
102 + ### Direct Purchase Links
103 +
104 + Every item gets a dedicated purchase link at `/buy/{item_id}` — a clean page with just the cover, title, price, and a buy button. No navigation, no distractions.
105 +
106 + Use these for:
107 + - Link-in-bio (Instagram, TikTok, Twitter)
108 + - Newsletter CTAs
109 + - Social media posts ("new release — buy here")
110 + - QR codes on physical media
111 +
112 + Fans can purchase in one click without creating an account. Guest checkout handles everything — they enter payment details via Stripe and receive a download link by email. Zero friction between "I want this" and "I have this."
113 +
102 114 ## Practical Tips
103 115
104 116 ### Set Expectations
@@ -6,7 +6,7 @@ Everything you need to know about supporting creators and managing your library
6 6
7 7 When you buy something here, the creator gets almost everything you paid. The platform takes 0% — the only fee is ~3% from payment processing, and that goes to the processor, not us.
8 8
9 - There's no algorithm deciding what you see. No ads. No tracking cookies following you around the internet. You find creators through search, links, or recommendations — the same way you find anything worth finding.
9 + There's no algorithm deciding what you see. No ads. No tracking cookies following you around the internet. You find creators through [search and browsing](/discover), direct links, or recommendations from people you trust.
10 10
11 11 Every item on the platform is classified as Handmade, Assisted, or Generated based on generative AI use. You can filter to see only Handmade content, only human-led work, or everything. See the [Generative AI Policy](../about/generative-ai.md) for what these tiers mean.
12 12
@@ -21,6 +21,18 @@ Your purchases are permanent and downloadable. No DRM, no streaming-only restric
21 21
22 22 This means creators have no incentive to upsell you on platform features. What you pay goes to the person who made the thing.
23 23
24 + ### Guest Checkout (No Account Required)
25 +
26 + You can buy content without creating an account:
27 +
28 + 1. Click "Buy Now" on any item page or direct purchase link
29 + 2. Complete payment via the secure checkout (Stripe collects your email)
30 + 3. Receive a download link via email immediately
31 +
32 + No sign-up, no password, no friction. If you later create an account with the same email, all your prior purchases appear in your library automatically.
33 +
34 + Free items work the same way — enter your email and receive a download link.
35 +
24 36 ## Ways to Support
25 37
26 38 ### One-Time Purchases
@@ -49,6 +61,33 @@ Some creators let you choose your price:
49 61 - Minimum is sometimes $0 (free with optional tip)
50 62 - The full amount above processing fees goes to the creator
51 63
64 + ## Finding Content
65 +
66 + ### Discover Page
67 +
68 + The [discover page](/discover) is the main way to browse content on the platform:
69 +
70 + - **Search** — Full-text fuzzy search across all public items and projects
71 + - **Filter by type** — Audio, video, digital, text (with item counts per type)
72 + - **Filter by tag** — Hierarchical tags let you drill down (e.g., Electronic > Ambient > Dark Ambient)
73 + - **Filter by price** — Free, price ranges, or custom min/max
74 + - **Filter by AI tier** — Show only Handmade, Assisted, or everything
75 + - **Sort** — Newest, most sold, price ascending/descending
76 +
77 + Switch between Items and Projects views. Results update in real-time as you adjust filters.
78 +
79 + ### Tag Browser
80 +
81 + The [tag tree](/discover/tags) lets you browse the full tag hierarchy visually. Click into a category to see subcategories and tagged items.
82 +
83 + ### Your Feed
84 +
85 + Once you follow creators, projects, or tags, your [feed](/feed) shows new items from everything you follow — a personalized timeline without algorithmic ranking.
86 +
87 + ### Following
88 +
89 + Follow a creator or project to see their new items in your feed. Follow a tag to see all new items in that category. No notifications by default — just your feed.
90 +
52 91 ## Your Library
53 92
54 93 Your library at `/library` is where everything you've bought, subscribed to, or claimed lives.
@@ -96,9 +135,9 @@ An account lets you:
96 135
97 136 ## If a Creator Leaves
98 137
99 - Your purchases stay in your library. Download links keep working. Files aren't deleted because the creator closed their account.
138 + When a creator deletes their account, your purchased content remains accessible for 90 days. Download your files during this window — after 90 days, content is removed from our servers. Transaction records and receipts are preserved indefinitely.
100 139
101 - This is a core commitment. See our [portability guarantee](../about/how-we-work.md).
140 + See our [buyer access guarantee](../about/guarantees.md#buyer-access).
102 141
103 142 ## If We Shut Down
104 143
@@ -74,6 +74,18 @@ If a fan requests a refund and you approve:
74 74
75 75 You have full control over refund decisions.
76 76
77 + ## Chargebacks (Disputes)
78 +
79 + If a fan disputes a charge with their bank instead of requesting a refund from you:
80 +
81 + - Stripe charges a **$15 dispute fee** regardless of outcome
82 + - The disputed amount is held until the dispute is resolved (typically 60-90 days)
83 + - You can submit evidence (proof of delivery, communication) through your Stripe dashboard
84 + - If you win the dispute, the held funds are released back to you; the $15 fee is also refunded
85 + - If you lose, the fan is refunded and you absorb both the sale amount and the $15 fee
86 +
87 + Chargebacks are rare for digital content but happen occasionally. Clear product descriptions, prompt communication, and a visible refund policy on your profile reduce dispute rates.
88 +
77 89 ## See Also
78 90
79 91 - [Analytics & Dashboard](./analytics.md) — Revenue charts and transaction history
@@ -36,13 +36,15 @@ These are suggestions. Price based on value, not convention.
36 36
37 37 ### Purchase Flow
38 38
39 - 1. Fan clicks "Buy"
40 - 2. Checkout shows price + payment methods
39 + 1. Fan clicks "Buy" (on item page, direct purchase link, or embed)
40 + 2. Checkout shows price + payment methods (no account required)
41 41 3. Payment processes instantly
42 - 4. Fan gets immediate access + download links
42 + 4. Fan receives download link via email immediately
43 43 5. Receipt emails to fan
44 44 6. Funds appear in your payment account
45 45
46 + Fans can purchase without creating an account — guest checkout collects only an email via the payment processor. If they create an account later with the same email, all prior purchases attach to their library automatically.
47 +
46 48 ---
47 49
48 50 ## Pay-What-You-Want
@@ -70,20 +70,16 @@ For musicians, podcasters, sound designers, indie developers, and plugin makers.
70 70
71 71 For game developers, educators, course creators, and anyone producing large content. Up to 20GB per file.
72 72
73 - *Video hosting and transcoding are planned but not yet implemented. Digital file downloads of any size (up to 20GB per file) are available now.*
74 -
75 73 ### What You Get (in addition to Small Files)
76 74
77 - - Video uploads up to 4K resolution (planned)
78 - - Automatic transcoding: 360p, 720p, 1080p, 4K (planned)
79 - - Adaptive streaming (planned)
80 - - Thumbnail generation and custom thumbnails (planned)
75 + - Video uploads (MP4, WebM, MOV) up to 20GB per file
76 + - In-browser video player with access control
81 77 - Subscriber-only and pay-per-view videos
82 78 - Video series and playlists
83 - - Large binary downloads up to 20GB per file (available now)
79 + - Large binary downloads up to 20GB per file
84 80 - Per-file size increase available on request for files over 20GB
85 -
86 - **Video formats (planned):** MP4, MOV, AVI, MKV, WebM
81 + - Automatic transcoding and adaptive streaming (planned)
82 + - Thumbnail generation (planned)
87 83
88 84 ### Storage
89 85
@@ -42,11 +42,11 @@ HSTS enabled. Certificate transparency logging.
42 42
43 43 | Data | Protection |
44 44 |------|------------|
45 - | Database | AES-256 disk encryption |
46 - | Object storage | AES-256 server-side encryption |
47 - | Backups | AES-256 with separate keys |
45 + | Database | AES-256 disk encryption (infrastructure-provided by hosting provider) |
46 + | Object storage | AES-256 server-side encryption (infrastructure-provided by storage provider) |
47 + | Backups | AES-256 disk encryption (infrastructure-provided) |
48 48
49 - We encrypt storage. This protects against physical theft and infrastructure compromise.
49 + Encryption at rest is provided at the infrastructure level by our hosting and storage providers (Hetzner). This protects against physical theft and infrastructure compromise. There is no additional application-level encryption layer — the platform has server-side access to stored data (necessary for indexing, search, and content delivery).
50 50
51 51 ---
52 52