Skip to main content

max / makenotwork

Flatten crate to repo root, reorganize for ~/Code layout - Move server_code/makenotwork/* to repository root - Rename docs/ to site-docs/ (DocEngine content) - Add project docs/ (audit, todo, architecture, internal, archive) - Update .gitignore for nested ecosystem repos (multithreaded, pom, mnw-cli) - Update deploy scripts, CLAUDE.md, README.md for new paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-30 02:15 UTC
Commit: 749c48c9f225bfaf8c2a229674cd6a184829e2ab
Parent: 93b80b7
1396 files changed, +141366 insertions, -128923 deletions
A .env.example +35
@@ -0,0 +1,35 @@
1 + # Server Configuration
2 + HOST=127.0.0.1
3 + PORT=3000
4 +
5 + # Database (on macOS with Homebrew, use your username without password)
6 + DATABASE_URL=postgres://your_user@localhost:5432/makenotwork
7 +
8 + # Authentication
9 + JWT_SECRET=your-super-secret-jwt-key-change-in-production
10 +
11 + # Session
12 + SESSION_SECRET=your-session-secret-change-in-production
13 +
14 + # Optional: Stripe Connect (payments)
15 + # Get these from https://dashboard.stripe.com/apikeys
16 + # STRIPE_SECRET_KEY=sk_test_...
17 + # STRIPE_WEBHOOK_SECRET=whsec_...
18 + # HOST_URL=http://localhost:3000
19 +
20 + # Optional: S3 Storage (for file uploads)
21 + # S3_ENDPOINT=https://fsn1.your-objectstorage.com
22 + # S3_BUCKET=makenotwork-files
23 + # S3_REGION=fsn1
24 + # S3_ACCESS_KEY=
25 + # S3_SECRET_KEY=
26 +
27 + # Optional: CDN for free content downloads (Cloudflare-proxied)
28 + # CDN_BASE_URL=https://cdn.makenot.work
29 +
30 + # Optional: Email (Phase 8)
31 + # SMTP_HOST=
32 + # SMTP_PORT=587
33 + # SMTP_USER=
34 + # SMTP_PASS=
35 + # FROM_EMAIL=noreply@makenot.work
M .gitignore +6 -1
@@ -23,7 +23,12 @@ Thumbs.db
23 23 .sqlx/
24 24
25 25 # Generated template partial (build.rs output)
26 - server_code/makenotwork/templates/_head_assets.html
26 + templates/_head_assets.html
27 27
28 28 # Generated rustdoc output
29 29 rustdoc-out/
30 +
31 + # Nested repos (separate git projects colocated in MNW/)
32 + /multithreaded/
33 + /pom/
34 + /mnw-cli/
M CLAUDE.md +170 -24
@@ -1,24 +1,179 @@
1 1 # Makenotwork
2 2
3 - Fair creator platform. Rust/Axum backend, HTMX frontend, PostgreSQL, Stripe Connect.
3 + Fair creator platform with 0% platform fee (only Stripe's ~3% processing fee). Mission: Prove platforms can be fair — no percentage cuts, no lock-in, everything exportable. Stage: Private alpha — live at makenot.work.
4 +
5 + ## Critical Rules
6 +
7 + **No Emoji.** The diamond mark (Young Serif period glyph) is the only graphic element. No emoji anywhere.
8 +
9 + **Typography (Three-Tier System):**
10 + - **H1**: Young Serif (wordmark, page/section headings)
11 + - **H2/H3/meta**: IBM Plex Mono (subheadings, taglines, footer)
12 + - **Body**: Lato (paragraphs, lists, table content)
13 +
14 + **Colors:**
15 + - Background: warm beige `#ede8e1` — never pure white
16 + - Text: dark charcoal-brown `#3d3530` — never pure black
17 + - Accent: violet `#6c5ce7` — diamond mark only, used sparingly
18 +
19 + **Platform Principles:**
20 + - **0% platform fee** — Stripe's ~3% processing fee is the only cost
21 + - **No lock-in** — Full data export, month-to-month cancellation
22 + - **Source available** — PolyForm Noncommercial 1.0.0
23 +
24 + ## Pricing Tiers
25 +
26 + - **Basic** — $10/mo (text, all base features)
27 + - **Small Files** — $20/mo (audio, software, plugins, small downloads)
28 + - **Big Files** — $30/mo (video, courses, large downloads)
29 + - **Streaming** — $40/mo (live streaming + everything above)
30 +
31 + ## Ecosystem
32 +
33 + This directory contains the MNW server and related ecosystem projects (each a separate git repo):
34 +
35 + | Project | Path | Description |
36 + |---------|------|-------------|
37 + | MNW Server | `.` (crate root) | Rust/Axum backend, HTMX frontend, PostgreSQL, Stripe Connect |
38 + | Multithreaded | `multithreaded/` | Forum software integrated with MNW (Rust/Axum/PostgreSQL, MNW OAuth) |
39 + | PoM | `pom/` | Production operations monitor (health checks, TLS tracking, email alerts) |
40 + | mnw-cli | `mnw-cli/` | CLI tool for MNW platform |
41 +
42 + Shared libraries live at `../Shared/` (docengine, tagtree, synckit-client, theme-common, themes).
4 43
5 44 ## Repository Layout
6 45
7 46 ```
8 - server_code/makenotwork/ # Main Rust application
9 - src/ # Application source
10 - migrations/ # SQLx migrations (numbered, auto-applied on boot)
11 - templates/ # Askama HTML templates
12 - static/ # CSS, JS, fonts, images
13 - tests/ # Integration tests (workflows/, load/, harness/)
14 - deploy/ # Deployment scripts and config files
15 - deploy.sh # Cross-compile + upload + restart
16 - makenotwork.service # systemd unit file
17 - Caddyfile # Reverse proxy config
18 - backup-db.sh # DB backup script
19 - error-pages/ # Custom 404/500/502 pages
20 - docs/ # Documentation (public/ and unpublished/)
47 + MNW/ # Repository root = crate root
48 + src/ # Application source
49 + migrations/ # SQLx migrations (numbered, auto-applied on boot)
50 + templates/ # Askama HTML templates
51 + static/ # CSS, JS, fonts, images
52 + tests/ # Integration tests (workflows/, load/, harness/)
53 + deploy/ # Deployment scripts and config files
54 + deploy.sh # Cross-compile + upload + restart
55 + makenotwork.service # systemd unit file
56 + Caddyfile # Reverse proxy config
57 + backup-db.sh # DB backup script
58 + error-pages/ # Custom 404/500/502 pages
59 + site-docs/ # DocEngine content (public/ and unpublished/)
60 + docs/ # Project docs (todo, audit, architecture, etc.)
61 + ```
62 +
63 + ## Code Patterns
64 +
21 65 ```
66 + src/
67 + ├── main.rs Entry point
68 + ├── lib.rs Library root
69 + ├── config.rs Configuration
70 + ├── constants.rs Shared constants
71 + ├── error.rs Error handling
72 + ├── auth.rs Authentication
73 + ├── csrf.rs CSRF protection
74 + ├── db/ Database queries (module)
75 + ├── docs.rs Documentation rendering
76 + ├── email/ Email handling (directory module)
77 + ├── git.rs Git source browser logic
78 + ├── helpers.rs Shared helper functions
79 + ├── markdown.rs Markdown rendering
80 + ├── monitor.rs Health monitoring
81 + ├── payments.rs Stripe integration
82 + ├── rss.rs RSS feed generation
83 + ├── scanning/ File scanning (ClamAV, YARA, hash lookup)
84 + ├── scheduler.rs Background task scheduler
85 + ├── sentry_layer.rs Sentry error tracking integration
86 + ├── storage.rs S3 storage
87 + ├── synckit_auth.rs SyncKit JWT auth
88 + ├── templates/ Askama templates (directory module)
89 + ├── types/ Shared types (directory module)
90 + ├── validation.rs Input validation
91 + ├── wordlist.rs Wordlist for invite codes
92 + └── routes/
93 + ├── mod.rs
94 + ├── admin.rs Admin panel
95 + ├── auth.rs Login, signup, logout
96 + ├── api/ JSON API endpoints (directory module)
97 + ├── git.rs Git source browser routes
98 + ├── git_issues.rs Git issue tracker routes
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
106 + ├── oauth.rs OAuth provider routes
107 + ├── postmark.rs Postmark webhook handler
108 + ├── storage.rs File upload/download
109 + ├── stripe/ Stripe webhooks + connect (directory module)
110 + └── synckit.rs SyncKit API endpoints
111 + ```
112 +
113 + Route files should stay under 500 lines. When a route module grows beyond that, split it into a directory module grouped by domain.
114 +
115 + ## Key Patterns
116 +
117 + - `impl_str_enum!` macro for enum <-> string (Display, FromStr, sqlx Type/Encode/Decode)
118 + - `define_pg_uuid_id!` macro for newtype UUID ID wrappers
119 + - `EnvironmentFile=/opt/makenotwork/.env` for all secrets
120 + - SQLx compile-time checked queries; migrations auto-run on boot
121 + - HTMX responses return HTML fragments; JSON fallback for non-HTMX requests
122 + - Tests: each integration test creates/drops its own PostgreSQL database
123 + - **Rust 2024 edition** (Rust 1.85+)
124 + - `site-docs/` = DocEngine content (public/ and unpublished/). Project docs are in `docs/`.
125 +
126 + ## Versioning
127 +
128 + - Semver in `Cargo.toml` (`env!("CARGO_PKG_VERSION")` compiles it into the binary for Sentry release strings)
129 + - **Before every deploy to production**, ask the user what version to set — never auto-bump
130 + - Version bump = edit `Cargo.toml` version field before building
131 +
132 + ---
133 +
134 + ## MNW SyncKit
135 +
136 + Developer infrastructure for indie apps, hosted on Makenotwork.
137 +
138 + ### Services
139 +
140 + - **Cloud Sync** — Push/pull changelog sync with E2E encryption, device management, conflict resolution
141 + - **OTA Updates** — App auto-update server (Tauri-compatible protocol), no app store dependency
142 +
143 + ### Design Philosophy
144 +
145 + - **General-purpose first** — API and SDK decisions should make sense for any app, not just GO
146 + - **E2E encrypted by default** — Server stores only encrypted blobs, never plaintext user data
147 + - **Bring your own schema** — Table names, row IDs, and data shapes are opaque to the server
148 + - **Auth via MNW accounts** — Users authenticate with their Makenot.work credentials
149 +
150 + ### Components
151 +
152 + | Component | Location | Role |
153 + |-----------|----------|------|
154 + | Server API | `src/routes/synckit.rs` | Axum endpoints (auth, push/pull, devices, keys) |
155 + | Server DB | `src/db/synckit.rs` | PostgreSQL queries (sync_apps, sync_devices, sync_log, sync_keys) |
156 + | Server Auth | `src/synckit_auth.rs` | JWT token creation + extraction |
157 + | Client SDK | `../Shared/synckit-client/` | Rust crate — HTTP client, E2E crypto, keychain storage |
158 + | Integration tests | `tests/workflows/synckit.rs` | 7 tests covering auth, devices, push/pull, keys, validation |
159 +
160 + ### Consumers
161 +
162 + | App | What it syncs | Status |
163 + |-----|---------------|--------|
164 + | GoingsOn | Tasks, projects, events, contacts, emails | Implemented |
165 + | Balanced Breakfast | Configs, feed sources, plugin manifests | Integrated |
166 + | audiofiles | Sample metadata, tags, VFS mappings | Integrated |
167 +
168 + ---
169 +
170 + ## CI
171 +
172 + MNW CI runs self-hosted on astra (`deploy/run-ci.sh` — check, test, clippy, audit). GO, BB, and AF still have `.build.yml` manifests for builds.sr.ht (Arch Linux + Rust). Sourcehut (`https://sr.ht/~maxmj/`) remains active as a git mirror. MNW has a built-in git browser (G1, `git2`-based) that reads bare repos from disk.
173 +
174 + ## Infrastructure Diagrams
175 +
176 + Mermaid diagrams documenting the full MNW ecosystem live at `../_meta/diagrams/infra/`. See `index.md` for the table of contents, or open `viewer.html` in a browser to view all 52 diagrams rendered. Keep diagrams in sync when making infrastructure changes.
22 177
23 178 ## Production Server (hetzner)
24 179
@@ -53,7 +208,7 @@ Hetzner VPS, x86_64 Linux. Tailscale hostname: `alpha-west-1` (IP: `100.120.174.
53 208
54 209 ### Deployment
55 210
56 - From `server_code/makenotwork/`:
211 + From the `MNW/` directory:
57 212 ```sh
58 213 ./deploy/deploy.sh # Full: build + config + binary + restart
59 214 ./deploy/deploy.sh --quick # Build + binary + restart (no config upload)
@@ -106,15 +261,6 @@ psql -t -c "SELECT datname FROM pg_database WHERE datname LIKE 'mnw_test_%';" po
106 261 - **Cloudflare** — DNS, CDN, DDoS protection
107 262 - **Fastmail** — business email (support@, legal@, max@)
108 263
109 - ## Key Patterns
110 -
111 - - `impl_str_enum!` macro for enum ↔ string (Display, FromStr, sqlx Type/Encode/Decode)
112 - - `define_pg_uuid_id!` macro for newtype UUID ID wrappers
113 - - `EnvironmentFile=/opt/makenotwork/.env` for all secrets
114 - - SQLx compile-time checked queries; migrations auto-run on boot
115 - - HTMX responses return HTML fragments; JSON fallback for non-HTMX requests
116 - - Tests: each integration test creates/drops its own PostgreSQL database
117 -
118 264 ## Testing
119 265
120 266 ```sh
A Cargo.lock +500
@@ -0,0 +1,7523 @@
1 + # This file is automatically @generated by Cargo.
2 + # It is not intended for manual editing.
3 + version = 4
4 +
5 + [[package]]
6 + name = "addr2line"
7 + version = "0.25.1"
8 + source = "registry+https://github.com/rust-lang/crates.io-index"
9 + checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
10 + dependencies = [
11 + "gimli",
12 + ]
13 +
14 + [[package]]
15 + name = "adler2"
16 + version = "2.0.1"
17 + source = "registry+https://github.com/rust-lang/crates.io-index"
18 + checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
19 +
20 + [[package]]
21 + name = "aes"
22 + version = "0.8.4"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
25 + dependencies = [
26 + "cfg-if",
27 + "cipher",
28 + "cpufeatures",
29 + ]
30 +
31 + [[package]]
32 + name = "aho-corasick"
33 + version = "1.1.4"
34 + source = "registry+https://github.com/rust-lang/crates.io-index"
35 + checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
36 + dependencies = [
37 + "log",
38 + "memchr",
39 + ]
40 +
41 + [[package]]
42 + name = "allocator-api2"
43 + version = "0.2.21"
44 + source = "registry+https://github.com/rust-lang/crates.io-index"
45 + checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
46 +
47 + [[package]]
48 + name = "ammonia"
49 + version = "4.1.2"
50 + source = "registry+https://github.com/rust-lang/crates.io-index"
51 + checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
52 + dependencies = [
53 + "cssparser",
54 + "html5ever",
55 + "maplit",
56 + "tendril",
57 + "url",
58 + ]
59 +
60 + [[package]]
61 + name = "android_system_properties"
62 + version = "0.1.5"
63 + source = "registry+https://github.com/rust-lang/crates.io-index"
64 + checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
65 + dependencies = [
66 + "libc",
67 + ]
68 +
69 + [[package]]
70 + name = "annotate-snippets"
71 + version = "0.12.13"
72 + source = "registry+https://github.com/rust-lang/crates.io-index"
73 + checksum = "74fc7650eedcb2fee505aad48491529e408f0e854c2d9f63eb86c1361b9b3f93"
74 + dependencies = [
75 + "anstyle",
76 + "memchr",
77 + "unicode-width",
78 + ]
79 +
80 + [[package]]
81 + name = "anstream"
82 + version = "1.0.0"
83 + source = "registry+https://github.com/rust-lang/crates.io-index"
84 + checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
85 + dependencies = [
86 + "anstyle",
87 + "anstyle-parse",
88 + "anstyle-query",
89 + "anstyle-wincon",
90 + "colorchoice",
91 + "is_terminal_polyfill",
92 + "utf8parse",
93 + ]
94 +
95 + [[package]]
96 + name = "anstyle"
97 + version = "1.0.14"
98 + source = "registry+https://github.com/rust-lang/crates.io-index"
99 + checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
100 +
101 + [[package]]
102 + name = "anstyle-parse"
103 + version = "1.0.0"
104 + source = "registry+https://github.com/rust-lang/crates.io-index"
105 + checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
106 + dependencies = [
107 + "utf8parse",
108 + ]
109 +
110 + [[package]]
111 + name = "anstyle-query"
112 + version = "1.1.5"
113 + source = "registry+https://github.com/rust-lang/crates.io-index"
114 + checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
115 + dependencies = [
116 + "windows-sys 0.61.2",
117 + ]
118 +
119 + [[package]]
120 + name = "anstyle-wincon"
121 + version = "3.0.11"
122 + source = "registry+https://github.com/rust-lang/crates.io-index"
123 + checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
124 + dependencies = [
125 + "anstyle",
126 + "once_cell_polyfill",
127 + "windows-sys 0.61.2",
128 + ]
129 +
130 + [[package]]
131 + name = "anyhow"
132 + version = "1.0.102"
133 + source = "registry+https://github.com/rust-lang/crates.io-index"
134 + checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
135 +
136 + [[package]]
137 + name = "arbitrary"
138 + version = "1.4.2"
139 + source = "registry+https://github.com/rust-lang/crates.io-index"
140 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
141 +
142 + [[package]]
143 + name = "argon2"
144 + version = "0.5.3"
145 + source = "registry+https://github.com/rust-lang/crates.io-index"
146 + checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
147 + dependencies = [
148 + "base64ct",
149 + "blake2",
150 + "cpufeatures",
151 + "password-hash",
152 + ]
153 +
154 + [[package]]
155 + name = "ascii_tree"
156 + version = "0.1.1"
157 + source = "registry+https://github.com/rust-lang/crates.io-index"
158 + checksum = "ca6c635b3aa665c649ad1415f1573c85957dfa47690ec27aebe7ec17efe3c643"
159 +
160 + [[package]]
161 + name = "askama"
162 + version = "0.13.1"
163 + source = "registry+https://github.com/rust-lang/crates.io-index"
164 + checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7"
165 + dependencies = [
166 + "askama_derive",
167 + "itoa",
168 + "percent-encoding",
169 + "serde",
170 + "serde_json",
171 + ]
172 +
173 + [[package]]
174 + name = "askama_derive"
175 + version = "0.13.1"
176 + source = "registry+https://github.com/rust-lang/crates.io-index"
177 + checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac"
178 + dependencies = [
179 + "askama_parser",
180 + "basic-toml",
181 + "memchr",
182 + "proc-macro2",
183 + "quote",
184 + "rustc-hash 2.1.1",
185 + "serde",
186 + "serde_derive",
187 + "syn 2.0.117",
188 + ]
189 +
190 + [[package]]
191 + name = "askama_parser"
192 + version = "0.13.0"
193 + source = "registry+https://github.com/rust-lang/crates.io-index"
194 + checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f"
195 + dependencies = [
196 + "memchr",
197 + "serde",
198 + "serde_derive",
199 + "winnow",
200 + ]
201 +
202 + [[package]]
203 + name = "asn1-rs"
204 + version = "0.6.2"
205 + source = "registry+https://github.com/rust-lang/crates.io-index"
206 + checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
207 + dependencies = [
208 + "asn1-rs-derive 0.5.1",
209 + "asn1-rs-impl",
210 + "displaydoc",
211 + "nom 7.1.3",
212 + "num-traits",
213 + "rusticata-macros",
214 + "thiserror 1.0.69",
215 + "time",
216 + ]
217 +
218 + [[package]]
219 + name = "asn1-rs"
220 + version = "0.7.1"
221 + source = "registry+https://github.com/rust-lang/crates.io-index"
222 + checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60"
223 + dependencies = [
224 + "asn1-rs-derive 0.6.0",
225 + "asn1-rs-impl",
226 + "displaydoc",
227 + "nom 7.1.3",
228 + "num-traits",
229 + "rusticata-macros",
230 + "thiserror 2.0.18",
231 + "time",
232 + ]
233 +
234 + [[package]]
235 + name = "asn1-rs-derive"
236 + version = "0.5.1"
237 + source = "registry+https://github.com/rust-lang/crates.io-index"
238 + checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
239 + dependencies = [
240 + "proc-macro2",
241 + "quote",
242 + "syn 2.0.117",
243 + "synstructure",
244 + ]
245 +
246 + [[package]]
247 + name = "asn1-rs-derive"
248 + version = "0.6.0"
249 + source = "registry+https://github.com/rust-lang/crates.io-index"
250 + checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
251 + dependencies = [
252 + "proc-macro2",
253 + "quote",
254 + "syn 2.0.117",
255 + "synstructure",
256 + ]
257 +
258 + [[package]]
259 + name = "asn1-rs-impl"
260 + version = "0.2.0"
261 + source = "registry+https://github.com/rust-lang/crates.io-index"
262 + checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
263 + dependencies = [
264 + "proc-macro2",
265 + "quote",
266 + "syn 2.0.117",
267 + ]
268 +
269 + [[package]]
270 + name = "async-channel"
271 + version = "1.9.0"
272 + source = "registry+https://github.com/rust-lang/crates.io-index"
273 + checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
274 + dependencies = [
275 + "concurrent-queue",
276 + "event-listener 2.5.3",
277 + "futures-core",
278 + ]
279 +
280 + [[package]]
281 + name = "async-stream"
282 + version = "0.3.6"
283 + source = "registry+https://github.com/rust-lang/crates.io-index"
284 + checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
285 + dependencies = [
286 + "async-stream-impl",
287 + "futures-core",
288 + "pin-project-lite",
289 + ]
290 +
291 + [[package]]
292 + name = "async-stream-impl"
293 + version = "0.3.6"
294 + source = "registry+https://github.com/rust-lang/crates.io-index"
295 + checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
296 + dependencies = [
297 + "proc-macro2",
298 + "quote",
299 + "syn 2.0.117",
300 + ]
301 +
302 + [[package]]
303 + name = "async-stripe"
304 + version = "0.37.3"
305 + source = "registry+https://github.com/rust-lang/crates.io-index"
306 + checksum = "e2f14b5943a52cf051bbbbb68538e93a69d1e291934174121e769f4b181113f5"
307 + dependencies = [
308 + "chrono",
309 + "futures-util",
310 + "hex",
311 + "hmac",
312 + "http-types",
313 + "hyper 0.14.32",
314 + "hyper-tls 0.5.0",
315 + "serde",
316 + "serde_json",
317 + "serde_path_to_error",
318 + "serde_qs 0.10.1",
319 + "sha2",
320 + "smart-default",
321 + "smol_str",
322 + "thiserror 1.0.69",
323 + "tokio",
324 + "uuid 0.8.2",
325 + ]
326 +
327 + [[package]]
328 + name = "async-trait"
329 + version = "0.1.89"
330 + source = "registry+https://github.com/rust-lang/crates.io-index"
331 + checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
332 + dependencies = [
333 + "proc-macro2",
334 + "quote",
335 + "syn 2.0.117",
336 + ]
337 +
338 + [[package]]
339 + name = "atoi"
340 + version = "2.0.0"
341 + source = "registry+https://github.com/rust-lang/crates.io-index"
342 + checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
343 + dependencies = [
344 + "num-traits",
345 + ]
346 +
347 + [[package]]
348 + name = "atomic-waker"
349 + version = "1.1.2"
350 + source = "registry+https://github.com/rust-lang/crates.io-index"
351 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
352 +
353 + [[package]]
354 + name = "autocfg"
355 + version = "1.5.0"
356 + source = "registry+https://github.com/rust-lang/crates.io-index"
357 + checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
358 +
359 + [[package]]
360 + name = "aws-config"
361 + version = "1.8.15"
362 + source = "registry+https://github.com/rust-lang/crates.io-index"
363 + checksum = "11493b0bad143270fb8ad284a096dd529ba91924c5409adeac856cc1bf047dbc"
364 + dependencies = [
365 + "aws-credential-types",
366 + "aws-runtime",
367 + "aws-sdk-sso",
368 + "aws-sdk-ssooidc",
369 + "aws-sdk-sts",
370 + "aws-smithy-async",
371 + "aws-smithy-http 0.63.6",
372 + "aws-smithy-json 0.62.5",
373 + "aws-smithy-runtime",
374 + "aws-smithy-runtime-api",
375 + "aws-smithy-types",
376 + "aws-types",
377 + "bytes",
378 + "fastrand 2.3.0",
379 + "hex",
380 + "http 1.4.0",
381 + "sha1",
382 + "time",
383 + "tokio",
384 + "tracing",
385 + "url",
386 + "zeroize",
387 + ]
388 +
389 + [[package]]
390 + name = "aws-credential-types"
391 + version = "1.2.14"
392 + source = "registry+https://github.com/rust-lang/crates.io-index"
393 + checksum = "8f20799b373a1be121fe3005fba0c2090af9411573878f224df44b42727fcaf7"
394 + dependencies = [
395 + "aws-smithy-async",
396 + "aws-smithy-runtime-api",
397 + "aws-smithy-types",
398 + "zeroize",
399 + ]
400 +
401 + [[package]]
402 + name = "aws-lc-rs"
403 + version = "1.16.2"
404 + source = "registry+https://github.com/rust-lang/crates.io-index"
405 + checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
406 + dependencies = [
407 + "aws-lc-sys",
408 + "zeroize",
409 + ]
410 +
411 + [[package]]
412 + name = "aws-lc-sys"
413 + version = "0.39.0"
414 + source = "registry+https://github.com/rust-lang/crates.io-index"
415 + checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
416 + dependencies = [
417 + "cc",
418 + "cmake",
419 + "dunce",
420 + "fs_extra",
421 + ]
422 +
423 + [[package]]
424 + name = "aws-runtime"
425 + version = "1.7.2"
426 + source = "registry+https://github.com/rust-lang/crates.io-index"
427 + checksum = "5fc0651c57e384202e47153c1260b84a9936e19803d747615edf199dc3b98d17"
428 + dependencies = [
429 + "aws-credential-types",
430 + "aws-sigv4",
431 + "aws-smithy-async",
432 + "aws-smithy-eventstream",
433 + "aws-smithy-http 0.63.6",
434 + "aws-smithy-runtime",
435 + "aws-smithy-runtime-api",
436 + "aws-smithy-types",
437 + "aws-types",
438 + "bytes",
439 + "bytes-utils",
440 + "fastrand 2.3.0",
441 + "http 0.2.12",
442 + "http 1.4.0",
443 + "http-body 0.4.6",
444 + "http-body 1.0.1",
445 + "percent-encoding",
446 + "pin-project-lite",
447 + "tracing",
448 + "uuid 1.22.0",
449 + ]
450 +
451 + [[package]]
452 + name = "aws-sdk-s3"
453 + version = "1.119.0"
454 + source = "registry+https://github.com/rust-lang/crates.io-index"
455 + checksum = "1d65fddc3844f902dfe1864acb8494db5f9342015ee3ab7890270d36fbd2e01c"
456 + dependencies = [
457 + "aws-credential-types",
458 + "aws-runtime",
459 + "aws-sigv4",
460 + "aws-smithy-async",
461 + "aws-smithy-checksums",
462 + "aws-smithy-eventstream",
463 + "aws-smithy-http 0.62.6",
464 + "aws-smithy-json 0.61.9",
465 + "aws-smithy-runtime",
466 + "aws-smithy-runtime-api",
467 + "aws-smithy-types",
468 + "aws-smithy-xml",
469 + "aws-types",
470 + "bytes",
471 + "fastrand 2.3.0",
472 + "hex",
473 + "hmac",
474 + "http 0.2.12",
475 + "http 1.4.0",
476 + "http-body 0.4.6",
477 + "lru",
478 + "percent-encoding",
479 + "regex-lite",
480 + "sha2",
481 + "tracing",
482 + "url",
483 + ]
484 +
485 + [[package]]
486 + name = "aws-sdk-sso"
487 + version = "1.97.0"
488 + source = "registry+https://github.com/rust-lang/crates.io-index"
489 + checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567"
490 + dependencies = [
491 + "aws-credential-types",
492 + "aws-runtime",
493 + "aws-smithy-async",
494 + "aws-smithy-http 0.63.6",
495 + "aws-smithy-json 0.62.5",
496 + "aws-smithy-observability",
497 + "aws-smithy-runtime",
498 + "aws-smithy-runtime-api",
499 + "aws-smithy-types",
500 + "aws-types",
Lines truncated
A Cargo.toml +111
@@ -0,0 +1,111 @@
1 + [package]
2 + name = "makenotwork"
3 + version = "0.3.17"
4 + edition = "2024"
5 + license-file = "LICENSE"
6 +
7 + [dependencies]
8 + # Async trait (for StorageBackend trait object)
9 + async-trait = "0.1"
10 +
11 + # Web framework
12 + axum = { version = "0.8.8", features = ["macros"] }
13 + axum-extra = { version = "0.10.3", features = ["cookie", "form", "typed-header"] }
14 + serde = { version = "1.0.228", features = ["derive"] }
15 + serde_json = "1.0.149"
16 + tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread", "net", "signal"] }
17 + tower = "0.5.3"
18 + tower-http = { version = "0.6.8", features = ["trace", "fs", "limit", "request-id", "propagate-header", "set-header"] }
19 + tracing = "0.1.44"
20 + tracing-subscriber = { version = "0.3.22", features = ["env-filter", "json"] }
21 +
22 + # Templates
23 + askama = "0.13.1"
24 +
25 + # Environment & Configuration
26 + dotenvy = "0.15.7"
27 +
28 + # Database
29 + sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres", "uuid", "chrono", "migrate"] }
30 + uuid = { version = "1.20.0", features = ["v4", "serde"] }
31 + chrono = { version = "0.4.43", features = ["serde"] }
32 +
33 + # Authentication
34 + argon2 = "0.5.3"
35 + tower-sessions = { version = "0.14.0", features = ["axum-core"] }
36 + tower-sessions-sqlx-store = { version = "0.15.0", features = ["postgres"] }
37 +
38 + # Concurrent hash map (session touch cache)
39 + dashmap = "6"
40 +
41 + # Rate Limiting
42 + tower_governor = "0.6.0"
43 + governor = "0.8.1"
44 +
45 + # JWT (SyncKit)
46 + jsonwebtoken = "9.3.1"
47 +
48 + # TOTP / 2FA
49 + totp-rs = { version = "5.7", features = ["qr"] }
50 +
51 + # WebAuthn / Passkeys
52 + webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "conditional-ui"] }
53 + webauthn-rs-proto = "0.5"
54 +
55 + # OpenSSL (transitive dep from git2, webauthn-rs — vendored for cross-compilation)
56 + openssl = { version = "0.10", features = ["vendored"] }
57 +
58 + # Security
59 + rand = "0.8.5"
60 + hmac = "0.12.1"
61 + sha1 = "0.10.6"
62 + sha2 = "0.10.9"
63 + hex = "0.4.3"
64 + base64 = "0.22.1"
65 +
66 + # File scanning
67 + infer = "0.19"
68 + goblin = "0.10"
69 + zip = "8.2"
70 + yara-x = "1.13"
71 +
72 + # CLI
73 + clap = { version = "4", features = ["derive"] }
74 +
75 + # Error handling
76 + thiserror = "2.0.18"
77 + anyhow = "1.0.101"
78 +
79 + # Markdown rendering + documentation engine
80 + docengine = { path = "../Shared/docengine", features = ["doc-loader", "frontmatter"] }
81 +
82 + # Tag standard
83 + tagtree = { path = "../Shared/tagtree" }
84 +
85 + # Git source browser
86 + git2 = { version = "0.20", features = ["vendored-libgit2"] }
87 + syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }
88 + regex = "1"
89 + semver = "1"
90 +
91 + # S3 Storage
92 + aws-sdk-s3 = "1.119.0"
93 + aws-config = { version = "1.8.14", features = ["behavior-version-latest"] }
94 +
95 + # Stripe Payments
96 + async-stripe = { version = "0.37.3", features = ["runtime-tokio-hyper", "checkout", "connect", "billing"] }
97 + reqwest = { version = "0.12", features = ["json", "cookies"] }
98 + urlencoding = "2.1.3"
99 +
100 + # URL parsing
101 + url = "2.5.8"
102 +
103 + [[bin]]
104 + name = "mnw-admin"
105 + path = "src/bin/mnw-admin.rs"
106 +
107 + [dev-dependencies]
108 + tower = { version = "0.5.3", features = ["util"] }
109 + http-body-util = "0.1"
110 + webauthn-authenticator-rs = { version = "0.5", features = ["softpasskey"] }
111 + tempfile = "3"
M README.md +2 -2
@@ -12,7 +12,7 @@ Built with Rust (2024 edition), Axum, PostgreSQL, Askama templates, and HTMX.
12 12
13 13 ## Build and Run
14 14
15 - All commands run from `server_code/makenotwork/`:
15 + All commands run from the `MNW/` directory:
16 16
17 17 ```sh
18 18 # Development
@@ -33,7 +33,7 @@ Production deployment uses `cargo zigbuild` for cross-compilation to x86_64 Linu
33 33 ## Project Structure
34 34
35 35 ```
36 - server_code/makenotwork/
36 + MNW/
37 37 src/
38 38 main.rs Entry point
39 39 lib.rs Library root
A build.rs +63
@@ -0,0 +1,63 @@
1 + use std::collections::hash_map::DefaultHasher;
2 + use std::hash::{Hash, Hasher};
3 + use std::process::Command;
4 + use std::{fs, path::Path};
5 +
6 + fn main() {
7 + // Set GIT_HASH env var for compile-time inclusion via option_env!()
8 + let hash = Command::new("git")
9 + .args(["rev-parse", "--short", "HEAD"])
10 + .output()
11 + .ok()
12 + .filter(|o| o.status.success())
13 + .and_then(|o| String::from_utf8(o.stdout).ok())
14 + .map(|s| s.trim().to_string())
15 + .unwrap_or_default();
16 +
17 + println!("cargo::rustc-env=GIT_HASH={}", hash);
18 + // Only re-run when HEAD changes
19 + println!("cargo::rerun-if-changed=.git/HEAD");
20 +
21 + // --- Static asset fingerprinting ---
22 + // Hash the content of key static files to produce a version suffix.
23 + // When any watched file changes, URLs in templates get a new ?v= param,
24 + // busting browser caches automatically.
25 + let static_files = [
26 + "static/style.css",
27 + "static/htmx.min.js",
28 + "static/upload.js",
29 + "static/passkey.js",
30 + "static/insertions.js",
31 + ];
32 +
33 + let mut hasher = DefaultHasher::new();
34 + for path in &static_files {
35 + println!("cargo::rerun-if-changed={}", path);
36 + if let Ok(content) = fs::read(path) {
37 + content.hash(&mut hasher);
38 + }
39 + }
40 + let static_hash = format!("{:016x}", hasher.finish());
41 + let version = &static_hash[..8];
42 +
43 + // Generate a template partial with versioned asset URLs.
44 + // base.html includes this via {% include "_head_assets.html" %}
45 + let partial = format!(
46 + r#" <link rel="preload" href="/static/fonts/Lato-Regular.woff2" as="font" type="font/woff2" crossorigin>
47 + <link rel="preload" href="/static/fonts/ysrf.woff2" as="font" type="font/woff2" crossorigin>
48 + <link rel="stylesheet" href="/static/style.css?v={v}">
49 + <link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
50 + <script src="/static/htmx.min.js"></script>
51 + <script src="/static/upload.js?v={v}"></script>"#,
52 + v = version,
53 + );
54 +
55 + let out_path = Path::new("templates/_head_assets.html");
56 + // Only write if content changed (avoids unnecessary recompilation)
57 + let needs_write = fs::read_to_string(out_path)
58 + .map(|existing| existing != partial)
59 + .unwrap_or(true);
60 + if needs_write {
61 + fs::write(out_path, &partial).expect("failed to write _head_assets.html");
62 + }
63 + }
@@ -0,0 +1,209 @@
1 + # Makenotwork Caddy Configuration
2 + # Place in /etc/caddy/Caddyfile on the server
3 + #
4 + # TLS: Cloudflare Origin CA cert (wildcard *.makenot.work + makenot.work)
5 + # All HTTPS traffic routed through Cloudflare proxy (origin IP hidden).
6 + # Authenticated Origin Pulls: only Cloudflare can reach the origin.
7 + # git.makenot.work redirects browser visits to the web UI.
8 + # SSH clone uses ssh.makenot.work (proxy OFF in Cloudflare).
9 + #
10 + # Custom domains: on-demand TLS via Let's Encrypt (ACME HTTP-01).
11 + # The ask endpoint validates that the domain is verified before issuing a cert.
12 + # makenot.work subdomains remain protected by Cloudflare mTLS even with ports open.
13 +
14 + {
15 + on_demand_tls {
16 + ask http://localhost:3000/api/domains/caddy-ask
17 + }
18 + }
19 +
20 + # Shared TLS config: Origin CA cert + Authenticated Origin Pulls (mTLS)
21 + (cloudflare_tls) {
22 + tls /etc/caddy/cloudflare-origin.pem /etc/caddy/cloudflare-origin-key.pem {
23 + client_auth {
24 + mode require_and_verify
25 + trusted_ca_cert_file /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem
26 + }
27 + }
28 + }
29 +
30 + makenot.work {
31 + import cloudflare_tls
32 +
33 + # Block internal API from external access (CLI uses localhost directly)
34 + @internal path /api/internal/*
35 + respond @internal 404
36 +
37 + # Reverse proxy to application (includes /docs routes)
38 + reverse_proxy localhost:3000
39 +
40 + # Security headers
41 + header {
42 + X-Frame-Options "SAMEORIGIN"
43 + X-Content-Type-Options "nosniff"
44 + X-XSS-Protection "1; mode=block"
45 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
46 + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(self)"
47 + Referrer-Policy "strict-origin-when-cross-origin"
48 + Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline' https://unpkg.com https://js.stripe.com; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data: https://fsn1.your-objectstorage.com https://cdn.makenot.work; connect-src 'self' https://api.stripe.com https://fsn1.your-objectstorage.com https://cdn.makenot.work; media-src 'self' https://fsn1.your-objectstorage.com https://cdn.makenot.work; frame-src https://js.stripe.com; base-uri 'self'; form-action 'self'"
49 + }
50 +
51 + # Static error pages when app is down
52 + handle_errors {
53 + @404 expression {err.status_code} == 404
54 + handle @404 {
55 + root * /opt/makenotwork/error-pages
56 + rewrite * /404.html
57 + file_server
58 + }
59 + @500 expression {err.status_code} == 500
60 + handle @500 {
61 + root * /opt/makenotwork/error-pages
62 + rewrite * /500.html
63 + file_server
64 + }
65 + handle {
66 + root * /opt/makenotwork/error-pages
67 + rewrite * /502.html
68 + file_server
69 + }
70 + }
71 +
72 + encode gzip zstd
73 +
74 + log {
75 + output file /var/log/caddy/makenotwork.log
76 + format json
77 + }
78 + }
79 +
80 + # Multithreaded forum
81 + forums.makenot.work {
82 + import cloudflare_tls
83 +
84 + reverse_proxy localhost:3400
85 +
86 + header {
87 + X-Frame-Options "SAMEORIGIN"
88 + X-Content-Type-Options "nosniff"
89 + X-XSS-Protection "1; mode=block"
90 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
91 + Permissions-Policy "camera=(), microphone=(), geolocation=()"
92 + Referrer-Policy "strict-origin-when-cross-origin"
93 + Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self' https://makenot.work"
94 + }
95 +
96 + encode gzip zstd
97 +
98 + log {
99 + output file /var/log/caddy/forums.log
100 + format json
101 + }
102 + }
103 +
104 + # CDN for free content downloads — reverse-proxies to Hetzner Object Storage.
105 + # Cloudflare caches responses at the edge (free egress). Origin only hit on cache miss.
106 + # Requires: S3 bucket policy allowing public s3:GetObject, Cloudflare DNS A record (proxy ON).
107 + cdn.makenot.work {
108 + import cloudflare_tls
109 +
110 + # Only allow GET (downloads). Block mutations.
111 + @not_get not method GET HEAD
112 + respond @not_get 405
113 +
114 + # Prepend bucket name to URI path and proxy to Hetzner Object Storage.
115 + # Replace BUCKET_NAME with the actual S3 bucket name.
116 + rewrite * /BUCKET_NAME{uri}
117 + reverse_proxy https://fsn1.your-objectstorage.com {
118 + header_up Host fsn1.your-objectstorage.com
119 + }
120 +
121 + header {
122 + X-Content-Type-Options "nosniff"
123 + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
124 + Access-Control-Allow-Origin "https://makenot.work"
125 + Access-Control-Allow-Methods "GET, HEAD"
126 + # Cache-Control is set on the S3 objects themselves (immutable).
127 + # Cloudflare respects the origin's Cache-Control header.
128 + }
129 +
130 + log {
131 + output file /var/log/caddy/cdn.log
132 + format json
133 + }
134 + }
135 +
136 + # maxj.phd TLS config: separate Origin CA cert + Authenticated Origin Pulls (mTLS)
137 + (maxjphd_tls) {
138 + tls /etc/caddy/maxj-phd-origin.pem /etc/caddy/maxj-phd-origin-key.pem {
139 + client_auth {
140 + mode require_and_verify
141 + trusted_ca_cert_file /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem
142 + }
143 + }
144 + }
145 +
146 + # Static file downloads (audiofiles binaries, etc.)
147 + dl.maxj.phd {
148 + import maxjphd_tls
149 +
150 + root * /opt/downloads
151 + file_server browse
152 +
153 + header {
154 + X-Content-Type-Options "nosniff"
155 + Strict-Transport-Security "max-age=31536000; includeSubDomains"
156 + }
157 +
158 + encode gzip zstd
159 +
160 + log {
161 + output file /var/log/caddy/dl-maxjphd.log
162 + format json
163 + }
164 + }
165 +
166 + # Redirect www to canonical domain
167 + # Note: makenotwork.com and www.makenotwork.com redirects are handled by
168 + # Cloudflare Redirect Rules (edge-level, no origin hit needed).
169 + # Those domains are not covered by the *.makenot.work Origin CA cert.
170 + # Redirect git subdomain browser visits to web UI
171 + git.makenot.work {
172 + import cloudflare_tls
173 + redir https://makenot.work/git permanent
174 + }
175 +
176 + www.makenot.work {
177 + import cloudflare_tls
178 + redir https://makenot.work{uri} permanent
179 + }
180 +
181 + # Custom domains — on-demand TLS via Let's Encrypt.
182 + # Caddy calls /api/domains/caddy-ask before issuing a cert for any domain.
183 + # makenot.work subdomains are unaffected (matched by explicit blocks above
184 + # which use Cloudflare Origin CA + mTLS).
185 + :443 {
186 + tls {
187 + on_demand
188 + }
189 +
190 + reverse_proxy localhost:3000
191 +
192 + header {
193 + X-Content-Type-Options "nosniff"
194 + Strict-Transport-Security "max-age=31536000; includeSubDomains"
195 + Referrer-Policy "strict-origin-when-cross-origin"
196 + }
197 +
198 + encode gzip zstd
199 +
200 + log {
201 + output file /var/log/caddy/custom-domains.log
202 + format json
203 + }
204 + }
205 +
206 + # HTTP catch-all — redirect to HTTPS (also needed for ACME HTTP-01 challenges)
207 + :80 {
208 + redir https://{host}{uri} permanent
209 + }
@@ -0,0 +1,172 @@
1 + # Database Recovery Procedure
2 +
3 + How to restore the Makenotwork database from a backup.
4 +
5 + Backups are gzipped SQL dumps in `/opt/makenotwork/backups/`, named `makenotwork-YYYYMMDD-HHMMSS.sql.gz`. Kept for 30 days.
6 +
7 + ---
8 +
9 + ## List Available Backups
10 +
11 + ```bash
12 + ls -lh /opt/makenotwork/backups/makenotwork-*.sql.gz
13 + ```
14 +
15 + ## Full Restore
16 +
17 + Replaces the entire database with the backup contents.
18 +
19 + ### 1. Stop the application
20 +
21 + ```bash
22 + sudo systemctl stop makenotwork
23 + ```
24 +
25 + ### 2. Drop and recreate the database
26 +
27 + ```bash
28 + sudo -u postgres psql <<EOF
29 + DROP DATABASE makenotwork;
30 + CREATE DATABASE makenotwork OWNER makenotwork;
31 + EOF
32 + ```
33 +
34 + ### 3. Restore from backup
35 +
36 + ```bash
37 + gunzip -c /opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz \
38 + | psql -U makenotwork -d makenotwork
39 + ```
40 +
41 + ### 4. Verify
42 +
43 + ```bash
44 + psql -U makenotwork -d makenotwork -c "SELECT COUNT(*) FROM users;"
45 + psql -U makenotwork -d makenotwork -c "SELECT COUNT(*) FROM projects;"
46 + psql -U makenotwork -d makenotwork -c "SELECT COUNT(*) FROM items;"
47 + ```
48 +
49 + ### 5. Restart the application
50 +
51 + ```bash
52 + sudo systemctl start makenotwork
53 + sudo systemctl status makenotwork
54 + ```
55 +
56 + ### 6. Smoke test
57 +
58 + - Visit https://makenot.work/ and confirm it loads
59 + - Check /health for system status
60 + - Try logging in
61 +
62 + ---
63 +
64 + ## Selective Restore (Single Table)
65 +
66 + If only one table is corrupted, extract and restore it without touching the rest.
67 +
68 + ### 1. Extract the table from the backup
69 +
70 + ```bash
71 + gunzip -c /opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz \
72 + | grep -A9999999 "^COPY public.TABLE_NAME" \
73 + | sed '/^\\\.$/q' > /tmp/table_restore.sql
74 + ```
75 +
76 + ### 2. Review the extracted data
77 +
78 + ```bash
79 + head -20 /tmp/table_restore.sql
80 + wc -l /tmp/table_restore.sql
81 + ```
82 +
83 + ### 3. Clear and restore the table
84 +
85 + ```bash
86 + psql -U makenotwork -d makenotwork -c "DELETE FROM TABLE_NAME;"
87 + psql -U makenotwork -d makenotwork < /tmp/table_restore.sql
88 + ```
89 +
90 + **Note:** Watch for foreign key constraints. If the table has dependencies, you may need to temporarily disable triggers:
91 +
92 + ```bash
93 + psql -U makenotwork -d makenotwork <<EOF
94 + SET session_replication_role = 'replica';
95 + DELETE FROM TABLE_NAME;
96 + \i /tmp/table_restore.sql
97 + SET session_replication_role = 'origin';
98 + EOF
99 + ```
100 +
101 + ---
102 +
103 + ## Restore to a Separate Database (For Inspection)
104 +
105 + Useful when you want to check backup contents without touching production.
106 +
107 + ```bash
108 + sudo -u postgres createdb makenotwork_restore -O makenotwork
109 + gunzip -c /opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz \
110 + | psql -U makenotwork -d makenotwork_restore
111 +
112 + # Inspect
113 + psql -U makenotwork -d makenotwork_restore
114 +
115 + # Clean up when done
116 + sudo -u postgres dropdb makenotwork_restore
117 + ```
118 +
119 + ---
120 +
121 + ## Failure Scenarios
122 +
123 + ### Application won't start after restore
124 +
125 + Check migration state. The backup includes the `_sqlx_migrations` table, so the app should recognize the schema. If migrations are ahead of the backup:
126 +
127 + ```bash
128 + # Check what the app expects vs what's in the DB
129 + psql -U makenotwork -d makenotwork \
130 + -c "SELECT version, description FROM _sqlx_migrations ORDER BY version;"
131 + ```
132 +
133 + If the backup is from before a migration was applied, the app will attempt to run pending migrations on startup.
134 +
135 + ### Backup file is corrupted
136 +
137 + ```bash
138 + # Test gzip integrity
139 + gzip -t /opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz
140 + ```
141 +
142 + If the most recent backup is bad, use the previous day's backup.
143 +
144 + ### No backups available
145 +
146 + If all backups have been lost, the only option is to start fresh:
147 +
148 + ```bash
149 + sudo -u postgres psql <<EOF
150 + DROP DATABASE makenotwork;
151 + CREATE DATABASE makenotwork OWNER makenotwork;
152 + EOF
153 + sudo systemctl restart makenotwork
154 + # The app will run all migrations and create a clean schema
155 + ```
156 +
157 + ---
158 +
159 + ## Backup Verification
160 +
161 + To confirm backups are running and healthy:
162 +
163 + ```bash
164 + # Check the most recent backup
165 + ls -lt /opt/makenotwork/backups/makenotwork-*.sql.gz | head -1
166 +
167 + # Check backup log for errors
168 + tail -20 /opt/makenotwork/backups/backup.log
169 +
170 + # Check cron is scheduled
171 + sudo crontab -u makenotwork -l
172 + ```
@@ -0,0 +1,352 @@
1 + # Makenotwork Server Setup Guide
2 +
3 + Complete checklist for deploying to Hetzner VPS.
4 +
5 + ---
6 +
7 + ## Pre-Deployment Checklist (Do Now)
8 +
9 + These can be done before provisioning the server:
10 +
11 + ### Stripe Setup
12 + - [ ] Create Stripe account (if not already)
13 + - [ ] Switch to live mode (or stay in test mode for initial testing)
14 + - [ ] Note your **Secret Key** (`sk_live_...` or `sk_test_...`)
15 + - [ ] Go to Settings > Connect settings
16 + - [ ] Note your **Client ID** (`ca_...`)
17 + - [ ] Go to Developers > Webhooks > Add endpoint
18 + - URL: `https://makenot.work/stripe/webhook`
19 + - Events: `checkout.session.completed`, `account.updated`
20 + - Note the **Webhook Secret** (`whsec_...`)
21 +
22 + ### Hetzner Object Storage Setup
23 + - [ ] Create Object Storage bucket in Hetzner Cloud Console
24 + - [ ] Bucket name: `makenotwork-files` (or your choice)
25 + - [ ] Region: `fsn1` (Frankfurt) or your preferred
26 + - [ ] Generate S3 credentials
27 + - [ ] Note: Endpoint, Access Key, Secret Key
28 +
29 + ### DNS Setup
30 + - [ ] Point `makenot.work` A record to server IP
31 + - [ ] Point `www.makenot.work` A record to server IP (for redirect)
32 +
33 + ### Generate Secrets
34 + Run locally and save for later:
35 + ```bash
36 + # JWT Secret
37 + openssl rand -base64 32
38 +
39 + # Session Secret
40 + openssl rand -base64 32
41 +
42 + # Database Password
43 + openssl rand -base64 24
44 + ```
45 +
46 + ---
47 +
48 + ## Server Provisioning
49 +
50 + ### 1. Create Hetzner VPS — DONE
51 + - Type: CCX13 x86 (US-West)
52 + - Disk: 80GB + 10GB
53 + - IP: `5.78.144.244`
54 + - DNS: Cloudflare pointing makenot.work + maxj.phd to this IP
55 +
56 + ### 2. Initial Server Setup
57 + ```bash
58 + # SSH into server
59 + ssh root@100.120.174.96
60 +
61 + # Update system
62 + apt update && apt upgrade -y
63 +
64 + # Set timezone
65 + timedatectl set-timezone America/New_York # or your timezone
66 +
67 + # Create non-root user (optional but recommended)
68 + adduser makenotwork
69 + usermod -aG sudo makenotwork
70 + ```
71 +
72 + ### 3. Install PostgreSQL
73 + ```bash
74 + # Install PostgreSQL
75 + apt install postgresql postgresql-contrib -y
76 +
77 + # Start and enable
78 + systemctl start postgresql
79 + systemctl enable postgresql
80 +
81 + # Create database and user
82 + sudo -u postgres psql << EOF
83 + CREATE USER makenotwork WITH PASSWORD '<DB_PASSWORD>';
84 + CREATE DATABASE makenotwork OWNER makenotwork;
85 + GRANT ALL PRIVILEGES ON DATABASE makenotwork TO makenotwork;
86 + EOF
87 +
88 + # Test connection
89 + psql -U makenotwork -h localhost -d makenotwork
90 + ```
91 +
92 + ### 4. Install Caddy
93 + ```bash
94 + # Install Caddy
95 + apt install -y debian-keyring debian-archive-keyring apt-transport-https
96 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
97 + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
98 + apt update
99 + apt install caddy -y
100 +
101 + # Create log directory
102 + mkdir -p /var/log/caddy
103 + chown caddy:caddy /var/log/caddy
104 + ```
105 +
106 + ### 5. Create Application Directory
107 + ```bash
108 + # Create directories
109 + mkdir -p /opt/makenotwork/docs
110 + chown -R makenotwork:makenotwork /opt/makenotwork
111 +
112 + # If using root for deployment initially:
113 + # mkdir -p /opt/makenotwork/docs
114 + ```
115 +
116 + ### 6. Upload Configuration Files
117 +
118 + From your local machine:
119 + ```bash
120 + # Copy Caddyfile
121 + scp deploy/Caddyfile root@100.120.174.96:/etc/caddy/Caddyfile
122 +
123 + # Copy systemd service
124 + scp deploy/makenotwork.service root@100.120.174.96:/etc/systemd/system/
125 +
126 + # Copy environment template
127 + scp deploy/env.production root@100.120.174.96:/opt/makenotwork/.env
128 + ```
129 +
130 + ### 7. Configure Environment
131 + ```bash
132 + # SSH into server
133 + ssh root@100.120.174.96
134 +
135 + # Edit .env with your actual values
136 + nano /opt/makenotwork/.env
137 +
138 + # Secure the file
139 + chmod 600 /opt/makenotwork/.env
140 + chown makenotwork:makenotwork /opt/makenotwork/.env
141 + ```
142 +
143 + ### 8. Enable Services
144 + ```bash
145 + # Reload systemd
146 + systemctl daemon-reload
147 +
148 + # Enable services
149 + systemctl enable makenotwork
150 + systemctl enable caddy
151 +
152 + # Start Caddy (will get SSL certificate)
153 + systemctl restart caddy
154 + ```
155 +
156 + ---
157 +
158 + ## First Deployment
159 +
160 + ### Cross-Compilation Setup (one-time, on Mac)
161 + ```bash
162 + brew install zig
163 + cargo install cargo-zigbuild
164 + rustup target add x86_64-unknown-linux-gnu
165 + ```
166 +
167 + ### Build and Deploy
168 + From your local machine in the `MNW/` directory:
169 +
170 + ```bash
171 + # Make deploy script executable
172 + chmod +x deploy/deploy.sh
173 +
174 + # Deploy — cross-compiles for x86_64 Linux, uploads binary, restarts service
175 + ./deploy/deploy.sh root@100.120.174.96
176 + ```
177 +
178 + ### Verify Deployment
179 + ```bash
180 + # Check service status
181 + ssh root@100.120.174.96 "systemctl status makenotwork"
182 +
183 + # Check logs
184 + ssh root@100.120.174.96 "journalctl -u makenotwork -f"
185 +
186 + # Test endpoints
187 + curl https://makenot.work/
188 + curl https://makenot.work/docs/
189 + ```
190 +
191 + ---
192 +
193 + ## Git SSH Access
194 +
195 + Public SSH access via `git.makenot.work` for clone/push from anywhere.
196 +
197 + ### Prerequisites
198 +
199 + - `setup-git-ssh.sh` and `setup-ssh-keys.sh` already exist in `deploy/`
200 + - `mnw-admin` binary with `rebuild-keys` and `git-auth` subcommands
201 + - SSH key management UI in dashboard already functional
202 +
203 + ### 1. DNS Record
204 +
205 + Add in Cloudflare (proxy **OFF** — SSH cannot go through Cloudflare):
206 + - Type: `A`
207 + - Name: `git`
208 + - Content: `5.78.144.244`
209 + - Proxy: DNS only (grey cloud)
210 +
211 + ### 2. Create git system user
212 + ```bash
213 + ssh root@100.120.174.96
214 + bash /opt/makenotwork/deploy/setup-git-ssh.sh
215 + ```
216 +
217 + ### 3. Set up sudoers for authorized_keys rebuild
218 + ```bash
219 + bash /opt/makenotwork/deploy/setup-ssh-keys.sh
220 + ```
221 +
222 + ### 4. Install sshd config
223 + ```bash
224 + cp /opt/makenotwork/deploy/sshd-git.conf /etc/ssh/sshd_config.d/git.conf
225 + systemctl restart sshd
226 + ```
227 +
228 + ### 5. Install fail2ban
229 + ```bash
230 + apt install fail2ban -y
231 + cp /opt/makenotwork/deploy/fail2ban-sshd.conf /etc/fail2ban/jail.d/sshd.conf
232 + systemctl enable fail2ban
233 + systemctl restart fail2ban
234 + ```
235 +
236 + ### 6. Configure firewall
237 + ```bash
238 + apt install ufw -y
239 + bash /opt/makenotwork/deploy/setup-firewall.sh
240 + ```
241 +
242 + ### 7. Add GIT_SSH_HOST to .env
243 + ```bash
244 + echo 'GIT_SSH_HOST=git.makenot.work' >> /opt/makenotwork/.env
245 + systemctl restart makenotwork
246 + ```
247 +
248 + ### 8. Verify
249 + ```bash
250 + # Should print "Interactive login disabled" or similar
251 + ssh git@git.makenot.work
252 +
253 + # Clone test (after adding SSH key in dashboard)
254 + git clone git@git.makenot.work:max/makenotwork.git /tmp/test-clone
255 + rm -rf /tmp/test-clone
256 + ```
257 +
258 + ---
259 +
260 + ## Post-Deployment
261 +
262 + ### Remove Demo Data
263 + The demo seed creates a test account. Remove it:
264 + ```bash
265 + # On the server
266 + psql -U makenotwork -d makenotwork << EOF
267 + DELETE FROM transactions WHERE buyer_id IN (SELECT id FROM users WHERE email = 'elena@example.com');
268 + DELETE FROM transactions WHERE seller_id IN (SELECT id FROM users WHERE email = 'elena@example.com');
269 + DELETE FROM items WHERE project_id IN (SELECT id FROM projects WHERE user_id IN (SELECT id FROM users WHERE email = 'elena@example.com'));
270 + DELETE FROM projects WHERE user_id IN (SELECT id FROM users WHERE email = 'elena@example.com');
271 + DELETE FROM custom_links WHERE user_id IN (SELECT id FROM users WHERE email = 'elena@example.com');
272 + DELETE FROM users WHERE email = 'elena@example.com';
273 + EOF
274 + ```
275 +
276 + ### Create Your Account
277 + 1. Go to https://makenot.work/join
278 + 2. Create your account
279 + 3. Go to Dashboard > Account
280 + 4. Connect Stripe
281 + 5. Create a project and upload content
282 +
283 + ---
284 +
285 + ## Troubleshooting
286 +
287 + ### Service won't start
288 + ```bash
289 + # Check logs
290 + journalctl -u makenotwork -n 50
291 +
292 + # Common issues:
293 + # - Database connection: check DATABASE_URL
294 + # - Missing .env: check /opt/makenotwork/.env exists
295 + # - Permission denied: check file ownership
296 + ```
297 +
298 + ### SSL Certificate Issues
299 + ```bash
300 + # Check Caddy logs
301 + journalctl -u caddy -f
302 +
303 + # Verify DNS is pointing to server
304 + dig makenot.work
305 + ```
306 +
307 + ### Database Connection Failed
308 + ```bash
309 + # Test connection
310 + psql -U makenotwork -h localhost -d makenotwork
311 +
312 + # Check PostgreSQL is running
313 + systemctl status postgresql
314 +
315 + # Check pg_hba.conf allows local connections
316 + cat /etc/postgresql/*/main/pg_hba.conf | grep makenotwork
317 + ```
318 +
319 + ### Stripe Webhooks Not Working
320 + 1. Check webhook is configured in Stripe Dashboard
321 + 2. Verify URL: `https://makenot.work/stripe/webhook`
322 + 3. Check STRIPE_WEBHOOK_SECRET matches
323 + 4. Test with Stripe CLI: `stripe listen --forward-to localhost:3000/stripe/webhook`
324 +
325 + ---
326 +
327 + ## Maintenance
328 +
329 + ### Update Application
330 + ```bash
331 + ./deploy/deploy.sh root@100.120.174.96
332 + ```
333 +
334 + ### View Logs
335 + ```bash
336 + # Application logs
337 + ssh root@100.120.174.96 "journalctl -u makenotwork -f"
338 +
339 + # Caddy logs
340 + ssh root@100.120.174.96 "tail -f /var/log/caddy/makenotwork.log"
341 + ```
342 +
343 + ### Database Backups
344 + Automated daily backups with 30-day retention. See setup in `backup-db.sh` header comments.
345 +
346 + For recovery procedures, see `RECOVERY.md`.
347 +
348 + ### Restart Services
349 + ```bash
350 + ssh root@100.120.174.96 "sudo systemctl restart makenotwork"
351 + ssh root@100.120.174.96 "sudo systemctl restart caddy"
352 + ```
@@ -0,0 +1,57 @@
1 + #!/bin/bash
2 + # Makenotwork Database Backup Script
3 + # Runs daily via cron, keeps 30 days of backups.
4 + #
5 + # Setup:
6 + # 1. Copy to server:
7 + # scp deploy/backup-db.sh root@<server>:/opt/makenotwork/
8 + # chmod +x /opt/makenotwork/backup-db.sh
9 + #
10 + # 2. Create backup directory:
11 + # mkdir -p /opt/makenotwork/backups
12 + # chown makenotwork:makenotwork /opt/makenotwork/backups
13 + #
14 + # 3. Add cron job (as makenotwork user):
15 + # sudo crontab -u makenotwork -e
16 + # # Daily at 03:00 UTC:
17 + # 0 3 * * * /opt/makenotwork/backup-db.sh >> /opt/makenotwork/backups/backup.log 2>&1
18 +
19 + set -euo pipefail
20 +
21 + # Configuration
22 + BACKUP_DIR="/opt/makenotwork/backups"
23 + DB_NAME="makenotwork"
24 + DB_USER="makenotwork"
25 + RETENTION_DAYS=30
26 +
27 + # Derived
28 + TIMESTAMP=$(date +%Y%m%d-%H%M%S)
29 + BACKUP_FILE="${BACKUP_DIR}/${DB_NAME}-${TIMESTAMP}.sql.gz"
30 +
31 + echo "[$(date -Iseconds)] Starting backup..."
32 +
33 + # Ensure backup directory exists
34 + mkdir -p "$BACKUP_DIR"
35 +
36 + # Dump and compress
37 + # Uses peer auth (no password needed when running as makenotwork user)
38 + pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "$BACKUP_FILE"
39 +
40 + # Verify the file is non-empty
41 + FILESIZE=$(stat -c%s "$BACKUP_FILE" 2>/dev/null || stat -f%z "$BACKUP_FILE" 2>/dev/null)
42 + if [ "$FILESIZE" -lt 100 ]; then
43 + echo "[$(date -Iseconds)] ERROR: Backup file suspiciously small (${FILESIZE} bytes)"
44 + exit 1
45 + fi
46 +
47 + echo "[$(date -Iseconds)] Backup complete: $BACKUP_FILE ($(du -h "$BACKUP_FILE" | cut -f1))"
48 +
49 + # Prune backups older than retention period
50 + DELETED=$(find "$BACKUP_DIR" -name "${DB_NAME}-*.sql.gz" -mtime +${RETENTION_DAYS} -delete -print | wc -l)
51 + if [ "$DELETED" -gt 0 ]; then
52 + echo "[$(date -Iseconds)] Pruned $DELETED backup(s) older than ${RETENTION_DAYS} days"
53 + fi
54 +
55 + # Summary
56 + TOTAL=$(find "$BACKUP_DIR" -name "${DB_NAME}-*.sql.gz" | wc -l)
57 + echo "[$(date -Iseconds)] Total backups on disk: $TOTAL"
@@ -0,0 +1,35 @@
1 + -----BEGIN CERTIFICATE-----
2 + MIIGCjCCA/KgAwIBAgIIV5G6lVbCLmEwDQYJKoZIhvcNAQENBQAwgZAxCzAJBgNV
3 + BAYTAlVTMRkwFwYDVQQKExBDbG91ZEZsYXJlLCBJbmMuMRQwEgYDVQQLEwtPcmln
4 + aW4gUHVsbDEWMBQGA1UEBxMNU2FuIEZyYW5jaXNjbzETMBEGA1UECBMKQ2FsaWZv
5 + cm5pYTEjMCEGA1UEAxMab3JpZ2luLXB1bGwuY2xvdWRmbGFyZS5uZXQwHhcNMTkx
6 + MDEwMTg0NTAwWhcNMjkxMTAxMTcwMDAwWjCBkDELMAkGA1UEBhMCVVMxGTAXBgNV
7 + BAoTEENsb3VkRmxhcmUsIEluYy4xFDASBgNVBAsTC09yaWdpbiBQdWxsMRYwFAYD
8 + VQQHEw1TYW4gRnJhbmNpc2NvMRMwEQYDVQQIEwpDYWxpZm9ybmlhMSMwIQYDVQQD
9 + ExpvcmlnaW4tcHVsbC5jbG91ZGZsYXJlLm5ldDCCAiIwDQYJKoZIhvcNAQEBBQAD
10 + ggIPADCCAgoCggIBAN2y2zojYfl0bKfhp0AJBFeV+jQqbCw3sHmvEPwLmqDLqynI
11 + 42tZXR5y914ZB9ZrwbL/K5O46exd/LujJnV2b3dzcx5rtiQzso0xzljqbnbQT20e
12 + ihx/WrF4OkZKydZzsdaJsWAPuplDH5P7J82q3re88jQdgE5hqjqFZ3clCG7lxoBw
13 + hLaazm3NJJlUfzdk97ouRvnFGAuXd5cQVx8jYOOeU60sWqmMe4QHdOvpqB91bJoY
14 + QSKVFjUgHeTpN8tNpKJfb9LIn3pun3bC9NKNHtRKMNX3Kl/sAPq7q/AlndvA2Kw3
15 + Dkum2mHQUGdzVHqcOgea9BGjLK2h7SuX93zTWL02u799dr6Xkrad/WShHchfjjRn
16 + aL35niJUDr02YJtPgxWObsrfOU63B8juLUphW/4BOjjJyAG5l9j1//aUGEi/sEe5
17 + lqVv0P78QrxoxR+MMXiJwQab5FB8TG/ac6mRHgF9CmkX90uaRh+OC07XjTdfSKGR
18 + PpM9hB2ZhLol/nf8qmoLdoD5HvODZuKu2+muKeVHXgw2/A6wM7OwrinxZiyBk5Hh
19 + CvaADH7PZpU6z/zv5NU5HSvXiKtCzFuDu4/Zfi34RfHXeCUfHAb4KfNRXJwMsxUa
20 + +4ZpSAX2G6RnGU5meuXpU5/V+DQJp/e69XyyY6RXDoMywaEFlIlXBqjRRA2pAgMB
21 + AAGjZjBkMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgECMB0GA1Ud
22 + DgQWBBRDWUsraYuA4REzalfNVzjann3F6zAfBgNVHSMEGDAWgBRDWUsraYuA4REz
23 + alfNVzjann3F6zANBgkqhkiG9w0BAQ0FAAOCAgEAkQ+T9nqcSlAuW/90DeYmQOW1
24 + QhqOor5psBEGvxbNGV2hdLJY8h6QUq48BCevcMChg/L1CkznBNI40i3/6heDn3IS
25 + zVEwXKf34pPFCACWVMZxbQjkNRTiH8iRur9EsaNQ5oXCPJkhwg2+IFyoPAAYURoX
26 + VcI9SCDUa45clmYHJ/XYwV1icGVI8/9b2JUqklnOTa5tugwIUi5sTfipNcJXHhgz
27 + 6BKYDl0/UP0lLKbsUETXeTGDiDpxZYIgbcFrRDDkHC6BSvdWVEiH5b9mH2BON60z
28 + 0O0j8EEKTwi9jnafVtZQXP/D8yoVowdFDjXcKkOPF/1gIh9qrFR6GdoPVgB3SkLc
29 + 5ulBqZaCHm563jsvWb/kXJnlFxW+1bsO9BDD6DweBcGdNurgmH625wBXksSdD7y/
30 + fakk8DagjbjKShYlPEFOAqEcliwjF45eabL0t27MJV61O/jHzHL3dknXeE4BDa2j
31 + bA+JbyJeUMtU7KMsxvx82RmhqBEJJDBCJ3scVptvhDMRrtqDBW5JShxoAOcpFQGm
32 + iYWicn46nPDjgTU0bX1ZPpTpryXbvciVL5RkVBuyX2ntcOLDPlZWgxZCBp96x07F
33 + AnOzKgZk4RzZPNAxCXERVxajn/FLcOhglVAKo5H0ac+AitlQ0ip55D2/mf8o72tM
34 + fVQ6VpyjEXdiIXWUq/o=
35 + -----END CERTIFICATE-----
@@ -0,0 +1,136 @@
1 + #!/bin/bash
2 + # Makenotwork Deployment Script
3 + # Cross-compiles for x86_64 Linux on macOS, uploads everything, restarts services.
4 + # Run from the MNW directory.
5 + #
6 + # Usage:
7 + # ./deploy/deploy.sh # Full deploy (build + upload + config + restart)
8 + # ./deploy/deploy.sh --quick # Quick deploy (build + upload binary + restart app)
9 + # ./deploy/deploy.sh --config # Config only (upload Caddyfile, systemd, error pages, backup script)
10 + #
11 + # Prerequisites (one-time):
12 + # brew install zig
13 + # cargo install cargo-zigbuild
14 + # rustup target add x86_64-unknown-linux-gnu
15 +
16 + set -e
17 +
18 + # Configuration
19 + SERVER="root@100.120.174.96"
20 + REMOTE_DIR="/opt/makenotwork"
21 + BINARY_NAME="makenotwork"
22 + TARGET="x86_64-unknown-linux-gnu"
23 + DEPLOY_DIR="deploy"
24 +
25 + # Check we're in the right directory
26 + if [ ! -f "Cargo.toml" ]; then
27 + echo "Error: Run this script from the MNW directory"
28 + exit 1
29 + fi
30 +
31 + upload_config() {
32 + echo "[config] Uploading configuration files..."
33 + scp $DEPLOY_DIR/Caddyfile $SERVER:/etc/caddy/Caddyfile
34 + scp $DEPLOY_DIR/makenotwork.service $SERVER:/etc/systemd/system/makenotwork.service
35 + scp $DEPLOY_DIR/backup-db.sh $SERVER:$REMOTE_DIR/backup-db.sh
36 + ssh $SERVER "chmod +x $REMOTE_DIR/backup-db.sh"
37 +
38 + # Error pages
39 + ssh $SERVER "mkdir -p $REMOTE_DIR/error-pages"
40 + scp $DEPLOY_DIR/error-pages/*.html $SERVER:$REMOTE_DIR/error-pages/
41 +
42 + # Git SSH and security config files
43 + ssh $SERVER "mkdir -p $REMOTE_DIR/deploy"
44 + scp $DEPLOY_DIR/sshd-git.conf $DEPLOY_DIR/fail2ban-sshd.conf $DEPLOY_DIR/setup-firewall.sh $SERVER:$REMOTE_DIR/deploy/
45 + scp $DEPLOY_DIR/setup-git-ssh.sh $DEPLOY_DIR/setup-ssh-keys.sh $SERVER:$REMOTE_DIR/deploy/ 2>/dev/null || true
46 + ssh $SERVER "chmod +x $REMOTE_DIR/deploy/setup-firewall.sh $REMOTE_DIR/deploy/setup-git-ssh.sh $REMOTE_DIR/deploy/setup-ssh-keys.sh 2>/dev/null || true"
47 +
48 + # Minify CSS for production (restore source on exit)
49 + echo "[config] Minifying CSS..."
50 + cp static/style.css static/style.css.src
51 + restore_css() { [ -f static/style.css.src ] && mv static/style.css.src static/style.css; }
52 + trap restore_css EXIT
53 + npx --yes clean-css-cli -o static/style.css static/style.css.src
54 + echo "[config] CSS: $(wc -c < static/style.css.src | tr -d ' ')B -> $(wc -c < static/style.css | tr -d ' ')B"
55 +
56 + # Static assets (CSS, JS, fonts, images)
57 + echo "[config] Uploading static assets..."
58 + rsync -az --delete static/ $SERVER:$REMOTE_DIR/static/
59 +
60 + # Restore unminified CSS
61 + restore_css
62 + trap - EXIT
63 +
64 + # Documentation (public markdown files)
65 + echo "[config] Uploading documentation..."
66 + rsync -az --delete site-docs/public/ $SERVER:$REMOTE_DIR/docs/public/
67 +
68 + # Rustdoc (API reference for library crates)
69 + echo "[config] Generating rustdoc..."
70 + "$DEPLOY_DIR/generate-rustdoc.sh"
71 + echo "[config] Uploading rustdoc..."
72 + rsync -az --delete rustdoc-out/ $SERVER:$REMOTE_DIR/rustdoc/
73 +
74 + # Reload systemd and restart Caddy
75 + ssh $SERVER "systemctl daemon-reload && systemctl restart caddy"
76 + echo "[config] Done"
77 + }
78 +
79 + build_binary() {
80 + echo "[build] Cross-compiling for $TARGET..."
81 + ulimit -n 65536 2>/dev/null || true
82 + cargo zigbuild --release --target $TARGET
83 + echo "[build] Done: target/$TARGET/release/$BINARY_NAME"
84 + }
85 +
86 + upload_binary() {
87 + echo "[upload] Stopping service and uploading binary..."
88 + ssh $SERVER "systemctl stop makenotwork || true"
89 + scp target/$TARGET/release/$BINARY_NAME $SERVER:$REMOTE_DIR/$BINARY_NAME
90 + ssh $SERVER "chmod +x $REMOTE_DIR/$BINARY_NAME"
91 + # Also upload mnw-admin binary (used for SSH key management)
92 + if [ -f "target/$TARGET/release/mnw-admin" ]; then
93 + scp target/$TARGET/release/mnw-admin $SERVER:$REMOTE_DIR/mnw-admin
94 + ssh $SERVER "chmod +x $REMOTE_DIR/mnw-admin"
95 + echo "[upload] mnw-admin binary uploaded"
96 + fi
97 + echo "[upload] Done"
98 + }
99 +
100 + restart_app() {
101 + echo "[restart] Restarting makenotwork..."
102 + ssh $SERVER "systemctl restart makenotwork"
103 + sleep 1
104 + echo ""
105 + ssh $SERVER "systemctl status makenotwork --no-pager"
106 + echo ""
107 + echo "[restart] Verifying app responds..."
108 + ssh $SERVER "curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1:3000"
109 + }
110 +
111 + case "${1:-full}" in
112 + --quick)
113 + echo "=== Quick Deploy ==="
114 + build_binary
115 + upload_binary
116 + restart_app
117 + ;;
118 + --config)
119 + echo "=== Config Deploy ==="
120 + upload_config
121 + ;;
122 + full|"")
123 + echo "=== Full Deploy ==="
124 + build_binary
125 + upload_config
126 + upload_binary
127 + restart_app
128 + ;;
129 + *)
130 + echo "Usage: $0 [--quick|--config]"
131 + exit 1
132 + ;;
133 + esac
134 +
135 + echo ""
136 + echo "=== Deploy Complete ==="
@@ -0,0 +1,75 @@
1 + <!DOCTYPE html>
2 + <html lang="en">
3 + <head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Page Not Found - makenot.work</title>
7 + <link rel="preconnect" href="https://fonts.googleapis.com">
8 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9 + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=Lato&family=Young+Serif&display=swap" rel="stylesheet">
10 + <style>
11 + * { margin: 0; padding: 0; box-sizing: border-box; }
12 + body {
13 + min-height: 100vh;
14 + display: flex;
15 + flex-direction: column;
16 + align-items: center;
17 + justify-content: center;
18 + padding: 2rem;
19 + background: #ede8e1;
20 + color: #3d3530;
21 + font-family: Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
22 + }
23 + .wordmark {
24 + position: absolute;
25 + top: 2rem;
26 + left: 2rem;
27 + font-family: "Young Serif", Georgia, "Times New Roman", serif;
28 + font-size: 1.25rem;
29 + color: #3d3530;
30 + text-decoration: none;
31 + }
32 + .wordmark .dot { color: #6c5ce7; }
33 + .container { text-align: center; max-width: 500px; }
34 + .code {
35 + font-size: 8rem;
36 + font-weight: 400;
37 + line-height: 1;
38 + margin-bottom: 1rem;
39 + font-family: "Young Serif", Georgia, "Times New Roman", serif;
40 + color: #3d3530;
41 + }
42 + .title {
43 + font-size: 1.25rem;
44 + font-family: "IBM Plex Mono", "Courier New", Consolas, monospace;
45 + color: #8a8480;
46 + margin-bottom: 1.5rem;
47 + }
48 + .message {
49 + color: #8a8480;
50 + margin-bottom: 2rem;
51 + line-height: 1.6;
52 + }
53 + a.btn {
54 + display: inline-block;
55 + padding: 0.75rem 1.5rem;
56 + background: #3d3530;
57 + color: #ede8e1;
58 + text-decoration: none;
59 + border-radius: 6px;
60 + font-weight: 500;
61 + transition: opacity 0.2s;
62 + }
63 + a.btn:hover { opacity: 0.85; }
64 + </style>
65 + </head>
66 + <body>
67 + <a href="/" class="wordmark">Makenot<span class="dot">.</span>work</a>
68 + <div class="container">
69 + <div class="code">404</div>
70 + <div class="title">Page not found</div>
71 + <p class="message">The page you're looking for doesn't exist or has been moved.</p>
72 + <a href="/" class="btn">Go Home</a>
73 + </div>
74 + </body>
75 + </html>
@@ -0,0 +1,83 @@
1 + <!DOCTYPE html>
2 + <html lang="en">
3 + <head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Something Went Wrong - makenot.work</title>
7 + <meta http-equiv="refresh" content="15">
8 + <link rel="preconnect" href="https://fonts.googleapis.com">
9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10 + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=Lato&family=Young+Serif&display=swap" rel="stylesheet">
11 + <style>
12 + * { margin: 0; padding: 0; box-sizing: border-box; }
13 + body {
14 + min-height: 100vh;
15 + display: flex;
16 + flex-direction: column;
17 + align-items: center;
18 + justify-content: center;
19 + padding: 2rem;
20 + background: #ede8e1;
21 + color: #3d3530;
22 + font-family: Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23 + }
24 + .wordmark {
25 + position: absolute;
26 + top: 2rem;
27 + left: 2rem;
28 + font-family: "Young Serif", Georgia, "Times New Roman", serif;
29 + font-size: 1.25rem;
30 + color: #3d3530;
31 + text-decoration: none;
32 + }
33 + .wordmark .dot { color: #6c5ce7; }
34 + .container { text-align: center; max-width: 500px; }
35 + .code {
36 + font-size: 8rem;
37 + font-weight: 400;
38 + line-height: 1;
39 + margin-bottom: 1rem;
40 + font-family: "Young Serif", Georgia, "Times New Roman", serif;
41 + color: #3d3530;
42 + }
43 + .title {
44 + font-size: 1.25rem;
45 + font-family: "IBM Plex Mono", "Courier New", Consolas, monospace;
46 + color: #8a8480;
47 + margin-bottom: 1.5rem;
48 + }
49 + .message {
50 + color: #8a8480;
51 + margin-bottom: 2rem;
52 + line-height: 1.6;
53 + }
54 + .retry {
55 + font-family: "IBM Plex Mono", "Courier New", Consolas, monospace;
56 + font-size: 0.85rem;
57 + color: #8a8480;
58 + }
59 + a.btn {
60 + display: inline-block;
61 + padding: 0.75rem 1.5rem;
62 + background: #3d3530;
63 + color: #ede8e1;
64 + text-decoration: none;
65 + border-radius: 6px;
66 + font-weight: 500;
67 + transition: opacity 0.2s;
68 + margin-bottom: 1.5rem;
69 + }
70 + a.btn:hover { opacity: 0.85; }
71 + </style>
72 + </head>
73 + <body>
74 + <a href="/" class="wordmark">Makenot<span class="dot">.</span>work</a>
75 + <div class="container">
76 + <div class="code">500</div>
77 + <div class="title">Something went wrong</div>
78 + <p class="message">An unexpected error occurred. This has been noted and will be looked into.</p>
79 + <a href="/" class="btn">Go Home</a>
80 + <p class="retry">This page will retry automatically.</p>
81 + </div>
82 + </body>
83 + </html>
@@ -0,0 +1,83 @@
1 + <!DOCTYPE html>
2 + <html lang="en">
3 + <head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>Temporarily Unavailable - makenot.work</title>
7 + <meta http-equiv="refresh" content="10">
8 + <link rel="preconnect" href="https://fonts.googleapis.com">
9 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10 + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&family=Lato&family=Young+Serif&display=swap" rel="stylesheet">
11 + <style>
12 + * { margin: 0; padding: 0; box-sizing: border-box; }
13 + body {
14 + min-height: 100vh;
15 + display: flex;
16 + flex-direction: column;
17 + align-items: center;
18 + justify-content: center;
19 + padding: 2rem;
20 + background: #ede8e1;
21 + color: #3d3530;
22 + font-family: Lato, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23 + }
24 + .wordmark {
25 + position: absolute;
26 + top: 2rem;
27 + left: 2rem;
28 + font-family: "Young Serif", Georgia, "Times New Roman", serif;
29 + font-size: 1.25rem;
30 + color: #3d3530;
31 + text-decoration: none;
32 + }
33 + .wordmark .dot { color: #6c5ce7; }
34 + .container { text-align: center; max-width: 500px; }
35 + .code {
36 + font-size: 8rem;
37 + font-weight: 400;
38 + line-height: 1;
39 + margin-bottom: 1rem;
40 + font-family: "Young Serif", Georgia, "Times New Roman", serif;
41 + color: #3d3530;
42 + }
43 + .title {
44 + font-size: 1.25rem;
45 + font-family: "IBM Plex Mono", "Courier New", Consolas, monospace;
46 + color: #8a8480;
47 + margin-bottom: 1.5rem;
48 + }
49 + .message {
50 + color: #8a8480;
51 + margin-bottom: 2rem;
52 + line-height: 1.6;
53 + }
54 + .retry {
55 + font-family: "IBM Plex Mono", "Courier New", Consolas, monospace;
56 + font-size: 0.85rem;
57 + color: #8a8480;
58 + }
59 + a.btn {
60 + display: inline-block;
61 + padding: 0.75rem 1.5rem;
62 + background: #3d3530;
63 + color: #ede8e1;
64 + text-decoration: none;
65 + border-radius: 6px;
66 + font-weight: 500;
67 + transition: opacity 0.2s;
68 + margin-bottom: 1.5rem;
69 + }
70 + a.btn:hover { opacity: 0.85; }
71 + </style>
72 + </head>
73 + <body>
74 + <a href="/" class="wordmark">Makenot<span class="dot">.</span>work</a>
75 + <div class="container">
76 + <div class="code">502</div>
77 + <div class="title">Temporarily unavailable</div>
78 + <p class="message">makenot.work is briefly offline for maintenance. Please wait a moment.</p>
79 + <a href="/" class="btn">Try Again</a>
80 + <p class="retry">This page will retry automatically.</p>
81 + </div>
82 + </body>
83 + </html>
@@ -0,0 +1,10 @@
1 + # fail2ban jail for SSH brute force protection
2 + # Drop-in for /etc/fail2ban/jail.d/
3 + [sshd]
4 + enabled = true
5 + port = ssh
6 + filter = sshd
7 + backend = systemd
8 + maxretry = 5
9 + findtime = 600
10 + bantime = 3600
@@ -0,0 +1,40 @@
1 + #!/bin/bash
2 + # Generate rustdoc for library crates (synckit-client, docengine, tagtree).
3 + # Output goes to rustdoc-out/ (relative to MNW/).
4 + # Run from the MNW directory.
5 +
6 + set -euo pipefail
7 +
8 + if [ ! -f "Cargo.toml" ]; then
9 + echo "Error: Run this script from the MNW directory"
10 + exit 1
11 + fi
12 +
13 + OUT_DIR="$(pwd)/rustdoc-out"
14 + SHARED_DIR="$(cd ../Shared && pwd)"
15 +
16 + CRATES=("synckit-client" "docengine" "tagtree")
17 +
18 + rm -rf "$OUT_DIR"
19 + mkdir -p "$OUT_DIR"
20 +
21 + for crate in "${CRATES[@]}"; do
22 + crate_dir="$SHARED_DIR/$crate"
23 + if [ ! -d "$crate_dir" ]; then
24 + echo "Warning: $crate_dir not found, skipping"
25 + continue
26 + fi
27 +
28 + echo "Generating docs for $crate..."
29 + (cd "$crate_dir" && cargo doc --no-deps --target-dir "$OUT_DIR/.target" 2>&1 | tail -1)
30 + done
31 +
32 + # Move generated docs from target/doc/ to output root
33 + if [ -d "$OUT_DIR/.target/doc" ]; then
34 + cp -r "$OUT_DIR/.target/doc/"* "$OUT_DIR/"
35 + rm -rf "$OUT_DIR/.target"
36 + fi
37 +
38 + echo ""
39 + echo "Rustdoc generated in $OUT_DIR/"
40 + ls -1 "$OUT_DIR/" | head -20
@@ -0,0 +1,413 @@
1 + # Makenotwork — Pre-Launch Manual Testing
2 +
3 + ## How to Test
4 +
5 + - Automated tests cover units and integration (1,060+ passing) but can't catch visual bugs, broken flows, or UX issues
6 + - Work through each section sequentially, checking boxes as you go
7 + - If something fails, note the issue inline and keep going — don't block the whole run
8 + - Prioritized: P0 first (launch-blocking), then P1 (core features), then P2 (edge cases)
9 +
10 + ### Environment Setup
11 +
12 + - [ ] PostgreSQL running locally with migrations applied (`cargo sqlx migrate run`)
13 + - [ ] Server running (`cargo run` or release binary)
14 + - [ ] `.env` has Stripe **test** keys (sk_test_*, not sk_live_*)
15 + - [ ] `.env` has SIGNING_SECRET set
16 + - [ ] S3 credentials configured (Hetzner Object Storage or compatible)
17 + - [ ] Postmark token set, or accept console-logged emails for dev
18 + - [ ] ADMIN_USER_ID set to your user's UUID
19 +
20 + ### Tips
21 +
22 + - Open browser devtools Network tab — HTMX requests show as XHR, check for 422/500s
23 + - Run server in a second terminal so you can watch logs in real time
24 + - Use incognito/private window when testing auth flows to avoid session bleed
25 + - Stripe test card: `4242 4242 4242 4242`, any future expiry, any CVC
26 +
27 + ---
28 +
29 + ## P0 — Critical Path
30 +
31 + > If any of these fail, do not launch.
32 +
33 + ### Signup → Verify → Login → Logout
34 +
35 + - [ ] `GET /join` — signup form renders
36 + - [ ] Submit signup with valid username, email, password (8+ chars)
37 + - [ ] Server logs verification email (or Postmark sends it)
38 + - [ ] Verification link in email works (`/verify-email?user=...&expires=...&sig=...`)
39 + - [ ] After verification, email_verified flag is true (check `/dashboard` details tab)
40 + - [ ] `GET /login` — login form renders
41 + - [ ] Login with correct credentials — redirects to `/dashboard`
42 + - [ ] `POST /logout` — session destroyed, redirects to `/`
43 + - [ ] Accessing `/dashboard` after logout redirects to `/login`
44 + - [ ] Login with wrong password — shows error, does not reveal whether user exists
45 + - [ ] Resend verification email works (`/api/resend-verification`)
46 +
47 + ### Account Lockout + Recovery
48 +
49 + - [ ] Fail login 5 times — account locks for 15 minutes
50 + - [ ] Lockout notification email sent with one-time login link
51 + - [ ] One-time login link works (logs you in)
52 + - [ ] One-time login link cannot be reused (single-use)
53 + - [ ] After lockout expires, normal login works again
54 +
55 + ### Password Reset
56 +
57 + - [ ] `GET /forgot-password` — form renders
58 + - [ ] Submit email — reset email sent (15-minute expiry link)
59 + - [ ] Reset link loads form (`/reset-password?user=...&expires=...&sig=...`)
60 + - [ ] Submit new password — succeeds, can login with new password
61 + - [ ] Old password no longer works
62 + - [ ] Expired reset link rejected
63 + - [ ] Reusing same reset link after password change rejected (HMAC includes password hash)
64 +
65 + ### Creator Onboarding
66 +
67 + - [ ] As a regular user, `/dashboard` creator tab shows waitlist apply form
68 + - [ ] Submit waitlist application with pitch text
69 + - [ ] As admin, `GET /admin/waitlist` — shows pending entries
70 + - [ ] Approve entry via `POST /api/admin/waitlist/{id}/approve`
71 + - [ ] Approved user now has can_create_projects flag
72 + - [ ] Approved user sees Stripe Connect setup in dashboard
73 + - [ ] `GET /stripe/connect` — disclaimer page renders
74 + - [ ] `POST /stripe/connect/proceed` — redirects to Stripe OAuth (use test mode)
75 + - [ ] Stripe callback (`/stripe/callback`) saves account ID
76 + - [ ] Dashboard now shows connected Stripe status
77 +
78 + ### Content Creation + Publishing
79 +
80 + - [ ] Create project (`POST /api/projects`) — appears in dashboard
81 + - [ ] Project page renders at `/p/{slug}`
82 + - [ ] Create text item — set title, price (free), description
83 + - [ ] Edit text body (`PUT /api/items/{id}/text`) — markdown renders correctly
84 + - [ ] Create audio item — presign upload, upload file to S3, confirm
85 + - [ ] Audio player works on item page (`/i/{item_id}`)
86 + - [ ] Create download item — presign version upload, upload, confirm
87 + - [ ] Download link works for authorized users
88 + - [ ] Set item visibility to public — appears on `/discover`
89 + - [ ] Set item visibility to private — disappears from `/discover`
90 + - [ ] Create paid item (set price > 0)
91 +
92 + ### Purchase Flow (Fixed Price)
93 +
94 + - [ ] As buyer (different account), browse `/discover` — find the paid item
95 + - [ ] `GET /purchase/{item_id}` — purchase page shows price and fee breakdown
96 + - [ ] `POST /stripe/checkout/{item_id}` — redirects to Stripe Checkout
97 + - [ ] Complete payment with test card (`4242 4242 4242 4242`)
98 + - [ ] `/stripe/success` — success page renders
99 + - [ ] Webhook fires (`checkout.session.completed`) — transaction recorded
100 + - [ ] Item appears in buyer's `/library`
101 + - [ ] Buyer can access item content (stream audio, read text, download file)
102 + - [ ] Cancel checkout — `/stripe/cancel` renders, no transaction created
103 +
104 + ### Pay-What-You-Want (PWYW) Purchase
105 +
106 + - [ ] Create PWYW item with $0 minimum — save succeeds
107 + - [ ] Purchase page shows PWYW input with suggested prices
108 + - [ ] Complete purchase at $0 — item added to library, no Stripe checkout
109 + - [ ] Complete purchase at custom amount (e.g. $5) — Stripe Checkout, item in library
110 + - [ ] Create PWYW item with non-zero minimum (e.g. $5)
111 + - [ ] Attempt purchase below minimum — rejected
112 +
113 + ### Subscription Flow
114 +
115 + - [ ] Create subscription tier on a project (e.g. $3/mo)
116 + - [ ] As buyer, subscription page renders with tier details
117 + - [ ] `POST /stripe/subscribe/{project_id}` — redirects to Stripe Checkout (subscription mode)
118 + - [ ] Complete subscription with test card
119 + - [ ] Webhook fires (`customer.subscription.created`) — subscription recorded
120 + - [ ] Subscriber can access subscriber-only items
121 + - [ ] Non-subscriber cannot access subscriber-only content
122 + - [ ] Cancel subscription — access continues until end of billing period
123 +
124 + ### Discount Codes
125 +
126 + - [ ] Create discount code (e.g. LAUNCH50, 50% off, limited uses)
127 + - [ ] Apply code at checkout — price reduced correctly
128 + - [ ] Discount shows in fee breakdown
129 + - [ ] Exhausted code rejected (after max uses reached)
130 + - [ ] Expired code rejected
131 +
132 + ### License Keys
133 +
134 + - [ ] Create item with license keys enabled
135 + - [ ] After purchase, license key displayed to buyer
136 + - [ ] `POST /api/licenses/{key}/activate` — activation succeeds
137 + - [ ] Activation count increments
138 + - [ ] `GET /api/licenses/{key}/verify` — returns valid status
139 + - [ ] Exceed activation limit — activation rejected
140 +
141 + ### Free Item Claim
142 +
143 + - [ ] As buyer, find a free item on `/discover`
144 + - [ ] `POST /api/library/add/{item_id}` — item added to library
145 + - [ ] Item content accessible
146 + - [ ] `DELETE /api/library/remove/{item_id}` — item removed from library
147 +
148 + ### File Upload + Delivery
149 +
150 + - [ ] Presign request (`POST /api/upload/presign`) returns valid S3 URL
151 + - [ ] Direct upload to presigned URL succeeds
152 + - [ ] Confirm upload (`POST /api/upload/confirm`) stores S3 key
153 + - [ ] Audio streaming URL (`GET /api/stream/{item_id}`) returns presigned URL
154 + - [ ] Version file download (`GET /api/versions/{version_id}/download`) works
155 + - [ ] Cover image upload and display works
156 + - [ ] Presigned URLs expire (check after 1+ hours)
157 +
158 + ---
159 +
160 + ## P1 — Core Features
161 +
162 + ### Dashboard
163 +
164 + - [ ] `/dashboard` renders with projects list
165 + - [ ] Details tab (`/dashboard/tabs/details`) — shows username, email, bio
166 + - [ ] Payments tab (`/dashboard/tabs/payments`) — shows transaction history
167 + - [ ] Projects tab (`/dashboard/tabs/projects`) — lists all projects
168 + - [ ] Creator tab (`/dashboard/tabs/creator`) — shows waitlist or Stripe status
169 + - [ ] Profile update (`PUT /api/users/me`) — display name and bio save correctly
170 + - [ ] Password update (`PUT /api/users/me/password`) — works with correct current password
171 +
172 + ### Project Management
173 +
174 + - [ ] Project dashboard (`/dashboard/project/{slug}`) renders
175 + - [ ] Overview tab — project stats display
176 + - [ ] Content tab — items listed
177 + - [ ] Analytics tab — renders (even if empty)
178 + - [ ] Settings tab — project settings editable
179 + - [ ] Update project (`PUT /api/projects/{id}`) — title, description, type, visibility
180 + - [ ] Delete project (`DELETE /api/projects/{id}`) — cascade deletes items
181 +
182 + ### Item Management
183 +
184 + - [ ] Item dashboard (`/dashboard/item/{id}`) renders
185 + - [ ] Inline edit row (`/dashboard/item/{id}/edit-row`) works via HTMX
186 + - [ ] Update item metadata (`PUT /api/items/{id}`) — title, price, type, description
187 + - [ ] Version list (`GET /api/items/{id}/versions`) renders
188 + - [ ] Create new version (`POST /api/items/{id}/versions`) with file upload
189 +
190 + ### Discover
191 +
192 + - [ ] `/discover` renders with default results
193 + - [ ] Search by text — results filter correctly (trigram search)
194 + - [ ] Filter by category — correct items shown, category counts update
195 + - [ ] Filter by price range (Free, <$25, $25-50, $50-100, $100+)
196 + - [ ] Switch between Items and Projects mode
197 + - [ ] Sort options work (newest, oldest, price, sales)
198 + - [ ] Pagination — next/prev pages load via HTMX
199 + - [ ] `/discover/results` partial loads correctly (check Network tab)
200 +
201 + ### Public Profiles
202 +
203 + - [ ] `/u/{username}` — user profile renders with projects and custom links
204 + - [ ] `/p/{slug}` — project page renders with items
205 + - [ ] `/i/{item_id}` — text item renders markdown correctly
206 + - [ ] `/i/{item_id}` — audio item shows player with chapters
207 + - [ ] `/i/{item_id}` — download item shows version list
208 +
209 + ### Custom Links
210 +
211 + - [ ] Create link (`POST /api/links`) — appears on profile
212 + - [ ] Update link (`PUT /api/links/{id}`) — changes reflected
213 + - [ ] Delete link (`DELETE /api/links/{id}`) — removed from profile
214 + - [ ] Reorder links (`PUT /api/links/reorder`) — order persists
215 +
216 + ### Tags + Chapters
217 +
218 + - [ ] Add tag to item (`POST /api/items/{id}/tags`) — tag appears
219 + - [ ] Remove tag (`DELETE /api/items/{id}/tags/{tag}`) — tag removed
220 + - [ ] Create chapter (`POST /api/items/{id}/chapters`) — chapter marker appears
221 + - [ ] Update chapter (`PUT /api/chapters/{id}`) — changes saved
222 + - [ ] Delete chapter (`DELETE /api/chapters/{id}`) — removed
223 + - [ ] Chapters display on audio item page with correct timestamps
224 +
225 + ### RSS Feeds
226 +
227 + - [ ] `/u/{username}/rss` — valid RSS 2.0, includes public items
228 + - [ ] `/p/{slug}/rss` — valid RSS 2.0, includes project's public items
229 + - [ ] Feed updates when new item published
230 +
231 + ### Blog Posts
232 +
233 + - [ ] Create blog post on a project — title, slug, body (markdown)
234 + - [ ] Blog post renders at `/p/{slug}/blog/{post_slug}`
235 + - [ ] Blog post appears in project RSS feed
236 + - [ ] Edit blog post — changes saved and visible
237 + - [ ] Delete blog post — removed from project page and RSS
238 +
239 + ### Two-Factor Authentication
240 +
241 + - [ ] Enable TOTP 2FA — QR code and secret displayed
242 + - [ ] Login with 2FA enabled — prompted for TOTP code after password
243 + - [ ] Correct TOTP code — login succeeds
244 + - [ ] Wrong TOTP code — login rejected
245 + - [ ] Backup codes — one works, same code cannot be reused
246 + - [ ] Disable 2FA — login no longer prompts for code
247 +
248 + ### Passkeys (WebAuthn)
249 +
250 + - [ ] Register passkey from dashboard security section
251 + - [ ] Login with passkey — bypasses password
252 + - [ ] Remove passkey — can no longer use it to login
253 +
254 + ### Git Browser
255 +
256 + - [ ] `/git/{username}/{repo}` — file tree renders
257 + - [ ] Click file — blob view with syntax highlighting
258 + - [ ] `/git/{username}/{repo}/commits` — commit log renders
259 + - [ ] Click commit — diff view renders
260 + - [ ] `/git/{username}/{repo}/blame/{path}` — blame view renders
261 + - [ ] Clone URL displayed and correct (`ssh.makenot.work`)
262 +
263 + ### Data Export
264 +
265 + - [ ] Projects export (`POST /api/export/projects`) — downloads JSON
266 + - [ ] Sales export (`POST /api/export/sales`) — downloads CSV
267 + - [ ] Purchases export (`POST /api/export/purchases`) — downloads CSV
268 + - [ ] Exported data is accurate (spot-check a few records)
269 +
270 + ---
271 +
272 + ## P2 — Edge Cases + Security
273 +
274 + ### Access Control
275 +
276 + - [ ] Cannot view another user's dashboard (`/dashboard` only shows your data)
277 + - [ ] Cannot edit another user's project (`PUT /api/projects/{id}` — 403/404)
278 + - [ ] Cannot delete another user's item (`DELETE /api/items/{id}` — 403/404)
279 + - [ ] Cannot access paid item content without purchase
280 + - [ ] Cannot access private/draft items via direct URL
281 + - [ ] Admin routes (`/admin/*`) return 403 for non-admin users
282 + - [ ] Stripe disconnect (`DELETE /api/users/me/stripe`) only affects your account
283 +
284 + ### Rate Limiting
285 +
286 + - [ ] Hit `/login` rapidly (>5 times) — returns 429 Too Many Requests
287 + - [ ] Hit `/api/upload/presign` rapidly (>10 times) — returns 429
288 + - [ ] Hit `/api/export/projects` rapidly (>3 times) — returns 429
289 + - [ ] Rate limits reset after the window passes
290 +
291 + ### CSRF
292 +
293 + - [ ] Submit a POST/PUT/DELETE without CSRF token — rejected
294 + - [ ] Submit with invalid CSRF token — rejected
295 + - [ ] Normal form submissions with valid token — succeed
296 + - [ ] Exempt routes work without CSRF: `/login`, `/join`, `/logout`, `/stripe/webhook`
297 +
298 + ### Input Validation
299 +
300 + - [ ] XSS attempt in username/bio/project fields — HTML escaped in output
301 + - [ ] SQL injection attempt in search/form fields — no errors, input treated as text
302 + - [ ] Overlong input (10k+ chars in text fields) — rejected or truncated gracefully
303 + - [ ] Negative price on item — rejected
304 + - [ ] Zero-length required fields — rejected with validation error
305 + - [ ] Markdown rendering sanitized (no script tags, no raw HTML that could execute)
306 +
307 + ### Account Deletion
308 +
309 + - [ ] Request deletion (`POST /api/account/request-deletion`) — confirmation email sent
310 + - [ ] Confirmation link (`/confirm-delete?user=...&expires=...&sig=...`) — deletes account
311 + - [ ] After deletion, login with old credentials fails
312 + - [ ] Deleted user's public pages return 404
313 + - [ ] Purchases by deleted user are preserved (preserve_purchases migration)
314 +
315 + ### Error Pages
316 +
317 + - [ ] Hit nonexistent route — custom 404 page renders
318 + - [ ] Error templates render correctly (check `/deploy/error-pages/`)
319 +
320 + ---
321 +
322 + ## Infrastructure Verification
323 +
324 + > Run these checks on the production server after deploy.
325 +
326 + ### DNS + HTTPS
327 +
328 + - [ ] A record points to server IP (`dig makenot.work`)
329 + - [ ] HTTPS certificate valid (Cloudflare Origin CA, 15yr wildcard)
330 + - [ ] `Strict-Transport-Security` header present
331 + - [ ] `http://makenot.work` redirects to `https://makenot.work`
332 + - [ ] `www.makenot.work` redirects to `makenot.work` (if configured)
333 +
334 + ### Security Headers
335 +
336 + - [ ] `Content-Security-Policy` header present
337 + - [ ] `X-Frame-Options: DENY` or `SAMEORIGIN`
338 + - [ ] `X-Content-Type-Options: nosniff`
339 + - [ ] `Referrer-Policy` header present
340 + - [ ] `Permissions-Policy` header present
341 + - [ ] Check headers: `curl -I https://makenot.work`
342 +
343 + ### Systemd Service
344 +
345 + - [ ] Service running: `systemctl status makenotwork`
346 + - [ ] Restart policy active: `Restart=on-failure` in service file
347 + - [ ] Service starts on boot: `systemctl is-enabled makenotwork`
348 + - [ ] Security hardening active (check `ProtectSystem`, `NoNewPrivileges`, etc. in service file)
349 + - [ ] Test restart: `systemctl restart makenotwork` — comes back healthy
350 +
351 + ### Database
352 +
353 + - [ ] Migrations applied: all 45 migrations (`cargo sqlx migrate info` or check schema)
354 + - [ ] Connection healthy: `GET /health` shows database green
355 + - [ ] Demo seed data removed (migrations 011-014, 016-017 are seed data — verify no test users/items in production)
356 + - [ ] pg_trgm extension installed (required for search)
357 +
358 + ### Backups
359 +
360 + - [ ] Cron job configured: `crontab -l` shows daily 3 AM backup
361 + - [ ] Manual backup works: `bash deploy/backup-db.sh`
362 + - [ ] Backup file created and non-empty in backup directory
363 + - [ ] Test restore to a scratch database (see `RECOVERY.md`)
364 + - [ ] 30-day retention — old backups cleaned up
365 +
366 + ### Environment
367 +
368 + - [ ] `.env` file permissions: `600` (owner read/write only)
369 + - [ ] No test keys in production (grep for `sk_test_`, `pk_test_`)
370 + - [ ] `SIGNING_SECRET` is set and is a strong random value
371 + - [ ] `HOST_URL` is `https://makenot.work` (not localhost)
372 + - [ ] `STRIPE_WEBHOOK_SECRET` matches the webhook configured in Stripe dashboard
373 + - [ ] `POSTMARK_TOKEN` is set (not console mode)
374 + - [ ] `ADMIN_USER_ID` is set to the correct UUID
375 +
376 + ### Health Endpoint
377 +
378 + - [ ] `GET /health` returns 200
379 + - [ ] Database: connected, shows table counts
380 + - [ ] Sessions: store active
381 + - [ ] S3: configured
382 + - [ ] Stripe: configured, **live mode** (not test)
383 + - [ ] Email: Postmark (not console)
384 +
385 + ### Logs
386 +
387 + - [ ] Server logs flowing: `journalctl -u makenotwork -f`
388 + - [ ] Caddy logs flowing: `journalctl -u caddy -f`
389 + - [ ] No errors or panics on startup
390 + - [ ] A test request shows up in logs
391 +
392 + ### Firewall
393 +
394 + - [ ] Ports 80, 443 open to all (required for custom domains + on-demand TLS): `ufw status`
395 + - [ ] Port 22 open (SSH)
396 + - [ ] All other ports blocked
397 + - [ ] makenot.work protected by Cloudflare mTLS even with open ports
398 +
399 + ---
400 +
401 + ## Sign-Off
402 +
403 + | Field | Value |
404 + |-------|-------|
405 + | Date | |
406 + | Tester | |
407 + | Environment | local / staging / production |
408 + | Automated tests passing | yes / no |
409 + | P0 result | pass / fail |
410 + | P1 result | pass / fail |
411 + | P2 result | pass / fail / skipped |
412 + | Infrastructure result | pass / fail / N/A |
413 + | Notes | |
@@ -0,0 +1,56 @@
1 + # Makenotwork systemd service
2 + # Place in /etc/systemd/system/makenotwork.service
3 + #
4 + # Commands:
5 + # sudo systemctl daemon-reload
6 + # sudo systemctl enable makenotwork
7 + # sudo systemctl start makenotwork
8 + # sudo systemctl status makenotwork
9 + # journalctl -u makenotwork -f
10 +
11 + [Unit]
12 + Description=Makenotwork - Fair creator platform
13 + Documentation=https://makenot.work/docs
14 + After=network.target postgresql.service
15 + Requires=postgresql.service
16 +
17 + [Service]
18 + Type=simple
19 + User=makenotwork
20 + Group=makenotwork
21 + WorkingDirectory=/opt/makenotwork
22 + ExecStart=/opt/makenotwork/makenotwork
23 + Restart=always
24 + RestartSec=5
25 +
26 + # Environment file with secrets
27 + EnvironmentFile=/opt/makenotwork/.env
28 + Environment=HOME=/opt/makenotwork
29 +
30 + # Security hardening
31 + NoNewPrivileges=true
32 + ProtectSystem=strict
33 + ProtectHome=true
34 + PrivateTmp=true
35 + ReadWritePaths=/opt/makenotwork /opt/git
36 + RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
37 + RestrictNamespaces=true
38 + RestrictRealtime=true
39 + RestrictSUIDSGID=true
40 + LockPersonality=true
41 + ProtectKernelTunables=true
42 + ProtectKernelModules=true
43 + ProtectControlGroups=true
44 + SystemCallArchitectures=native
45 +
46 + # Resource limits
47 + LimitNOFILE=65535
48 + MemoryMax=512M
49 +
50 + # Logging (goes to journald)
51 + StandardOutput=journal
52 + StandardError=journal
53 + SyslogIdentifier=makenotwork
54 +
55 + [Install]
56 + WantedBy=multi-user.target
A docs/audit.md +127
A docs/cli.md +617
A docs/todo.md +335
A src/auth.rs +486
A src/config.rs +435
A src/csrf.rs +296
A src/db/enums.rs +1217
A src/db/ota.rs +177
A src/error.rs +233
A src/git.rs +1176
A src/lib.rs +138
A src/main.rs +337
A src/payments.rs +1173
A src/rss.rs +236
A src/wordlist.rs +2056
A static/mnw.js +165