Skip to main content

max / makenotwork

Maintainability splits, content fingerprinting, bundled license text Split large modules into directory modules (git/, payments/, validation/, routes/api/internal/, routes/api/items/, dashboard/tabs/, dashboard/wizards/item/, email_actions/, public/content/, postmark/, storage/, stripe/checkout/). Add content fingerprinting system (migration 051, perceptual hashing for images/audio/video, fuzzy matching, DMCA claim workflow, 8 unit + 5 integration tests). Add bundled license text for items (migration 052, 7 presets + custom, license.txt endpoint, wizard + dashboard UI, item page display, 8 unit + 5 integration tests). Add project documentation (api_reference, database_schema, architecture updates, codesize/concurrency/payload audits, deploy README). Bump version to 0.3.19. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-06 21:29 UTC
Commit: 8211fd93e88560cbfaaea48951dc8c1e0dcbe444
Parent: 1f4ab0b
128 files changed, +16647 insertions, -11767 deletions
M CLAUDE.md +15 -15
@@ -71,14 +71,14 @@ src/
71 71 ├── error.rs Error handling
72 72 ├── auth.rs Authentication
73 73 ├── csrf.rs CSRF protection
74 - ├── db/ Database queries (module)
74 + ├── db/ Database queries (directory module)
75 75 ├── docs.rs Documentation rendering
76 76 ├── email/ Email handling (directory module)
77 - ├── git.rs Git source browser logic
77 + ├── git/ Git source browser logic (directory module)
78 78 ├── helpers.rs Shared helper functions
79 79 ├── markdown.rs Markdown rendering
80 80 ├── monitor.rs Health monitoring
81 - ├── payments.rs Stripe integration
81 + ├── payments/ Stripe integration (directory module)
82 82 ├── rss.rs RSS feed generation
83 83 ├── scanning/ File scanning (ClamAV, YARA, hash lookup)
84 84 ├── scheduler.rs Background task scheduler
@@ -87,27 +87,27 @@ src/
87 87 ├── synckit_auth.rs SyncKit JWT auth
88 88 ├── templates/ Askama templates (directory module)
89 89 ├── types/ Shared types (directory module)
90 - ├── validation.rs Input validation
90 + ├── validation/ Input validation (directory module)
91 91 ├── wordlist.rs Wordlist for invite codes
92 92 └── routes/
93 93 ├── mod.rs
94 94 ├── admin.rs Admin panel
95 95 ├── auth.rs Login, signup, logout
96 96 ├── api/ JSON API endpoints (directory module)
97 - ├── git.rs Git source browser routes
98 - ├── git_issues.rs Git issue tracker routes
97 + ├── git/ Git source browser routes (directory module)
98 + ├── git_issues/ Git issue tracker routes (directory module)
99 99 ├── pages/ HTML page routes (directory module)
100 - │ ├── mod.rs Route composer
101 - │ ├── public/ Public-facing pages (directory module)
102 - │ ├── dashboard/ Creator dashboard + HTMX tabs (directory module)
103 - │ ├── email_actions.rs Email link handlers
104 - │ ├── feeds.rs RSS feeds
105 - │ └── blog.rs Blog pages
100 + │ ├── mod.rs Route composer
101 + │ ├── public/ Public-facing pages (directory module)
102 + │ ├── dashboard/ Creator dashboard + HTMX tabs (directory module)
103 + │ ├── email_actions/ Email link handlers (directory module)
104 + │ ├── feeds.rs RSS feeds
105 + │ └── blog.rs Blog pages
106 106 ├── oauth.rs OAuth provider routes
107 - ├── postmark.rs Postmark webhook handler
108 - ├── storage.rs File upload/download
107 + ├── postmark/ Postmark webhook handler (directory module)
108 + ├── storage/ File upload/download (directory module)
109 109 ├── stripe/ Stripe webhooks + connect (directory module)
110 - └── synckit.rs SyncKit API endpoints
110 + └── synckit/ SyncKit API endpoints (directory module)
111 111 ```
112 112
113 113 Route files should stay under 500 lines. When a route module grows beyond that, split it into a directory module grouped by domain.
M Cargo.lock +2 -1
@@ -1785,6 +1785,7 @@ dependencies = [
1785 1785 "regex",
1786 1786 "serde",
1787 1787 "toml",
1788 + "tracing",
1788 1789 ]
1789 1790
1790 1791 [[package]]
@@ -3350,7 +3351,7 @@ dependencies = [
3350 3351
3351 3352 [[package]]
3352 3353 name = "makenotwork"
3353 - version = "0.3.17"
3354 + version = "0.3.18"
3354 3355 dependencies = [
3355 3356 "anyhow",
3356 3357 "argon2",
M Cargo.toml +1 -1
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.18"
3 + version = "0.3.19"
4 4 edition = "2024"
5 5 license-file = "LICENSE"
6 6
@@ -0,0 +1,156 @@
1 + # MNW Deployment
2 +
3 + Scripts and configuration for deploying MNW to production.
4 +
5 + ## Quick Reference
6 +
7 + ```sh
8 + ./deploy/deploy.sh # Full: build + config + binary + restart
9 + ./deploy/deploy.sh --quick # Build + binary + restart (skip config)
10 + ./deploy/deploy.sh --config # Config files only (Caddyfile, systemd, static, docs)
11 + ```
12 +
13 + Run all commands from the `MNW/` directory.
14 +
15 + ## Prerequisites (one-time)
16 +
17 + ```sh
18 + brew install zig
19 + cargo install cargo-zigbuild
20 + rustup target add x86_64-unknown-linux-gnu
21 + ```
22 +
23 + ## What Each Mode Does
24 +
25 + ### Full deploy (default)
26 +
27 + 1. Cross-compiles the binary with `cargo zigbuild --release --target x86_64-unknown-linux-gnu`
28 + 2. Uploads config files (Caddyfile, systemd unit, error pages, security configs)
29 + 3. Minifies CSS via `clean-css-cli`, uploads static assets via rsync
30 + 4. Uploads public site-docs and generated rustdoc
31 + 5. Sends a 30-second restart warning to connected users via internal API
32 + 6. Stops the service, uploads the binary (+ `mnw-admin` if present), restarts
33 + 7. Verifies the app responds on `http://127.0.0.1:3000`
34 +
35 + ### Quick deploy (`--quick`)
36 +
37 + Skips config/static/docs upload. Builds, warns users, uploads binary, restarts.
38 +
39 + ### Config deploy (`--config`)
40 +
41 + Uploads Caddyfile, systemd unit, error pages, security configs, minified CSS, static assets, site-docs, and rustdoc. Reloads systemd and restarts Caddy. Does not touch the application binary.
42 +
43 + ## Production Server
44 +
45 + - **Host:** Hetzner VPS (CCX13 x86, US-West)
46 + - **Public IP:** `5.78.144.244`
47 + - **Tailscale IP:** `100.120.174.96` (hostname: `alpha-west-1`)
48 + - **SSH:** `root@100.120.174.96` (via Tailscale only)
49 + - **OS:** Ubuntu, x86_64
50 +
51 + ### Filesystem layout
52 +
53 + ```
54 + /opt/makenotwork/
55 + makenotwork Application binary
56 + mnw-admin Admin CLI binary
57 + .env Environment variables (secrets)
58 + static/ CSS, JS, fonts, images
59 + error-pages/ Custom 404/500/502 pages
60 + backup-db.sh Database backup script
61 + docs/public/ Site documentation (rendered by DocEngine)
62 + rustdoc/ Generated API reference
63 + deploy/ Security config copies
64 +
65 + /opt/git/ Bare git repos (source browser)
66 + makenotwork.git/
67 + synckit-client.git/
68 + ...
69 +
70 + /etc/caddy/Caddyfile Reverse proxy config
71 + /etc/caddy/cloudflare-origin.pem Cloudflare Origin CA cert
72 + /etc/caddy/cloudflare-origin-key.pem Origin CA private key
73 + /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem Cloudflare AOP CA
74 + /etc/caddy/maxj-phd-origin.pem maxj.phd Origin CA cert
75 + /etc/caddy/maxj-phd-origin-key.pem maxj.phd Origin CA key
76 + /etc/systemd/system/makenotwork.service systemd unit
77 + ```
78 +
79 + ### Services
80 +
81 + | Service | Role | Port |
82 + |---------|------|------|
83 + | `makenotwork` | Application (systemd, runs as `makenotwork` user) | 3000 |
84 + | `caddy` | Reverse proxy, TLS termination | 443 |
85 + | `postgresql` | Database (`makenotwork` db + user) | 5432 |
86 +
87 + ### Networking
88 +
89 + - **Cloudflare** proxies all HTTP/HTTPS traffic (origin IP hidden)
90 + - **SSL:** Full (Strict) mode with Cloudflare Origin CA (15yr wildcard for `*.makenot.work` and `*.maxj.phd`)
91 + - **Authenticated Origin Pulls** enabled (mTLS between Cloudflare and origin)
92 + - **SSH:** `ssh.makenot.work` DNS A record points directly to public IP (proxy OFF) for git push/pull
93 + - **Firewall:** ufw + fail2ban, sshd hardened
94 +
95 + ## Scripts Reference
96 +
97 + | Script | Purpose |
98 + |--------|---------|
99 + | `deploy.sh` | Main deployment script (build, upload, restart) |
100 + | `backup-db.sh` | PostgreSQL backup (pg_dump, uploaded to server) |
101 + | `generate-rustdoc.sh` | Generate rustdoc for library crates |
102 + | `ota-publish.sh` | Publish OTA release (auth, create release, presigned upload, verify) |
103 + | `run-ci.sh` | CI runner (check, test, clippy, audit) -- runs on astra |
104 + | `setup-firewall.sh` | Configure ufw rules |
105 + | `setup-git-ssh.sh` | Configure git SSH access |
106 + | `setup-ssh-keys.sh` | Deploy SSH authorized keys |
107 +
108 + ## Configuration Files
109 +
110 + | File | Deployed to | Purpose |
111 + |------|-------------|---------|
112 + | `Caddyfile` | `/etc/caddy/Caddyfile` | Reverse proxy rules for all domains |
113 + | `makenotwork.service` | `/etc/systemd/system/` | systemd unit (EnvironmentFile, restart policy) |
114 + | `env.production` | Reference for `.env` format | **Not deployed** -- `.env` is edited on server |
115 + | `fail2ban-sshd.conf` | `/opt/makenotwork/deploy/` | fail2ban jail config |
116 + | `sshd-git.conf` | `/opt/makenotwork/deploy/` | SSH config for git user |
117 + | `error-pages/*.html` | `/opt/makenotwork/error-pages/` | Custom Caddy error pages |
118 +
119 + ## Environment Variables
120 +
121 + All secrets live in `/opt/makenotwork/.env` (loaded by systemd `EnvironmentFile`). See `env.production` for the template. Key variables:
122 +
123 + - `DATABASE_URL` -- PostgreSQL connection string
124 + - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_CLIENT_ID` -- Stripe Connect
125 + - `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` -- Hetzner Object Storage
126 + - `POSTMARK_SERVER_TOKEN` -- transactional email
127 + - `JWT_SECRET`, `SESSION_SECRET` -- authentication
128 + - `SYNCKIT_JWT_SECRET` -- SyncKit token signing
129 + - `HOST_URL` -- public base URL (`https://makenot.work`)
130 +
131 + ## Astra (dev/test server)
132 +
133 + - **Tailscale IP:** `100.106.221.39`
134 + - **OS:** Pop!_OS 24.04 LTS, aarch64 (96 cores, 125GB RAM)
135 + - **PostgreSQL 16** with tuned settings
136 + - **Used for:** CI, integration tests, staging
137 +
138 + ### Running tests on astra
139 +
140 + ```sh
141 + ssh 100.106.221.39
142 + /home/max/staging/run-tests.sh # All tests
143 + /home/max/staging/run-tests.sh auth:: # Filtered
144 + ```
145 +
146 + Use `--test-threads=8` (or the `RUST_TEST_THREADS=8` env var set in `.bashrc`) to avoid overwhelming PostgreSQL with 96 concurrent `CREATE DATABASE` calls.
147 +
148 + ## Versioning
149 +
150 + - Version is set in `Cargo.toml` and compiled into the binary via `env!("CARGO_PKG_VERSION")`
151 + - **Always ask before bumping** -- never auto-increment
152 + - Edit `Cargo.toml` version field before building
153 +
154 + ## Full Setup
155 +
156 + See `SERVER_SETUP.md` for the complete provisioning checklist (PostgreSQL, Caddy, systemd, Stripe, S3, DNS, Cloudflare, security hardening) and `RECOVERY.md` for disaster recovery procedures.
@@ -0,0 +1,291 @@
1 + # MNW -- API Reference
2 +
3 + Public JSON API endpoints. All write routes require authentication via session cookie or JWT.
4 +
5 + Rate limits: write routes (burst 10, 2/sec per IP), export routes (burst 3, 1/sec per IP).
6 +
7 + HTMX responses return HTML fragments; non-HTMX requests get JSON.
8 +
9 + ---
10 +
11 + ## Projects
12 +
13 + | Method | Path | Description |
14 + |--------|------|-------------|
15 + | POST | /api/projects | Create a new project |
16 + | GET | /api/projects | List all projects for the authenticated user |
17 + | PUT | /api/projects/{id} | Update a project |
18 + | DELETE | /api/projects/{id} | Delete a project |
19 +
20 + ## Git Repos
21 +
22 + | Method | Path | Description |
23 + |--------|------|-------------|
24 + | POST | /api/repos | Create a bare git repo on disk |
25 + | POST | /api/projects/{id}/repos | Link a repo to a project |
26 + | DELETE | /api/projects/{id}/repos/{name} | Unlink a repo from a project |
27 + | PUT | /api/repos/{id}/visibility | Update repo visibility |
28 +
29 + ## Items
30 +
31 + | Method | Path | Description |
32 + |--------|------|-------------|
33 + | POST | /api/projects/{id}/items | Create a new item |
34 + | PUT | /api/items/{id} | Update an item |
35 + | DELETE | /api/items/{id} | Delete an item |
36 + | POST | /api/items/{id}/duplicate | Duplicate an item as a new draft |
37 + | PUT | /api/items/{id}/move | Reorder an item within its project |
38 +
39 + ### Bulk Operations
40 +
41 + | Method | Path | Description |
42 + |--------|------|-------------|
43 + | POST | /api/items/bulk/publish | Publish multiple items |
44 + | POST | /api/items/bulk/unpublish | Unpublish multiple items |
45 + | POST | /api/items/bulk/delete | Delete multiple items |
46 +
47 + ### Tags
48 +
49 + | Method | Path | Description |
50 + |--------|------|-------------|
51 + | POST | /api/items/{id}/tags | Add a tag to an item |
52 + | DELETE | /api/items/{id}/tags/{tag_id} | Remove a tag from an item |
53 + | PUT | /api/items/{id}/primary-tag | Set the primary tag |
54 + | GET | /api/tags/search | Typeahead tag search |
55 + | GET | /api/items/{id}/tag-suggestions | Suggest tags for an item |
56 +
57 + ### Bundles
58 +
59 + | Method | Path | Description |
60 + |--------|------|-------------|
61 + | POST | /api/items/{id}/bundle/add | Add a child item to a bundle |
62 + | DELETE | /api/items/{id}/bundle/{child_id} | Remove a child from a bundle |
63 + | PUT | /api/items/{id}/bundle/{child_id}/listed | Toggle child visibility |
64 +
65 + ### Text Content
66 +
67 + | Method | Path | Description |
68 + |--------|------|-------------|
69 + | PUT | /api/items/{id}/text | Save or update text body content |
70 +
71 + ### Chapters
72 +
73 + | Method | Path | Description |
74 + |--------|------|-------------|
75 + | GET | /api/items/{id}/chapters | List chapters for an item |
76 + | POST | /api/items/{id}/chapters | Create a chapter marker |
77 + | PUT | /api/chapters/{id} | Update a chapter |
78 + | DELETE | /api/chapters/{id} | Delete a chapter |
79 +
80 + ### Versions
81 +
82 + | Method | Path | Description |
83 + |--------|------|-------------|
84 + | GET | /api/items/{id}/versions | List versions for an item |
85 + | POST | /api/items/{id}/versions | Create a new version |
86 +
87 + ## Blog
88 +
89 + | Method | Path | Description |
90 + |--------|------|-------------|
91 + | POST | /api/projects/{id}/blog | Create a blog post |
92 + | GET | /api/projects/{id}/blog | List blog posts for a project |
93 + | GET | /api/blog/{id} | Get a blog post (includes markdown body) |
94 + | PUT | /api/blog/{id} | Update a blog post |
95 + | DELETE | /api/blog/{id} | Delete a blog post |
96 +
97 + ## Collections
98 +
99 + | Method | Path | Description |
100 + |--------|------|-------------|
101 + | POST | /api/collections | Create a collection |
102 + | PUT | /api/collections/{id} | Update a collection |
103 + | DELETE | /api/collections/{id} | Delete a collection |
104 + | POST | /api/collections/{id}/items/{item_id} | Add an item to a collection |
105 + | DELETE | /api/collections/{id}/items/{item_id} | Remove an item from a collection |
106 + | PUT | /api/collections/{id}/items/reorder | Reorder items |
107 + | GET | /api/collections/for-item/{item_id} | Collections containing an item |
108 +
109 + ## License Keys
110 +
111 + ### Creator Endpoints (auth required)
112 +
113 + | Method | Path | Description |
114 + |--------|------|-------------|
115 + | POST | /api/items/{id}/license-settings | Configure license key settings |
116 + | GET | /api/items/{id}/keys | List keys for an item |
117 + | POST | /api/items/{id}/keys | Generate a new key |
118 + | POST | /api/keys/{id}/revoke | Revoke a key |
119 +
120 + ### Public Endpoints (rate-limited, stable API contract)
121 +
122 + | Method | Path | Description |
123 + |--------|------|-------------|
124 + | POST | /api/keys/validate | Validate and optionally activate a key |
125 + | POST | /api/keys/deactivate | Release an activation slot |
126 + | GET | /api/keys/{code}/status | Check key status |
127 +
128 + ## Promo Codes
129 +
130 + | Method | Path | Description |
131 + |--------|------|-------------|
132 + | POST | /api/promo-codes | Create a promo code |
133 + | GET | /api/promo-codes | List promo codes |
134 + | DELETE | /api/promo-codes/{id} | Delete a promo code |
135 + | POST | /api/promo-codes/claim | Claim a free_access promo code (buyer endpoint) |
136 +
137 + ## Subscription Tiers
138 +
139 + | Method | Path | Description |
140 + |--------|------|-------------|
141 + | POST | /api/projects/{id}/tiers | Create a subscription tier |
142 + | GET | /api/projects/{id}/tiers | List tiers for a project |
143 + | PUT | /api/tiers/{id} | Update a tier |
144 + | DELETE | /api/tiers/{id} | Delete a tier |
145 +
146 + ## Follows
147 +
148 + | Method | Path | Description |
149 + |--------|------|-------------|
150 + | POST | /api/follow/{type}/{id} | Follow a user or project |
151 + | DELETE | /api/follow/{type}/{id} | Unfollow a user or project |
152 +
153 + ## Custom Links
154 +
155 + | Method | Path | Description |
156 + |--------|------|-------------|
157 + | POST | /api/links | Create a profile link |
158 + | PUT | /api/links/{id} | Update a link |
159 + | DELETE | /api/links/{id} | Delete a link |
160 + | PUT | /api/links/reorder | Reorder all links |
161 +
162 + ## Labels
163 +
164 + | Method | Path | Description |
165 + |--------|------|-------------|
166 + | GET | /api/labels | List all available labels |
167 + | GET | /api/projects/{id}/labels | Get labels for a project |
168 + | POST | /api/projects/{id}/labels | Add a label to a project |
169 + | DELETE | /api/projects/{id}/labels/{label_id} | Remove a label from a project |
170 +
171 + ## Categories
172 +
173 + | Method | Path | Description |
174 + |--------|------|-------------|
175 + | POST | /api/categories | Create a category |
176 + | GET | /api/categories/search | Search categories (typeahead) |
177 +
178 + ## Custom Domains
179 +
180 + | Method | Path | Description |
181 + |--------|------|-------------|
182 + | POST | /api/domains | Add a custom domain |
183 + | POST | /api/domains/verify | Trigger DNS TXT verification |
184 + | DELETE | /api/domains/{id} | Remove a custom domain |
185 +
186 + ## Content Insertions
187 +
188 + | Method | Path | Description |
189 + |--------|------|-------------|
190 + | POST | /api/users/me/insertions/presign | Get presigned upload URL |
191 + | POST | /api/users/me/insertions/confirm | Confirm upload |
192 + | GET | /api/users/me/insertions | List insertions |
193 + | PUT | /api/insertions/{id} | Rename an insertion |
194 + | DELETE | /api/insertions/{id} | Delete an insertion |
195 + | POST | /api/items/{id}/insertions | Place an insertion in an item |
196 + | DELETE | /api/item-insertions/{id} | Remove a placement |
197 + | GET | /api/items/{id}/insertions | List placements for an item |
198 +
199 + ## Exports
200 +
201 + | Method | Path | Description |
202 + |--------|------|-------------|
203 + | POST | /api/export/projects | Export projects + items as JSON |
204 + | POST | /api/export/sales | Export sales as CSV |
205 + | POST | /api/export/purchases | Export purchases as CSV |
206 + | POST | /api/export/followers | Export followers as CSV |
207 + | POST | /api/export/content | Export content files as ZIP |
208 +
209 + ## User Account
210 +
211 + | Method | Path | Description |
212 + |--------|------|-------------|
213 + | PUT | /api/users/me | Update display name and bio |
214 + | PUT | /api/users/me/password | Change password |
215 + | PUT | /api/users/me/preferences | Update notification preferences |
216 + | PUT | /api/users/me/stripe-tax | Toggle Stripe tax collection |
217 + | DELETE | /api/users/me | Delete account |
218 + | DELETE | /api/users/me/stripe | Disconnect Stripe Connect |
219 + | POST | /api/users/me/appeal | Submit suspension appeal |
220 + | POST | /api/resend-verification | Resend verification email |
221 + | POST | /api/account/request-deletion | Request account deletion with data export |
222 +
223 + ### Sessions
224 +
225 + | Method | Path | Description |
226 + |--------|------|-------------|
227 + | DELETE | /api/users/me/sessions/{id} | Revoke a session |
228 + | DELETE | /api/users/me/sessions | Revoke all other sessions |
229 +
230 + ### SSH Keys
231 +
232 + | Method | Path | Description |
233 + |--------|------|-------------|
234 + | GET | /api/users/me/ssh-keys | List SSH keys |
235 + | POST | /api/users/me/ssh-keys | Add an SSH key |
236 + | DELETE | /api/users/me/ssh-keys/{id} | Delete an SSH key |
237 +
238 + ### TOTP (2FA)
239 +
240 + | Method | Path | Description |
241 + |--------|------|-------------|
242 + | POST | /api/users/me/totp/setup | Generate TOTP secret and QR code |
243 + | POST | /api/users/me/totp/confirm | Verify first code and enable 2FA |
244 + | POST | /api/users/me/totp/disable | Disable 2FA |
245 + | POST | /api/users/me/totp/backup-codes | Regenerate backup codes |
246 + | GET | /api/users/me/totp/status | Get 2FA status |
247 +
248 + ### Passkeys (WebAuthn)
249 +
250 + | Method | Path | Description |
251 + |--------|------|-------------|
252 + | POST | /api/users/me/passkeys/register/start | Start passkey registration |
253 + | POST | /api/users/me/passkeys/register/finish | Finish passkey registration |
254 + | GET | /api/users/me/passkeys | List passkeys |
255 + | PUT | /api/users/me/passkeys/{id} | Rename a passkey |
256 + | DELETE | /api/users/me/passkeys/{id} | Delete a passkey |
257 +
258 + ## Library
259 +
260 + | Method | Path | Description |
261 + |--------|------|-------------|
262 + | POST | /api/library/add/{item_id} | Add to library |
263 + | DELETE | /api/library/remove/{item_id} | Remove from library |
264 +
265 + ## Miscellaneous
266 +
267 + | Method | Path | Description |
268 + |--------|------|-------------|
269 + | POST | /api/reports | Submit a content report |
270 + | POST | /api/broadcast | Send broadcast to followers |
271 + | POST | /api/waitlist/apply | Join the platform waitlist |
272 + | POST | /api/invites/create | Create an invite code |
273 + | POST | /api/validate/project-slug | Check project slug availability |
274 + | POST | /api/validate/collection-slug | Check collection slug availability |
275 + | POST | /api/validate/blog-slug | Check blog slug availability |
276 + | POST | /api/email-signup | Subscribe to newsletter (public, no auth) |
277 + | GET | /api/restart-status | Check pending service restart (public, no auth) |
278 +
279 + ---
280 +
281 + ## Internal API
282 +
283 + Service-to-service endpoints under `/api/internal/` are authenticated via `ServiceAuth` bearer token. Used by mnw-cli for creator operations, git authorization, and file uploads. Not listed here — see `src/routes/api/internal.rs` for the full surface.
284 +
285 + ## SyncKit API
286 +
287 + SyncKit endpoints under `/api/synckit/` handle cloud sync, device management, and blob operations. Authenticated via SyncKit JWT. See `src/routes/synckit.rs`.
288 +
289 + ## OTA API
290 +
291 + OTA update endpoints under `/api/ota/` serve release metadata and presigned download URLs. See `src/routes/ota.rs`.
@@ -44,6 +44,7 @@ MNW/src/
44 44 mt_client.rs HTTP client for Multithreaded internal API
45 45 rss.rs RSS/Atom feed generation
46 46 wordlist.rs Wordlist for human-readable invite codes
47 + pricing.rs Access control and pricing model trait
47 48 db/ Database queries (module per domain)
48 49 routes/ HTTP handlers (module per domain)
49 50 templates/ Askama template structs (public, dashboard, partials)
@@ -93,7 +94,7 @@ A `json_error_layer` middleware converts HTML error responses to `{"error": "...
93 94
94 95 ## Database Layer
95 96
96 - PostgreSQL via sqlx with compile-time checked queries. 43 migrations (auto-applied on boot). Connection pool: 25 max connections, 3-second acquire timeout.
97 + PostgreSQL via sqlx with compile-time checked queries. 50 migrations (auto-applied on boot). Connection pool: 25 max connections, 3-second acquire timeout.
97 98
98 99 ### DB Modules
99 100
@@ -143,6 +144,8 @@ Each `db/` submodule handles a specific domain:
143 144 | `reports` | User content reports |
144 145 | `fan_plus` | Fan+ subscription tracking |
145 146 | `creator_tiers` | Creator tier enforcement (storage limits, feature gates) |
147 + | `bundles` | Item bundling/packs |
148 + | `email_signups` | Early signup tracking |
146 149
147 150 ### Type System
148 151
@@ -338,7 +341,7 @@ Two spawned Tokio tasks, coordinated via `watch::channel` for graceful shutdown:
338 341 | Error types | `src/error.rs` |
339 342 | Authentication | `src/auth.rs` |
340 343 | CSRF middleware | `src/csrf.rs` |
341 - | Database modules | `src/db/` (53 submodules) |
344 + | Database modules | `src/db/` (49 submodules) |
342 345 | Route modules | `src/routes/` (17 submodules) |
343 346 | Template structs | `src/templates/` |
344 347 | Email service | `src/email/` |
@@ -353,7 +356,7 @@ Two spawned Tokio tasks, coordinated via `watch::channel` for graceful shutdown:
353 356 | Scheduler | `src/scheduler.rs` |
354 357 | Shared types | `src/types/` |
355 358 | Askama templates | `templates/` |
356 - | Migrations | `migrations/` (001-043) |
359 + | Migrations | `migrations/` (001-050) |
357 360 | Static assets | `static/` |
358 361 | Integration tests | `tests/` |
359 362 | Deploy scripts | `deploy/` |
@@ -5,7 +5,7 @@
5 5
6 6 ## Overall Grade: A
7 7
8 - Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.13. Grade stable at A. Major additions since Run 11: email-first issue tracker (G6), I5 git patch inbound, bundles + batch upload, ProjectFeature trait. 2 integration test failures found (delete_item_returns_toast, item_wizard_license_keys).
8 + Run 12 cross-project audit. 1,174 tests (584 unit + 545 integration + 17 admin + 28 health). 0 clippy warnings. v0.3.18. Grade stable at A. Major additions since Run 11: email-first issue tracker (G6), I5 git patch inbound, bundles + batch upload, ProjectFeature trait. 2 integration test failures found (delete_item_returns_toast, item_wizard_license_keys).
9 9
10 10 ## Scorecard
11 11
@@ -0,0 +1,109 @@
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 + ```
@@ -0,0 +1,86 @@
1 + # Codesize Efficiency Audit
2 +
3 + **Date:** 2026-04-02
4 + **Scope:** `src/` (production code only, tests excluded)
5 + **Grade:** B+
6 +
7 + ## Summary
8 +
9 + - 61,340 lines across 146 `.rs` files in `src/`
10 + - 12 files exceed 500 lines; 6 are exempt (flat lists), 6 are violations, 3 are borderline
11 + - 3 duplication patterns identified
12 + - No dead code found
13 + - Test suite (23,768 lines, 544 tests) is A+ — well-factored, no issues
14 +
15 + ## Exempt Files (flat lists, correctly large)
16 +
17 + | File | Lines | Why exempt |
18 + |------|-------|-----------|
19 + | `src/wordlist.rs` | 2,056 | Static 2048-word array |
20 + | `src/db/models.rs` | 2,045 | FromRow structs + simple accessors |
21 + | `src/db/enums.rs` | 1,217 | 35 `impl_str_enum!` macro enums |
22 + | `src/validation.rs` | 1,177 | 60+ linear validation functions |
23 + | `src/templates/public.rs` | 952 | Askama HTML markup |
24 + | `src/types/mod.rs` | 871 | Type definitions / newtypes |
25 +
26 + ## Violations (branching logic >500 lines)
27 +
28 + | File | Lines | Domains | Split recommendation |
29 + |------|-------|---------|---------------------|
30 + | `src/routes/api/internal.rs` | 1,634 | 10 (SSH, items, blog, promo, licenses, analytics, git auth...) | `internal/` dir with 5-6 submodules |
31 + | `src/git.rs` | 1,176 | 4 (refs, commit graph, blame, annotation) | `git/{refs,graph,blame}.rs` |
32 + | `src/payments.rs` | 1,173 | 5 (PWYW, discounts, licenses, subs, transactions) | `payments/{checkout,subscriptions,discounts}.rs` |
33 + | `src/routes/storage.rs` | 920 | 4 (presign, confirm+scan, download, health) | `routes/storage/` dir |
34 + | `src/routes/postmark.rs` | 887 | 4 (bounces, complaints, tracking, delivery) | `routes/email_webhooks/` dir |
35 + | `src/routes/pages/dashboard/wizards/item.rs` | 791 | 6 wizard steps | `wizards/item/` dir |
36 + | `src/helpers.rs` | 775 | 8 (slugs, CSV, URLs, dates, crypto, email, cache, forms) | `helpers/` dir with 4 submodules |
37 + | `src/routes/stripe/checkout.rs` | 768 | 4 (forms, promo validation, session, payment intent) | Extract `checkout/promo.rs` |
38 +
39 + ## Borderline (monitor, no action needed)
40 +
41 + | File | Lines | Notes |
42 + |------|-------|-------|
43 + | `src/routes/api/items.rs` | 885 | 6 domains (CRUD, publish, chapters) but each handler is self-contained |
44 + | `src/routes/pages/public/health.rs` | 767 | Health page handlers, low branching complexity |
45 + | `src/storage.rs` | 855 | S3 client operations, cohesive module |
46 + | `src/db/items.rs` | 835 | Item queries, each <100 lines, flat structure |
47 +
48 + ## Duplication Patterns
49 +
50 + ### Ownership verification (8+ sites)
51 +
52 + ```
53 + get_project → check user_id → NotFound/Forbidden
54 + ```
55 +
56 + Repeated across route files for project-scoped endpoints. Could extract a shared `verify_project_ownership(pool, project_id, user_id)` helper.
57 +
58 + ### Slug collision resolution (5+ sites)
59 +
60 + ```
61 + slugify → exists check → append suffix loop
62 + ```
63 +
64 + Appears in project creation, item creation, blog posts, collections, and page creation. Could consolidate into a generic `resolve_unique_slug()` function.
65 +
66 + ### Price formatting (3+ sites)
67 +
68 + Cents-to-display logic (`amount / 100`, decimal formatting) repeated in payment routes, dashboard templates, and email templates.
69 +
70 + ## Dead Code
71 +
72 + None found. All public functions have callers. No unused feature flags. No orphaned modules.
73 +
74 + ## Test Suite
75 +
76 + - 23,768 lines, 544 tests
77 + - Test harness: 1,129 lines (`tests/common/`)
78 + - Well-factored — shared fixtures, clear module boundaries
79 + - Grade: A+ — no splitting needed
80 +
81 + ## Key Paths
82 +
83 + - `src/` — all production code (146 files)
84 + - `tests/` — integration tests
85 + - `src/routes/` — HTTP handlers (largest concentration of branching logic)
86 + - `src/db/` — database queries (mostly flat, exempt)
@@ -0,0 +1,62 @@
1 + # Concurrency Audit
2 +
3 + Audit date: 2026-04-02. Grade: **A**.
4 +
5 + No code fixes needed. Zero concurrency issues found.
6 +
7 + ## Primitives In Use
8 +
9 + | Primitive | Count | Usage |
10 + |-----------|-------|-------|
11 + | `Arc<T>` | 16 fields | All heavy AppState fields (db pool, S3 client, email client, config, etc.) |
12 + | `DashMap` | 2 caches | session_cache (UserSessionId→Instant), domain_cache (String→UserId) |
13 + | `tokio::spawn` | ~20 sites | Fire-and-forget tasks (emails, webhooks, builds) |
14 + | `tokio::sync::watch` | 1 channel | Graceful shutdown broadcast to monitor + scheduler |
15 + | `tokio::select!` | 2 loops | Monitor + scheduler main loops (shutdown + interval) |
16 + | `LazyLock<Regex>` | 3 regexes | Thread-safe compiled regexes |
17 + | `Mutex` / `RwLock` / `unsafe` / `Condvar` | 0 | Not used in production code |
18 +
19 + ## Verified Areas
20 +
21 + ### AppState (lib.rs)
22 + - `#[derive(Clone)]` with all heavy types in `Arc`
23 + - Cheap per-request clone via Axum's `State` extractor
24 +
25 + ### DashMap (16 call sites)
26 + - All operations are single-level get/insert/remove/retain — no nested access (which could deadlock sharded maps)
27 + - Session cache pruned every monitor cycle via `retain()` with TTL check
28 + - Domain cache populated on startup, updated on domain add/remove
29 +
30 + ### Fire-and-Forget Spawns (~20)
31 + - All spawned tasks have internal error handling (`if let Err(e)` + tracing)
32 + - No `.unwrap()` or `.expect()` in any spawned task
33 + - Panic risk negligible
34 +
35 + ### Email Fan-Out (3 paths)
36 + - `send_release_announcements` (scheduler.rs:63-89)
37 + - `send_blog_post_announcements` (scheduler.rs:147-173)
38 + - `broadcast_send` (broadcast.rs:93-109)
39 + - Pattern: spawn one task, iterate sequentially over subscribers
40 + - Sequential sending naturally rate-limits Postmark API calls
41 +
42 + ### Graceful Shutdown (main.rs)
43 + - `tokio::sync::watch` channel created in main, `subscribe()` shared to monitor + scheduler
44 + - Both use `tokio::select!` to check shutdown signal on every loop iteration
45 +
46 + ### HTTP Client Timeouts (mt_client.rs)
47 + - 5s request timeout, 3s connect timeout
48 + - Prevents hung connections from blocking the async runtime
49 +
50 + ### Connection Pool (sqlx PgPool)
51 + - Shared via `Arc` in AppState
52 + - Spawned tasks share the pool; sqlx handles connection limiting internally
53 +
54 + ### Onboarding Scheduler (scheduler.rs:181-238)
55 + - Sequential email sending within the scheduler tick
56 + - Batch operations for skip cases
57 +
58 + ## Intentional Design Decisions
59 +
60 + - **No Sentry** — panics in spawned tasks go to stderr only. Acceptable for private alpha.
61 + - **JoinHandle discarded** — intentional fire-and-forget for emails, webhooks, cache invalidation. All have internal error handling; parent tasks don't need the result.
62 + - **No Semaphore on email fan-out** — sequential sending in a single task is sufficient for current scale. A concurrency limiter would only matter if switching to concurrent per-subscriber sending.
@@ -0,0 +1,533 @@
1 + # MNW Database Schema
2 +
3 + PostgreSQL schema reference. 50 migrations, 60+ tables. Migrations live in `migrations/` and auto-run on boot via sqlx.
4 +
5 + ## Domain Map
6 +
7 + | Domain | Tables | Purpose |
8 + |--------|--------|---------|
9 + | Auth & Users | 10 | Identity, sessions, passkeys, waitlist, creator tiers |
10 + | Content | 9 | Projects, items, versions, chapters, tags, blog posts |
11 + | Commerce | 9 | Transactions, subscriptions, license keys, promo codes |
12 + | Collections | 3 | User-curated collections and bundles |
13 + | Git & Issues | 8 | Repositories, SSH keys, issues, email threading |
14 + | SyncKit | 9 | Cloud sync, E2E encryption, OTA updates, build pipeline |
15 + | Email | 4 | Mailing lists, subscribers, file scanning, signups |
16 + | Moderation | 3 | Reports, platform labels, project label assignments |
17 + | Content Insertions | 2 | Reusable media clips with placement positions |
18 + | Social | 1 | Follows (users, projects, tags) |
19 + | Creator Growth | 1 | Invite codes |
20 + | Sessions | 1 | HTTP session store (tower-sessions) |
21 +
22 + ---
23 +
24 + ## Auth & Users
25 +
26 + ### users
27 + Primary user table. Handles identity, Stripe Connect, email verification, security, and creator access.
28 +
29 + | Column | Type | Notes |
30 + |--------|------|-------|
31 + | `id` | UUID PK | |
32 + | `username` | VARCHAR | Unique |
33 + | `email` | VARCHAR | Unique |
34 + | `password_hash` | VARCHAR | argon2 |
35 + | `display_name` | VARCHAR | |
36 + | `bio` | TEXT | |
37 + | `avatar_url` | VARCHAR | |
38 + | `stripe_account_id` | VARCHAR | Stripe Connect |
39 + | `stripe_onboarding_complete` | BOOL | |
40 + | `stripe_payouts_enabled` | BOOL | |
41 + | `stripe_charges_enabled` | BOOL | |
42 + | `email_verified` | BOOL | |
43 + | `email_verification_token` | VARCHAR | |
44 + | `totp_secret` | VARCHAR | TOTP 2FA |
45 + | `totp_enabled` | BOOL | |
46 + | `failed_login_attempts` | INT | Brute-force protection |
47 + | `locked_until` | TIMESTAMPTZ | |
48 + | `can_create_projects` | BOOL | Waitlist gate |
49 + | `creator_tier` | VARCHAR | Migration 036 |
50 + | `login_notification_enabled` | BOOL | |
51 + | `notify_release` | BOOL | Migration 015 |
52 + | `created_at`, `updated_at` | TIMESTAMPTZ | |
53 +
54 + ### user_passkeys
55 + WebAuthn/passkey credentials. Migration 006.
56 +
57 + - FK `user_id` -> users
58 + - `credential_json` (JSONB), `credential_id` (BYTEA, unique per user), `name`, `last_used_at`
59 +
60 + ### user_sessions
61 + Active login sessions.
62 +
63 + - FK `user_id` -> users
64 + - `user_agent`, `ip_address`, `created_at`, `last_active_at`
65 +
66 + ### login_tokens
67 + One-time tokens for passwordless login.
68 +
69 + - FK `user_id` -> users
70 + - `token_hash` (unique), `expires_at`, `used_at`
71 +
72 + ### backup_codes
73 + 2FA recovery codes.
74 +
75 + - FK `user_id` -> users
76 + - `code_hash`, `used_at`
77 +
78 + ### oauth_authorization_codes
79 + PKCE OAuth provider (used by Multithreaded).
80 +
81 + - FK `app_id` -> sync_apps, `user_id` -> users
82 + - `code` (unique), `code_challenge`, `code_challenge_method`, `redirect_uri`, `expires_at`, `used_at`
83 +
84 + ### email_suppressions
85 + Bounce/complaint tracking. Migration 015.
86 +
87 + - `email` (unique, case-insensitive), `reason` ('HardBounce', 'SpamComplaint')
88 +
89 + ### creator_waitlist
90 + Alpha access waitlist.
91 +
92 + - FK `user_id` (unique) -> users, `wave_id` -> creator_waves, `invited_by_user_id` -> users
93 + - `pitch`, `status` ('pending', 'approved', 'rejected'), `selection_method`, `admin_note`
94 +
95 + ### creator_waves
96 + Batch invitation waves.
97 +
98 + - `wave_number` (unique), `hand_picked_count`, `lottery_count`, `total_eligible`, `note`
99 +
100 + ### creator_subscriptions
101 + Creator tier billing. Migration 036.
102 +
103 + - FK `user_id` (unique) -> users
104 + - `stripe_subscription_id` (unique), `tier`, `status`, `current_period_start`, `current_period_end`
105 +
106 + ---
107 +
108 + ## Content
109 +
110 + ### projects
111 + Creator projects (albums, podcasts, books, software, etc.).
112 +
113 + | Column | Type | Notes |
114 + |--------|------|-------|
115 + | `id` | UUID PK | |
116 + | `user_id` | UUID FK | -> users |
117 + | `slug` | VARCHAR | Unique per user |
118 + | `title` | VARCHAR | |
119 + | `description` | TEXT | |
120 + | `project_type` | VARCHAR | music, podcast, blog, book, course, software, art, writing |
121 + | `category_id` | UUID FK | -> project_categories |
122 + | `cover_image_url` | VARCHAR | |
123 + | `is_public` | BOOL | |
124 + | `mt_community_id` | UUID | Multithreaded link (migration 037) |
125 + | `features` | TEXT[] | Platform capabilities (migration 046) |
126 + | `created_at`, `updated_at` | TIMESTAMPTZ | |
127 +
128 + Unique constraint: `(user_id, slug)`.
129 +
130 + ### items
131 + Individual content pieces within a project.
132 +
133 + | Column | Type | Notes |
134 + |--------|------|-------|
135 + | `id` | UUID PK | |
136 + | `project_id` | UUID FK | -> projects (CASCADE) |
137 + | `title` | VARCHAR | |
138 + | `description` | TEXT | |
139 + | `slug` | VARCHAR | Unique per project (migration 043) |
140 + | `price_cents` | INT | >= 0 |
141 + | `item_type` | VARCHAR | |
142 + | `is_public` | BOOL | |
143 + | `listed` | BOOL | Default true (migration 048) |
144 + | `body` | TEXT | Text content |
145 + | `word_count`, `reading_time_minutes` | INT | |
146 + | `audio_url`, `duration_seconds` | | Audio content |
147 + | `audio_s3_key`, `cover_s3_key` | VARCHAR | S3 storage keys |
148 + | `enable_license_keys` | BOOL | |
149 + | `default_max_activations` | INT | |
150 + | `sales_count`, `play_count`, `download_count` | INT | Denormalized counters |
151 + | `pwyw_enabled` | BOOL | Pay-what-you-want |
152 + | `pwyw_min_cents` | INT | |
153 + | `publish_at` | TIMESTAMPTZ | Scheduled publishing (migration 011) |
154 + | `scan_status` | VARCHAR | File scanning (migration 004) |
155 + | `mt_thread_id` | UUID | Multithreaded link (migration 037) |
156 + | `sort_order` | INT | >= 0 |
157 + | `created_at`, `updated_at` | TIMESTAMPTZ | |
158 +
159 + ### versions
160 + File versions for downloadable items.
161 +
162 + - FK `item_id` -> items (CASCADE)
163 + - `version_number`, `changelog`, `file_url`, `file_size_bytes`, `file_name`, `s3_key`
164 + - `download_count` (>= 0), `is_current` (unique constraint: one current per item)
165 + - `scan_status` (migration 004)
166 +
167 + ### chapters
168 + Audio chapter markers.
169 +
170 + - FK `item_id` -> items (CASCADE)
171 + - `title`, `start_seconds`, `sort_order`
172 +
173 + ### tags
174 + Hierarchical tag system.
175 +
176 + - Self-referential: `parent_id` -> tags
177 + - `name`, `slug` (unique), `sort_order`, `path` (denormalized hierarchy, migration 038)
178 +
179 + ### item_tags
180 + Many-to-many item-tag association.
181 +
182 + - PK `(item_id, tag_id)`
183 + - `is_primary` (one primary tag per item)
184 +
185 + ### blog_posts
186 + Project blog posts with markdown.
187 +
188 + - FK `project_id` -> projects (CASCADE), `author_id` -> users
189 + - `title`, `slug`, `body_markdown`, `body_html`
190 + - `published_at`, `publish_at` (scheduled, migration 011)
191 + - `mt_thread_id` (migration 037)
192 + - Unique: `(project_id, slug)`
193 +
194 + ### project_categories
195 + Browsable project categories.
196 +
197 + - `name`, `slug` (both unique)
198 +
199 + ### custom_links
200 + User profile links.
201 +
202 + - FK `user_id` -> users
203 + - `url`, `title`, `description`, `sort_order`
204 +
205 + ---
206 +
207 + ## Commerce
208 +
209 + ### transactions
210 + Purchase records.
211 +
212 + - FK `buyer_id` -> users, `seller_id` -> users (nullable), `item_id` -> items (nullable), `project_id` (migration 047, nullable)
213 + - `amount_cents` (>= 0), `platform_fee_cents` (>= 0), `currency`
214 + - `status`: 'pending', 'completed', 'failed', 'refunded'
215 + - `stripe_payment_intent_id`, `stripe_checkout_session_id`
216 + - `item_title`, `seller_username` -- denormalized (preserved after deletion)
217 + - `share_contact`
218 + - View: **purchases** (distinct on buyer_id, item_id, completed)
219 +
220 + ### subscription_tiers
221 + Subscription plans within projects (or items, migration 047).
222 +
223 + - FK `project_id` -> projects, `item_id` (nullable) -> items
224 + - `name`, `description`, `price_cents` (> 0), `sort_order`, `is_active`
225 + - `stripe_product_id`, `stripe_price_id`
226 +
227 + ### subscriptions
228 + Active subscriber records.
229 +
230 + - FK `subscriber_id` -> users, `tier_id` -> subscription_tiers, `project_id` -> projects, `item_id` (nullable)
231 + - `stripe_subscription_id`, `stripe_customer_id`, `status`
232 + - `current_period_start`, `current_period_end`, `canceled_at`
233 +
234 + ### subscription_events
235 + Stripe webhook event log.
236 +
237 + - `stripe_event_id` (unique), `event_type`, `payload` (JSONB)
238 +
239 + ### license_keys
240 + Software license keys.
241 +
242 + - FK `item_id` -> items, `owner_id` -> users, `transaction_id` -> transactions (nullable)
243 + - `key_code` (unique), `max_activations`, `activation_count`, `revoked_at`
244 +
245 + ### license_activations
246 + Machine activations for license keys.
247 +
248 + - FK `license_key_id` -> license_keys
249 + - `machine_id`, `label`, `is_active`
250 + - Unique: `(license_key_id, machine_id)`
251 +
252 + ### promo_codes
253 + Unified promotion system (replaces old discount_codes + download_codes). Migration 019.
254 +
255 + - FK `creator_id` -> users, `item_id` (nullable), `project_id` (nullable), `tier_id` (nullable)
256 + - `code` (unique per creator, case-insensitive)
257 + - `code_purpose`: 'discount', 'free_access', 'free_trial'
258 + - Discount fields: `discount_type` ('percentage', 'fixed'), `discount_value`, `min_price_cents`
259 + - Trial fields: `trial_days`
260 + - `max_uses`, `use_count`, `expires_at`
261 + - `is_platform_wide` (migration 031)
262 + - CHECK constraints enforce field consistency by purpose
263 +
264 + ### fan_plus_subscriptions
265 + Consumer-side platform subscription. Migration 031.
266 +
267 + - FK `user_id` (unique) -> users
268 + - `stripe_subscription_id` (unique), `status`, `current_period_start`, `current_period_end`
269 +
270 + ### contact_revocations
271 + Buyer-seller contact sharing opt-out. Migration 016.
272 +
273 + - PK `(buyer_id, seller_id)`
274 + - `revoked_at`
275 +
276 + ---
277 +
278 + ## Collections & Bundles
279 +
280 + ### collections
281 + User-curated collections (playlists, reading lists). Migration 032.
282 +
283 + - FK `user_id` -> users
284 + - `slug`, `title`, `description`, `is_public`
285 + - Unique: `(user_id, slug)`
286 +
287 + ### collection_items
288 + Items within a collection. Migration 032.
289 +
290 + - PK `(collection_id, item_id)`
291 + - `position`, `added_at`
292 +
293 + ### bundle_items
294 + Items grouped into a bundle (parent item). Migration 048.
295 +
296 + - PK `(bundle_id, item_id)` -- both FK to items
297 + - `sort_order`, `added_at`
298 + - CHECK: `bundle_id != item_id`
299 +
300 + ---
301 +
302 + ## Git & Issues
303 +
304 + ### git_repos
305 + Git repositories (bare repos on disk, browsed via `git2`). Migration 020.
306 +
307 + - FK `user_id` -> users, `project_id` -> projects (nullable)
308 + - `name`
309 + - Unique: `(user_id, name)`
310 +
311 + ### ssh_keys
312 + User SSH public keys for git push. Migration 024.
313 +
314 + - FK `user_id` -> users
315 + - `public_key`, `fingerprint`, `label`
316 + - Unique: `(user_id, fingerprint)`
317 +
318 + ### issues
319 + Email-first issue tracker. Migration 027.
320 +
321 + - FK `repo_id` -> git_repos, `author_user_id` -> users
322 + - `number` (unique per repo), `title`, `body_markdown`, `body_html`
323 + - `status`: 'open', 'closed'
324 +
325 + ### issue_comments
326 + Comments on issues. Migration 027.
327 +
328 + - FK `issue_id` -> issues, `author_user_id` -> users
329 + - `body_markdown`, `body_html`
330 +
331 + ### issue_labels, issue_label_assignments
332 + Label definitions and assignments. Migration 027.
333 +
334 + - Labels: `name`, `color` (hex), unique per repo
335 + - Assignments: PK `(issue_id, label_id)`
336 +
337 + ### issue_message_ids
338 + Email Message-ID bridging for issue threading. Migration 045.
339 +
340 + - FK `issue_id` -> issues
341 + - `message_id` (unique)
342 +
343 + ### patch_message_ids
344 + Email Message-ID bridging for patch discussions. Migration 044.
345 +
346 + - FK `project_id` -> projects
347 + - `message_id` (unique), `thread_id` (Multithreaded thread ID)
348 +
349 + ---
350 +
351 + ## SyncKit
352 +
353 + ### sync_apps
354 + Registered applications.
355 +
356 + - FK `creator_id` -> users
357 + - `name`, `api_key` (unique, 64-char), `slug` (unique, nullable, migration 033), `is_active`
358 +
359 + ### sync_devices
360 + Per-user device registrations.
361 +
362 + - FK `app_id` -> sync_apps, `user_id` -> users
363 + - `device_name`, `platform`, `last_seen_at`
364 + - Unique: `(app_id, user_id, device_name)`
365 +
366 + ### sync_keys
367 + E2E encryption keys (server never has plaintext).
368 +
369 + - PK `(app_id, user_id)`
370 + - `key_version`, `encrypted_key`
371 +
372 + ### sync_log
373 + Changelog entries for push/pull sync.
374 +
375 + - PK `seq` (BIGSERIAL)
376 + - FK `app_id`, `user_id`, `device_id`
377 + - `table_name`, `operation` ('INSERT', 'UPDATE', 'DELETE'), `row_id`, `client_timestamp`, `data` (JSONB)
378 +
379 + ### sync_blobs
380 + Content-addressed file storage for sync. Migration 012.
381 +
382 + - FK `app_id`, `user_id`
383 + - `hash` (content hash), `s3_key`, `size_bytes`
384 + - Unique: `(app_id, user_id, hash)`
385 +
386 + ### ota_releases
387 + OTA update releases. Migration 033.
388 +
389 + - FK `app_id` -> sync_apps
390 + - `version`, `notes`, `signature`, `pub_date`
391 + - Unique: `(app_id, version)`
392 +
393 + ### ota_artifacts
394 + Per-platform binaries for OTA releases. Migration 033.
395 +
396 + - FK `release_id` -> ota_releases
397 + - `target`, `arch`, `s3_key`, `file_size`
398 + - Unique: `(release_id, target, arch)`
399 +
400 + ### ota_build_configs
401 + Automated build configuration (one per app). Migration 034.
402 +
403 + - FK `app_id` -> sync_apps, `repo_id` -> git_repos
404 + - `build_command`, `artifact_path`, `signing_key_path`, `targets` (TEXT[]), `enabled`
405 +
406 + ### ota_builds
407 + Build execution records. Migration 034.
408 +
409 + - FK `config_id` -> ota_build_configs, `app_id`, `release_id` (nullable)
410 + - `version`, `tag`, `status`, `log`, `error_message`, `triggered_by` (default 'tag')
411 +
412 + ---
413 +
414 + ## Email & Scanning
415 +
416 + ### mailing_lists
417 + Per-project mailing lists. Migration 039.
418 +
419 + - FK `project_id` -> projects
420 + - `list_type`: 'content', 'devlog', 'patches'
421 + - Unique: `(project_id, list_type)`
422 +
423 + ### mailing_list_subscribers
424 + List memberships. Migration 039.
425 +
426 + - FK `list_id` -> mailing_lists, `user_id` -> users
427 + - Unique: `(list_id, user_id)`
428 +
429 + ### file_scan_results
430 + ClamAV/YARA scan results for uploaded files. Migration 004.
431 +
432 + - `s3_key`, `scan_status`, `scan_layers` (JSONB), `sha256`, `file_size_bytes`
433 +
434 + ### email_signups
435 + Landing page email collection. Migration 050.
436 +
437 + - `email` (unique), `source` (default 'landing')
438 +
439 + ---
440 +
441 + ## Moderation
442 +
443 + ### reports
444 + User-submitted reports. Migration 030.
445 +
446 + - FK `reporter_user_id`, `resolved_by` (nullable) -> users
447 + - `target_type`, `target_id` (polymorphic), `report_type`, `reason`, `status`, `admin_notes`
448 +
449 + ### labels
450 + Platform-wide creator commitment labels. Migration 029. Examples: drm-free, no-tracking, solo-dev, accessible.
451 +
452 + - `slug` (unique), `display_name`, `definition`, `examples`, `nonexamples`, `sort_order`
453 +
454 + ### project_labels
455 + Label assignments to projects. Migration 029.
456 +
457 + - PK `(project_id, label_id)`
458 + - `confirmed_at`
459 +
460 + ---
461 +
462 + ## Other
463 +
464 + ### content_insertions, content_insertion_placements
465 + Reusable media clips (e.g., intros, ads) with per-item placement. Migration 007.
466 +
467 + - Insertions: FK `user_id`, `title`, `media_type`, `storage_key`, `duration_ms`, `file_size`, `mime_type`
468 + - Placements: FK `item_id`, `insertion_id`, `position` ('pre_roll', 'mid_roll', 'post_roll'), `offset_ms`
469 +
470 + ### follows
471 + Polymorphic follow system.
472 +
473 + - FK `follower_id` -> users
474 + - `target_type` ('user', 'project', 'tag'), `target_id` (no FK, polymorphic)
475 + - Unique: `(follower_id, target_type, target_id)`
476 +
477 + ### invite_codes
478 + Alpha access invite codes. Migration 009.
479 +
480 + - FK `creator_id` -> users, `redeemed_by_id` (nullable) -> users
481 + - `code` (unique), `redeemed_at`
482 +
483 + ### tower_sessions.session
484 + HTTP session store (managed by tower-sessions-sqlx-store).
485 +
486 + - PK `id` (TEXT), `data` (BYTEA), `expiry_date`
487 +
488 + ---
489 +
490 + ## Design Patterns
491 +
492 + - **Polymorphic targeting:** `follows` and `reports` use `(target_type, target_id)` instead of per-type FK columns
493 + - **Denormalized counters:** `items.sales_count`/`play_count`/`download_count` for fast reads
494 + - **Denormalized display fields:** `transactions.item_title`/`seller_username` survive deletion
495 + - **E2E encryption:** `sync_keys.encrypted_key` is opaque to the server
496 + - **Unified promo codes:** Single table with CHECK constraints instead of separate discount/download tables
497 + - **Message-ID bridging:** `issue_message_ids` and `patch_message_ids` link email threads to platform objects
498 + - **Scheduled publishing:** `publish_at` on items and blog_posts for future release
499 + - **Content-addressed blobs:** `sync_blobs` deduplicates by `(app_id, user_id, hash)`
500 +
Lines truncated
@@ -0,0 +1,313 @@
1 + # MNW Code Patterns
2 +
3 + Recurring patterns, macros, and conventions used throughout the MNW codebase.
4 +
5 + ## Macros
6 +
7 + ### `impl_str_enum!`
8 +
9 + **Location:** `src/db/enums.rs`
10 +
11 + Generates `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode` for enums that map to text columns. The sqlx impls delegate to `String`, so the enum works with both TEXT and VARCHAR columns.
12 +
13 + ```rust
14 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15 + #[serde(rename_all = "snake_case")]
16 + pub enum CodePurpose {
17 + Discount,
18 + FreeAccess,
19 + FreeTrial,
20 + }
21 +
22 + impl_str_enum!(CodePurpose {
23 + Discount => "discount",
24 + FreeAccess => "free_access",
25 + FreeTrial => "free_trial",
26 + });
27 + ```
28 +
29 + **What it generates:**
30 + - `Display::fmt()` writes the mapped string literal
31 + - `FromStr::from_str()` parses back, with error `"invalid CodePurpose: xyz"`
32 + - `sqlx::Type`/`Encode`/`Decode` so sqlx can read/write the enum directly in queries
33 +
34 + **Used for:** `DiscountType`, `CodePurpose`, `WaitlistStatus`, `TransactionStatus`, `ItemType`, `FileScanStatus`, `SelectionMethod`, `LoginTokenPurpose`, and more.
35 +
36 + ### `define_pg_uuid_id!`
37 +
38 + **Location:** `src/db/id_types.rs`
39 +
40 + Creates newtype wrappers around `uuid::Uuid` for type-safe IDs. Prevents accidental mixing of ID types at compile time (e.g., `UserId` vs `ProjectId`), while remaining transparent in the database layer and JSON APIs.
41 +
42 + ```rust
43 + define_pg_uuid_id!(
44 + UserId,
45 + ProjectId,
46 + ItemId,
47 + VersionId,
48 + TransactionId,
49 + // ... 30+ more
50 + );
51 + ```
52 +
53 + **What each type gets:**
54 + - `Copy`, `Clone`, `Hash`, `Eq`, `Ord`
55 + - `::new()` generates a UUID v4
56 + - `::from_uuid(uuid)` and `From<Uuid>`
57 + - `.as_uuid()` and `Deref<Target=Uuid>`
58 + - `::nil()` for test fixtures
59 + - `serde(transparent)` -- serializes as a UUID string
60 + - `sqlx::Type`/`Encode`/`Decode` for PostgreSQL UUID columns
61 + - `Display` and `FromStr`
62 +
63 + ### `impl_into_response!`
64 +
65 + **Location:** `src/templates/mod.rs`
66 +
67 + Bulk-implements `IntoResponse` for Askama template structs, so they can be returned directly from route handlers.
68 +
69 + ```rust
70 + impl_into_response!(
71 + IndexTemplate,
72 + LoginTemplate,
73 + DashboardUserTemplate,
74 + // ... 90+ template types
75 + );
76 + ```
77 +
78 + Each implementation calls `render_template(self)`, which renders to HTML and returns a 200 response (or a 500 error page if rendering fails).
79 +
80 + ## HTMX Dual-Response Pattern
81 +
82 + Routes detect HTMX requests and return different formats. Page routes return HTML fragments; API clients get JSON.
83 +
84 + ```rust
85 + if is_htmx_request(&headers) {
86 + // Return HX-Redirect header
87 + let mut response = Response::new(Body::empty());
88 + response.headers_mut().insert(
89 + "HX-Redirect",
90 + format!("/dashboard/item/{}", item.id).parse().expect("valid path"),
91 + );
92 + return Ok(response);
93 + }
94 +
95 + Ok(Json(ItemResponse { /* ... */ }).into_response())
96 + ```
97 +
98 + ### Response patterns by action
99 +
100 + | Action | HTMX response | Non-HTMX response |
101 + |--------|---------------|-------------------|
102 + | Create + redirect | `HX-Redirect` header | `Json(ItemResponse)` |
103 + | Delete with feedback | `htmx_toast_response("Deleted", "success")` | `StatusCode::NO_CONTENT` |
104 + | Save with status | `Html(SaveStatusTemplate { success, message })` | `Json(UpdateTextResponse)` |
105 + | Inline edit | HTML fragment (re-rendered partial) | `Json(...)` |
106 +
107 + ### Helper functions
108 +
109 + ```rust
110 + // src/helpers.rs
111 + pub fn is_htmx_request(headers: &HeaderMap) -> bool {
112 + headers.get("HX-Request").is_some()
113 + }
114 +
115 + pub fn htmx_toast_response(message: &str, toast_type: &str) -> impl IntoResponse {
116 + // Returns HX-Trigger header with showToast JSON event
117 + // Frontend JS listens for this and renders a toast notification
118 + }
119 +
120 + pub fn hx_toast(message: &str, toast_type: &str) -> HeaderValue {
121 + // Serializes: { "showToast": { "message": "...", "type": "..." } }
122 + }
123 + ```
124 +
125 + ## `ListResponse<T>` Envelope
126 +
127 + **Location:** `src/types/mod.rs`
128 +
129 + All JSON list endpoints wrap results in a `{ "data": [...] }` envelope, making the response forward-compatible for pagination metadata.
130 +
131 + ```rust
132 + #[derive(Serialize)]
133 + pub struct ListResponse<T: Serialize> {
134 + pub data: Vec<T>,
135 + }
136 + ```
137 +
138 + Usage:
139 + ```rust
140 + let chapters = db::chapters::get_chapters_by_item(&state.db, item_id).await?;
141 + Ok(Json(ListResponse { data: chapters.into_iter().map(ChapterResponse::from).collect() }))
142 + ```
143 +
144 + ## Error Handling
145 +
146 + **Location:** `src/error.rs`
147 +
148 + ### AppError enum
149 +
150 + ```rust
151 + pub enum AppError {
152 + NotFound,
153 + Unauthorized,
154 + Forbidden,
155 + BadRequest(String),
156 + Validation(String),
157 + Database(sqlx::Error),
158 + Internal(anyhow::Error),
159 + Storage(String),
160 + InvalidFileType(String),
161 + FileTooLarge(String),
162 + MalwareDetected(String),
163 + ServiceUnavailable(String),
164 + }
165 + ```
166 +
167 + ### HTTP status mapping
168 +
169 + | Variant | Status code |
170 + |---------|------------|
171 + | `NotFound` | 404 |
172 + | `Unauthorized` | 401 |
173 + | `Forbidden` | 403 |
174 + | `BadRequest` | 400 |
175 + | `Validation` | 422 |
176 + | `Database`, `Internal`, `Storage` | 500 |
177 + | `InvalidFileType` | 400 |
178 + | `FileTooLarge` | 413 |
179 + | `MalwareDetected` | 422 |
180 + | `ServiceUnavailable` | 503 |
181 +
182 + ### User-safe messages
183 +
184 + Internal errors (`Database`, `Internal`, `Storage`) return generic "Something went wrong" to the client. `Validation` and `BadRequest` messages pass through since they describe user input problems.
185 +
186 + ### JSON error middleware
187 +
188 + API routes use a middleware layer (`json_error_layer` in `routes/api/mod.rs`) that converts `AppError` HTML responses to JSON `{"error": "..."}` responses. Page routes render an error template.
189 +
190 + ### Result alias
191 +
192 + ```rust
193 + pub type Result<T> = std::result::Result<T, AppError>;
194 + ```
195 +
196 + ## Rate Limiting
197 +
198 + **Location:** `src/helpers.rs` (config builders), `src/routes/api/mod.rs` (applied to routes)
199 +
200 + Uses `tower-governor` with `SmartIpKeyExtractor` (reads X-Forwarded-For or direct IP).
201 +
202 + ```rust
203 + let write_rate_limit = rate_limiter_ms(
204 + constants::API_WRITE_RATE_LIMIT_MS,
205 + constants::API_WRITE_RATE_LIMIT_BURST,
206 + );
207 +
208 + let write_routes = Router::new()
209 + .route("/api/projects", post(projects::create_project))
210 + // ...
211 + .route_layer(GovernorLayer { config: write_rate_limit });
212 + ```
213 +
214 + Rate limits are grouped by tier:
215 + - **Write routes** -- mutations (create, update, delete)
216 + - **Export routes** -- data export endpoints (lower burst)
217 + - **License key routes** -- activation/validation (tight limits)
218 + - **Dashboard GET routes** -- read-heavy dashboard endpoints
219 +
220 + ## Template View Models
221 +
222 + **Location:** `src/templates/` (public.rs, dashboard.rs, partials.rs)
223 +
224 + Each template is an Askama struct with all the data it needs pre-loaded. No database calls happen in templates.
225 +
226 + ```rust
227 + #[derive(Template)]
228 + #[template(path = "pages/project.html")]
229 + pub struct ProjectTemplate {
230 + pub csrf_token: CsrfTokenOption,
231 + pub session_user: Option<SessionUser>,
232 + pub project: Project,
233 + pub creator_username: String,
234 + pub items: Vec<Item>,
235 + pub subscription_tiers: Vec<SubscriptionTier>,
236 + pub is_following: bool,
237 + pub follower_count: i64,
238 + // ...
239 + }
240 + ```
241 +
242 + ### Organization
243 +
244 + | Module | Contents |
245 + |--------|----------|
246 + | `templates/public.rs` | Public pages (landing, auth, profiles, content) |
247 + | `templates/dashboard.rs` | Creator dashboards, admin views |
248 + | `templates/partials.rs` | HTMX fragments, tab content, alerts, form status |
249 +
250 + ### Physical template files
251 +
252 + ```
253 + templates/
254 + base.html Base layout with head, nav, footer
255 + pages/ Full public pages (30+)
256 + dashboards/ Creator + admin dashboards
257 + partials/ HTMX fragments
258 + tabs/ Tab content for dashboards
259 + wizards/ Multi-step flows (join wizard)
260 + steps/
261 + ```
262 +
263 + ### Common partial types
264 +
265 + - `AlertTemplate` -- feedback messages with optional link (builder pattern)
266 + - `FormStatusTemplate` -- inline success/error for HTMX form submissions
267 + - Tab templates -- one struct per dashboard tab, loaded via HTMX on tab click
268 +
269 + ## Route Organization
270 +
271 + Routes are split by audience:
272 +
273 + ```
274 + routes/
275 + api/ JSON endpoints (grouped by domain: items, projects, subscriptions, etc.)
276 + pages/
277 + public/ Public-facing HTML pages
278 + dashboard/ Authenticated creator dashboard pages
279 + feeds.rs RSS/Atom feeds
280 + blog.rs Blog pages
281 + auth.rs Login, signup, logout, password reset
282 + git.rs Source browser
283 + git_issues.rs Issue tracker
284 + oauth.rs OAuth provider (for Multithreaded)
285 + postmark.rs Inbound email webhook
286 + storage.rs File upload/download (presigned URLs)
287 + stripe/ Stripe webhooks and Connect callbacks
288 + synckit.rs SyncKit cloud sync + OTA API
289 + ```
290 +
291 + The `api/mod.rs` file composes all API sub-routers, applies rate limiting layers, and adds the JSON error middleware.
292 +
293 + ## Database Conventions
294 +
295 + - All tables use UUID primary keys (except `sync_log` which uses BIGSERIAL)
296 + - Text enums stored as VARCHAR/TEXT, mapped via `impl_str_enum!`
297 + - Timestamps are `TIMESTAMPTZ NOT NULL DEFAULT NOW()` unless nullable
298 + - Foreign keys use `ON DELETE CASCADE` for owned relationships
299 + - Compile-time checked queries via sqlx -- migrations auto-run on boot
300 + - Each integration test creates and drops its own PostgreSQL database
301 +
302 + ## Key Paths
303 +
304 + | Pattern | Location |
305 + |---------|----------|
306 + | Enum macro | `src/db/enums.rs` |
307 + | ID macro | `src/db/id_types.rs` |
308 + | Template macro | `src/templates/mod.rs` |
309 + | Error type | `src/error.rs` |
310 + | HTMX helpers | `src/helpers.rs` |
311 + | Rate limiters | `src/helpers.rs`, `src/routes/api/mod.rs` |
312 + | ListResponse | `src/types/mod.rs` |
313 + | Constants | `src/constants.rs` |
@@ -0,0 +1,89 @@
1 + # Payload Audit
2 +
3 + **Date:** 2026-04-02
4 + **Scope:** Browser-facing assets served by MNW
5 + **Grade:** A — Lean payload, no bloat
6 +
7 + ## Summary
8 +
9 + - ~170 KB gzipped first load, ~55 KB subsequent pages
10 + - Hand-written CSS + vanilla JS + HTMX only — no framework bloat
11 + - woff2 fonts with preload and `font-display: swap`
12 + - Conditional JS loading per page type
13 + - No large dead asset sections found
14 +
15 + ## Asset Inventory
16 +
17 + ### Production Page Load (gzipped)
18 +
19 + | Category | Uncompressed | Gzipped | Notes |
20 + |----------|-------------|---------|-------|
21 + | CSS (style.css + wizard.css) | 90 KB | ~16 KB | Hand-written, no framework |
22 + | JS custom (mnw, passkey, upload, insertions, wizard) | 27 KB | ~8 KB | Vanilla JS, unminified |
23 + | JS HTMX 2.0.4 | 51 KB | ~16 KB | Local copy, minified |
24 + | Fonts (woff2, 5 faces) | 122 KB | ~115 KB | Already compressed |
25 + | Images (logo + favicon) | 18 KB | ~15 KB | Optimized |
26 + | **Typical first load** | **~308 KB** | **~170 KB** | Fonts cached after first visit |
27 + | **Subsequent pages** | **~186 KB** | **~55 KB** | Fonts + HTMX + CSS cached |
28 +
29 + ### CSS Files
30 +
31 + - `style.css` — main stylesheet, 431 classes
32 + - `wizard.css` — wizard-specific styles, loaded only on wizard pages
33 +
34 + ### JS Files
35 +
36 + | File | Size | Loaded on |
37 + |------|------|-----------|
38 + | `mnw.js` | Main script | All pages |
39 + | `passkey.js` | WebAuthn | `/login` only |
40 + | `upload.js` | File upload | Dashboard pages |
41 + | `insertions.js` | Content editor | Dashboard pages |
42 + | `wizard.js` | Step wizard | Wizard pages only |
43 +
44 + ### Fonts (woff2)
45 +
46 + 5 font faces served via `@font-face` with `font-display: swap`. Critical faces use `<link rel="preload">`.
47 +
48 + TTF fallbacks exist on disk (1.5 MB) but are never served — woff2 takes priority in all modern browsers.
49 +
50 + ## Compression
51 +
52 + Caddy serves all responses with gzip + zstd compression enabled. No additional build-time minification pipeline.
53 +
54 + ## CSS Unused Classes Analysis
55 +
56 + ~362 of 431 classes in style.css appear unused by simple `grep` against templates, but most are accounted for:
57 +
58 + - **JS-created classes** — toast variants, dragover, fade-out, active states (created dynamically)
59 + - **Askama template conditionals** — `class="toast-{{ type }}"` and similar dynamic class construction
60 + - **Git browser classes** — 30+ classes for the repository viewer feature (confirmed active)
61 + - **Use-case + analytics pages** — confirmed used in their respective templates
62 +
63 + True dead CSS is minimal. A browser-based Coverage tool would give exact numbers, but static analysis shows no large dead sections.
64 +
65 + ## Assessed Non-Issues
66 +
67 + | Item | Assessment |
68 + |------|-----------|
69 + | Custom JS unminified | Saves 1-2 KB gzipped if minified. Not worth a build step at this scale. |
70 + | TTF font files on disk | Not served to browsers. Only occupies disk space, not bandwidth. |
71 + | upload.js on non-upload pages | 1.1 KB gzipped. Marginal overhead, not worth conditional loading complexity. |
72 + | HTMX served locally | Correct — avoids CDN dependency, enables offline-first development. |
73 +
74 + ## What's Good
75 +
76 + - **No framework bloat** — no Tailwind, Bootstrap, React, or build toolchain
77 + - **woff2 fonts** with preload + swap — fast text rendering
78 + - **Conditional JS loading** — scripts loaded only where needed
79 + - **HTMX 2.0.4** — current version, minimal footprint
80 + - **Zero unused JS functions** — all exports have documented call sites
81 + - **Caddy compression** — gzip + zstd on all routes, no configuration gaps
82 +
83 + ## Key Paths
84 +
85 + - `static/css/` — stylesheets
86 + - `static/js/` — JavaScript files
87 + - `static/fonts/` — font files (woff2 + ttf)
88 + - `static/images/` — logo + favicon
89 + - `src/templates/` — Askama templates (CSS class consumers)
M docs/todo.md +53 -4
@@ -1,9 +1,9 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - Done: All pre-beta phases + frontend audit. Active: None. Next: Post-beta features below.
4 + Done: All pre-beta phases + frontend audit + content fingerprinting. Active: None. Next: Post-beta features below.
5 5
6 - Live at makenot.work. v0.3.17. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed.
6 + Live at makenot.work. v0.3.18. Audit grade A. Stripe + Postmark live. All platform integrations (I1-I5) deployed.
7 7
8 8 **Scope:** Sections tagged `(pre-beta)` ship before initial beta. Untagged sections are post-beta.
9 9
@@ -186,6 +186,54 @@ Findings from investor/business and marketing review of all customer-facing temp
186 186
187 187 ---
188 188
189 + ## Content Fingerprinting (Anti-Piracy)
190 +
191 + Migration 051. 27 unit tests + 10 integration tests.
192 +
193 + ### Done
194 + - [x] Transactional fingerprint registry (`db/fingerprints.rs`, `download_fingerprints` table)
195 + - [x] Visible "Licensed to" stamps — language-aware comment headers for 30+ extensions (`fingerprint/visible.rs`)
196 + - [x] Invisible text watermarks — zero-width character encoding with round-trip extract (`fingerprint/watermark_text.rs`)
197 + - [x] Token-gated streaming — IP-bound sessions, concurrency cap (2), stale expiry (`fingerprint/streaming.rs`)
198 + - [x] License key binding — phone-home verify + deactivate endpoints, JWT offline grace (`routes/api/license_keys.rs`)
199 + - [x] `streaming_sessions` table with IP binding and expiry
200 + - [x] `license_activations` table with machine fingerprint + activation cap
201 + - [x] `license_verification_enabled` flag on projects
202 + - [x] Scheduler cleanup of stale streaming sessions
203 + - [x] Download routes record fingerprints for paid content
204 +
205 + ### Remaining
206 + - [ ] Invisible image watermarks — LSB encoding (stub exists at `fingerprint/watermark_image.rs`)
207 + - [ ] Invisible audio watermarks — spread-spectrum (stub exists at `fingerprint/watermark_audio.rs`)
208 + - [ ] Wire visible stamps into download routes (stamp text files before serving)
209 + - [ ] Wire ZWC watermarks into download routes (watermark text files before serving)
210 + - [ ] ZIP/tar archive stamping (inject LICENSE.txt at archive root)
211 + - [ ] Dashboard UI for fingerprint tracing (lookup by fingerprint_id, view download history)
212 + - [ ] Anti-hotlink header checks in streaming routes (logic exists in `fingerprint/streaming.rs`)
213 +
214 + ---
215 +
216 + ## Bundled License Text
217 +
218 + Migration 052. 8 unit tests + 5 integration tests.
219 +
220 + Per-item license configuration: creators pick from 7 presets or write custom terms. License displayed on item page, downloadable as LICENSE.txt, URL included in download API responses.
221 +
222 + ### Done
223 + - [x] `license_preset` and `custom_license_text` columns on items (migration 052)
224 + - [x] License templates module (`fingerprint/license_templates.rs`) — 7 presets + custom, `{year}`/`{owner}` placeholder substitution
225 + - [x] `update_item_license_text` DB function (`db/items.rs`)
226 + - [x] License preset in `PUT /api/items/{id}/license-settings` (validation: custom requires text, preset key validated)
227 + - [x] `GET /api/items/{id}/license.txt` — public endpoint, renders license as text/plain
228 + - [x] `license_url` field in `VersionDownloadResponse` (set when item has a license)
229 + - [x] Wizard distribution step: license preset dropdown + custom textarea
230 + - [x] Dashboard pricing tab: license preset dropdown + custom textarea in license settings form
231 + - [x] Item public page: license section with name, collapsible full text (lazy-loaded), download link
232 + - [x] 8 unit tests (preset round-trip, render substitution, custom text, options count)
233 + - [x] 5 integration tests (preset set+serve, custom license, 404 when none, clear license, validation)
234 +
235 + ---
236 +
189 237 ## Post-Beta
190 238
191 239 ### Phase 11B: Promotions
@@ -366,7 +414,7 @@ Archive policy: items on platform 12+ months stay hosted if creator cancels.
366 414 - [ ] Revisit admin system (currently config-based ADMIN_USER_ID)
367 415 - [ ] Test restore from backup
368 416 - [ ] S3 bucket versioning
369 - - [ ] PDF stamping (watermark with buyer email/name — Gumroad has this as piracy disincentive)
417 + - [ ] PDF stamping (watermark with buyer email/name — superseded by fingerprinting system, needs PDF library integration)
370 418
371 419 ## Key Paths
372 420 ```
@@ -374,9 +422,10 @@ MNW/src/
374 422 lib.rs, main.rs, config.rs, error.rs, auth.rs, db/
375 423 storage.rs, payments.rs, templates/, routes/
376 424 git.rs, git_issues.rs, synckit_auth.rs, build_runner.rs, validation.rs
425 + fingerprint/ (registry, visible stamps, watermarks, streaming)
377 426 MNW/tests/
378 427 integration.rs, harness/, workflows/*.rs
379 - MNW/migrations/ (001-045)
428 + MNW/migrations/ (001-051)
380 429 MNW/templates/
381 430 MNW/deploy/
382 431 MNW/site-docs/public/, MNW/site-docs/unpublished/
@@ -0,0 +1,34 @@
1 + -- Content fingerprinting for anti-piracy: download tracking, streaming sessions, license activation binding.
2 +
3 + -- Track every download/stream with a unique fingerprint for tracing leaked content.
4 + CREATE TABLE download_fingerprints (
5 + id BIGSERIAL PRIMARY KEY,
6 + user_id UUID NOT NULL REFERENCES users(id),
7 + content_type TEXT NOT NULL,
8 + content_id TEXT NOT NULL,
9 + fingerprint_id UUID NOT NULL DEFAULT gen_random_uuid(),
10 + watermark_method TEXT,
11 + ip_address INET,
12 + user_agent TEXT,
13 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
14 + );
15 + CREATE INDEX idx_download_fp_user ON download_fingerprints(user_id);
16 + CREATE INDEX idx_download_fp_fingerprint ON download_fingerprints(fingerprint_id);
17 + CREATE INDEX idx_download_fp_content ON download_fingerprints(content_type, content_id);
18 +
19 + -- Server-side streaming session tracking for IP binding and concurrency enforcement.
20 + CREATE TABLE streaming_sessions (
21 + id BIGSERIAL PRIMARY KEY,
22 + user_id UUID NOT NULL REFERENCES users(id),
23 + content_id TEXT NOT NULL,
24 + session_token UUID NOT NULL DEFAULT gen_random_uuid(),
25 + ip_address INET NOT NULL,
26 + started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27 + last_active_at TIMESTAMPTZ NOT NULL DEFAULT now(),
28 + expired BOOLEAN NOT NULL DEFAULT false
29 + );
30 + CREATE INDEX idx_stream_session_user ON streaming_sessions(user_id, expired);
31 + CREATE INDEX idx_stream_session_token ON streaming_sessions(session_token);
32 +
33 + -- Per-project opt-in for license verification (phone-home on first use).
34 + ALTER TABLE projects ADD COLUMN license_verification_enabled BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,3 @@
1 + -- Per-item license text: preset selection and optional custom text.
2 + ALTER TABLE items ADD COLUMN license_preset TEXT;
3 + ALTER TABLE items ADD COLUMN custom_license_text TEXT;
M src/auth.rs +3 -1
@@ -138,7 +138,9 @@ impl FromRequestParts<crate::AppState> for AuthUser {
138 138 user.is_fan_plus = is_fan_plus;
139 139 user.can_create_projects = result.can_create_projects;
140 140 user.creator_tier = creator_tier;
141 - let _ = session.insert(USER_SESSION_KEY, user.clone()).await;
141 + if let Err(e) = session.insert(USER_SESSION_KEY, user.clone()).await {
142 + tracing::warn!(user_id = %user.id, error = ?e, "failed to update session with refreshed user state");
143 + }
142 144 }
143 145 state.session_cache.insert(tracking_id, Instant::now());
144 146 }
@@ -31,7 +31,7 @@
31 31 use clap::{Parser, Subcommand};
32 32 use sqlx::PgPool;
33 33
34 - use makenotwork::db::{self, AppealDecision, SelectionMethod, Username, WaitlistStatus};
34 + use makenotwork::db::{self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus};
35 35
36 36 #[derive(Parser)]
37 37 #[command(name = "mnw-admin", about = "MNW admin CLI")]
@@ -489,7 +489,7 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<(
489 489 "{:<12} {:<30} {:>10} {:<10}",
490 490 date, title_short, amount, tx.status
491 491 );
492 - if tx.status.to_string() == "completed" {
492 + if tx.status == TransactionStatus::Completed {
493 493 total_cents += tx.amount_cents as i64;
494 494 }
495 495 }
@@ -97,13 +97,15 @@ pub async fn dispatch_pending_build(state: &AppState) {
97 97 Ok(Some(c)) => c,
98 98 Ok(None) => {
99 99 tracing::error!(build_id = %build.id, "build config not found for pending build");
100 - let _ = db::builds::update_build_status(
100 + if let Err(e) = db::builds::update_build_status(
101 101 &state.db,
102 102 build.id,
103 103 BuildStatus::Failed,
104 104 Some("Build config not found"),
105 105 )
106 - .await;
106 + .await {
107 + tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (config not found)");
108 + }
107 109 return;
108 110 }
109 111 Err(e) => {
@@ -168,13 +170,15 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
168 170
169 171 if artifact_keys.is_empty() {
170 172 let err_msg = first_error.as_deref().unwrap_or("no targets produced artifacts");
171 - let _ = db::builds::update_build_status(
173 + if let Err(e) = db::builds::update_build_status(
172 174 &state.db,
173 175 build.id,
174 176 BuildStatus::Failed,
175 177 Some(err_msg),
176 178 )
177 - .await;
179 + .await {
180 + tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (no artifacts)");
181 + }
178 182 return;
179 183 }
180 184
@@ -191,13 +195,15 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
191 195 Ok(r) => r,
192 196 Err(e) => {
193 197 let msg = format!("failed to create OTA release: {e}");
194 - let _ = db::builds::update_build_status(
198 + if let Err(e) = db::builds::update_build_status(
195 199 &state.db,
196 200 build.id,
197 201 BuildStatus::Failed,
198 202 Some(&msg),
199 203 )
200 - .await;
204 + .await {
205 + tracing::error!(build_id = %build.id, error = ?e, "failed to mark build as failed (release creation)");
206 + }
201 207 return;
202 208 }
203 209 };
@@ -220,7 +226,9 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
220 226 }
221 227
222 228 // Link build to release
223 - let _ = db::builds::set_build_release(&state.db, build.id, release.id).await;
229 + if let Err(e) = db::builds::set_build_release(&state.db, build.id, release.id).await {
230 + tracing::error!(build_id = %build.id, release_id = %release.id, error = ?e, "failed to link build to release");
231 + }
224 232
225 233 let status = if all_succeeded {
226 234 BuildStatus::Succeeded
@@ -228,13 +236,15 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
228 236 BuildStatus::Failed
229 237 };
230 238
231 - let _ = db::builds::update_build_status(
239 + if let Err(e) = db::builds::update_build_status(
232 240 &state.db,
233 241 build.id,
234 242 status,
235 243 first_error.as_deref(),
236 244 )
237 - .await;
245 + .await {
246 + tracing::error!(build_id = %build.id, status = %status, error = ?e, "failed to update final build status");
247 + }
238 248
239 249 tracing::info!(
240 250 build_id = %build.id,
M src/config.rs +1 -1
@@ -105,7 +105,7 @@ impl Config {
105 105 Ok(secret) => secret,
106 106 Err(_) => {
107 107 // If HOST is 0.0.0.0 or HOST_URL looks like production, refuse to start
108 - let is_production = host.to_string() == "0.0.0.0"
108 + let is_production = host == std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
109 109 || std::env::var("HOST_URL")
110 110 .map(|u| u.starts_with("https://"))
111 111 .unwrap_or(false);
M src/db/enums.rs +1 -1
M src/db/models.rs +8 -16
D src/git.rs -1176
M src/lib.rs +1
D src/payments.rs -1173
M src/scheduler.rs +17 -4