Skip to main content

max / makenotwork

Add git issues, labels, reporting; nav audit fixes; admin/OAuth/CSRF enhancements Git infrastructure: issues (CRUD, labels, search, close/reopen), project labels (create/edit/delete, assign to items), user reporting (submit/review/resolve). Migrations 027-030. Admin nav partial, reports and labels admin pages. Navigation audit: site-wide footer (pricing/creators/docs/policy links), project page blog link (conditional on published posts), dashboard export link, dead #privacy/#terms anchors fixed to /policy, landing footer removed. OAuth provider: /oauth/userinfo endpoint, relaxed redirect_uri validation. CSRF: refactored for testability. Validation: extensive input sanitization. Health: simplified endpoint. Deploy: firewall + fail2ban + git SSH scripts. Discover: tag filtering. Monitoring: health check improvements. Version bump to 0.2.6 for deploy. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-15 02:33 UTC
Commit: ae10c93402ad61d6e4fcd81ebb590a6153d2fe5d
Parent: 7d08887
92 files changed, +6622 insertions, -291 deletions
@@ -89,7 +89,7 @@ Everything listed here is live and working.
89 89 - **Health monitoring**: Real uptime tracking, database status, service connectivity checks
90 90 - **Malware scanning**: ClamAV + YARA rules + MalwareBazaar hash lookup on file uploads
91 91 - **Creator guide**: 12-page documentation covering the full UX surface area
92 - - **824 automated tests**: Unit, integration, workflow, and health tests
92 + - **840+ automated tests**: Unit, integration, workflow, and health tests
93 93
94 94 ### Developer Infrastructure (SyncKit)
95 95
@@ -0,0 +1,149 @@
1 + # License Key API Guide
2 +
3 + The License Key API lets software applications validate and manage license keys issued through Makenot.work. All endpoints are public (no authentication required) and rate-limited.
4 +
5 + ## Validate and Activate a Key
6 +
7 + Validates a license key and optionally activates it on a machine.
8 +
9 + ```
10 + POST https://makenot.work/api/keys/validate
11 + Content-Type: application/json
12 +
13 + {
14 + "key": "XXXX-XXXX-XXXX-XXXX",
15 + "machine_id": "unique-machine-identifier",
16 + "label": "Alice's MacBook Pro"
17 + }
18 + ```
19 +
20 + ### Parameters
21 +
22 + | Field | Required | Description |
23 + |-------|----------|-------------|
24 + | `key` | Yes | The license key string |
25 + | `machine_id` | Yes | A stable, unique identifier for this machine |
26 + | `label` | No | Human-readable name for this activation |
27 +
28 + ### Response
29 +
30 + ```json
31 + {
32 + "valid": true,
33 + "activated": true,
34 + "license": {
35 + "item_id": "550e8400-...",
36 + "max_activations": 3,
37 + "activation_count": 1,
38 + "created_at": "2026-03-13T10:00:00Z"
39 + }
40 + }
41 + ```
42 +
43 + ### Error Cases
44 +
45 + ```json
46 + {
47 + "valid": false,
48 + "error": "invalid_key"
49 + }
50 + ```
51 +
52 + | Error | Meaning |
53 + |-------|---------|
54 + | `invalid_key` | Key does not exist |
55 + | `key_revoked` | Key has been revoked by the creator |
56 + | `activation_limit_reached` | All activation slots are in use |
57 +
58 + ### Idempotent Activation
59 +
60 + If the same `key` + `machine_id` combination is submitted again, the existing activation is refreshed (timestamp updated) without consuming an additional slot.
61 +
62 + ## Check Key Status
63 +
64 + Check whether a key is valid without activating it.
65 +
66 + ```
67 + GET https://makenot.work/api/keys/{key_code}/status
68 + ```
69 +
70 + Response:
71 +
72 + ```json
73 + {
74 + "valid": true,
75 + "license": {
76 + "item_id": "550e8400-...",
77 + "max_activations": 3,
78 + "activation_count": 2,
79 + "remaining_activations": 1,
80 + "created_at": "2026-03-13T10:00:00Z"
81 + }
82 + }
83 + ```
84 +
85 + Use this for periodic license checks without affecting activation state.
86 +
87 + ## Deactivate a Key
88 +
89 + Release an activation slot (e.g., when the user uninstalls your software).
90 +
91 + ```
92 + POST https://makenot.work/api/keys/deactivate
93 + Content-Type: application/json
94 +
95 + {
96 + "key": "XXXX-XXXX-XXXX-XXXX",
97 + "machine_id": "unique-machine-identifier"
98 + }
99 + ```
100 +
101 + Response:
102 +
103 + ```json
104 + {
105 + "success": true,
106 + "message": "Activation removed"
107 + }
108 + ```
109 +
110 + ## Machine ID Guidelines
111 +
112 + The `machine_id` should be:
113 + - **Stable**: Same value across app restarts and updates
114 + - **Unique**: Different on each machine
115 + - **Not personally identifiable**: Avoid using MAC addresses or usernames
116 +
117 + Good approaches:
118 + - Generate a UUID on first launch and store it in the app's data directory
119 + - Use a hardware-derived hash (disk serial, motherboard ID)
120 + - Use the OS machine ID (e.g., `/etc/machine-id` on Linux)
121 +
122 + ## Rate Limits
123 +
124 + All license key endpoints: 200ms per request, burst 20.
125 +
126 + Exceeding the limit returns HTTP 429. Implement exponential backoff in your client.
127 +
128 + ## Integration Pattern
129 +
130 + Recommended flow for desktop applications:
131 +
132 + 1. **On first launch**: Prompt for license key, call `/api/keys/validate` with a generated machine ID
133 + 2. **On subsequent launches**: Call `/api/keys/{key}/status` to verify the key is still valid
134 + 3. **On uninstall**: Call `/api/keys/deactivate` to release the activation slot
135 + 4. **On activation failure**: Show the error message and allow the user to enter a different key
136 +
137 + ## Error Response Format
138 +
139 + ```json
140 + {
141 + "error": "descriptive message"
142 + }
143 + ```
144 +
145 + | Status | Meaning |
146 + |--------|---------|
147 + | 400 | Invalid request body |
148 + | 404 | Key not found (for status endpoint) |
149 + | 429 | Rate limit exceeded |
@@ -0,0 +1,141 @@
1 + # OAuth2 Integration Guide
2 +
3 + Makenot.work acts as an OAuth2 provider. Third-party applications can authenticate users with their MNW credentials using the Authorization Code flow with PKCE (RFC 7636).
4 +
5 + ## Flow Overview
6 +
7 + 1. Your app opens the MNW authorization URL in the user's browser
8 + 2. The user logs in and grants access
9 + 3. MNW redirects back to your app with an authorization code
10 + 4. Your app exchanges the code for an access token
11 + 5. Your app uses the token to fetch user info
12 +
13 + ## Step 1: Build the Authorization URL
14 +
15 + ```
16 + GET https://makenot.work/oauth/authorize
17 + ?response_type=code
18 + &client_id=your-app-id
19 + &redirect_uri=http://localhost:8765/callback
20 + &state=random-csrf-token
21 + &code_challenge=base64url-sha256-of-verifier
22 + &code_challenge_method=S256
23 + ```
24 +
25 + ### Parameters
26 +
27 + | Parameter | Required | Description |
28 + |-----------|----------|-------------|
29 + | `response_type` | Yes | Must be `code` |
30 + | `client_id` | Yes | Your app identifier |
31 + | `redirect_uri` | Yes | Callback URL (localhost or registered domain) |
32 + | `state` | Yes | Random string for CSRF protection |
33 + | `code_challenge` | Yes | SHA256 hash of code verifier, base64url-encoded |
34 + | `code_challenge_method` | No | Defaults to `S256` |
35 +
36 + ### PKCE Code Verifier
37 +
38 + Generate a random string (43-128 characters, unreserved URI characters):
39 +
40 + ```
41 + verifier = random_string(64)
42 + challenge = base64url(sha256(verifier))
43 + ```
44 +
45 + ### Redirect URI
46 +
47 + For desktop/mobile apps, use a localhost URL with a random port:
48 +
49 + ```
50 + http://localhost:{random_port}/callback
51 + ```
52 +
53 + Start a temporary HTTP server on that port to receive the callback.
54 +
55 + ## Step 2: Handle the Callback
56 +
57 + After the user authorizes, MNW redirects to your `redirect_uri`:
58 +
59 + ```
60 + http://localhost:8765/callback?code=auth-code-here&state=your-csrf-token
61 + ```
62 +
63 + Verify that `state` matches the value you sent. If it doesn't, abort (possible CSRF attack).
64 +
65 + ## Step 3: Exchange the Code
66 +
67 + ```
68 + POST https://makenot.work/oauth/token
69 + Content-Type: application/json
70 +
71 + {
72 + "grant_type": "authorization_code",
73 + "code": "auth-code-from-callback",
74 + "redirect_uri": "http://localhost:8765/callback",
75 + "code_verifier": "your-original-verifier",
76 + "client_id": "your-app-id"
77 + }
78 + ```
79 +
80 + Response:
81 +
82 + ```json
83 + {
84 + "access_token": "eyJhb...",
85 + "token_type": "Bearer",
86 + "expires_in": 3600,
87 + "user_id": "550e8400-e29b-41d4-a716-446655440000",
88 + "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
89 + }
90 + ```
91 +
92 + ## Step 4: Fetch User Info
93 +
94 + ```
95 + GET https://makenot.work/oauth/userinfo
96 + Authorization: Bearer {access_token}
97 + ```
98 +
99 + Response:
100 +
101 + ```json
102 + {
103 + "user_id": "550e8400-...",
104 + "username": "alice",
105 + "display_name": "Alice Smith",
106 + "avatar_url": "https://makenot.work/avatars/alice.jpg"
107 + }
108 + ```
109 +
110 + ## Token Lifecycle
111 +
112 + - Tokens are short-lived JWTs (check `expires_in` for exact duration)
113 + - No refresh tokens are issued — re-authenticate when the token expires
114 + - Store the token securely (OS keychain recommended, never in plain text files)
115 +
116 + ## Rate Limits
117 +
118 + All OAuth endpoints: 200ms per request, burst 20.
119 +
120 + Exceeding the limit returns HTTP 429.
121 +
122 + ## Error Responses
123 +
124 + ```json
125 + {
126 + "error": "descriptive message"
127 + }
128 + ```
129 +
130 + | Status | Meaning |
131 + |--------|---------|
132 + | 400 | Invalid request (missing parameters, bad code_verifier) |
133 + | 401 | Invalid credentials or expired code |
134 + | 429 | Rate limit exceeded |
135 +
136 + ## Security Notes
137 +
138 + - Always use PKCE. Plain authorization code flow is not supported.
139 + - Always verify the `state` parameter on callback.
140 + - Use HTTPS in production (localhost is allowed for development).
141 + - Tokens grant read access to user identity only. They do not grant access to the user's MNW content, projects, or payment information.
@@ -0,0 +1,342 @@
1 + # SyncKit API Integration Guide
2 +
3 + SyncKit provides end-to-end encrypted cloud sync for desktop and mobile applications. All data is encrypted client-side before leaving the device. The server stores only ciphertext.
4 +
5 + ## Prerequisites
6 +
7 + - A Makenot.work creator account
8 + - A SyncKit app registered in the creator dashboard (Dashboard > SyncKit tab)
9 + - Your app's API key (generated on registration)
10 +
11 + ## Authentication
12 +
13 + ### Email/Password Auth
14 +
15 + ```
16 + POST /api/sync/auth
17 + Content-Type: application/json
18 +
19 + {
20 + "email": "user@example.com",
21 + "password": "user-password",
22 + "api_key": "your-app-api-key"
23 + }
24 + ```
25 +
26 + Response:
27 +
28 + ```json
29 + {
30 + "token": "eyJhb...",
31 + "user_id": "550e8400-e29b-41d4-a716-446655440000",
32 + "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
33 + }
34 + ```
35 +
36 + The `token` is a short-lived JWT. Use it as a Bearer token for all subsequent requests.
37 +
38 + ### OAuth2 PKCE Auth
39 +
40 + For desktop/mobile apps, use the OAuth2 Authorization Code flow with PKCE:
41 +
42 + 1. Generate a code verifier (random 43-128 character string) and code challenge (SHA256 hash, base64url-encoded).
43 + 2. Open the authorization URL in the user's browser:
44 +
45 + ```
46 + GET /oauth/authorize?response_type=code
47 + &client_id=your-app-id
48 + &redirect_uri=http://localhost:{port}/callback
49 + &state=random-csrf-token
50 + &code_challenge=base64url-sha256-of-verifier
51 + &code_challenge_method=S256
52 + ```
53 +
54 + 3. Start a localhost HTTP server to receive the callback.
55 + 4. After the user authorizes, exchange the code for a token:
56 +
57 + ```
58 + POST /oauth/token
59 + Content-Type: application/json
60 +
61 + {
62 + "grant_type": "authorization_code",
63 + "code": "received-auth-code",
64 + "redirect_uri": "http://localhost:{port}/callback",
65 + "code_verifier": "original-verifier",
66 + "client_id": "your-app-id"
67 + }
68 + ```
69 +
70 + Response:
71 +
72 + ```json
73 + {
74 + "access_token": "eyJhb...",
75 + "token_type": "Bearer",
76 + "expires_in": 3600,
77 + "user_id": "550e8400-...",
78 + "app_id": "6ba7b810-..."
79 + }
80 + ```
81 +
82 + ## Device Registration
83 +
84 + Register each device before pushing or pulling data.
85 +
86 + ```
87 + POST /api/sync/devices
88 + Authorization: Bearer {token}
89 + Content-Type: application/json
90 +
91 + {
92 + "device_name": "MacBook Pro",
93 + "platform": "macos"
94 + }
95 + ```
96 +
97 + Response: device object with `id`, `app_id`, `user_id`, `device_name`, `platform`, `last_seen_at`, `created_at`.
98 +
99 + Upserts: if a device with the same name exists, it updates `platform` and `last_seen_at`.
100 +
101 + ### List Devices
102 +
103 + ```
104 + GET /api/sync/devices
105 + Authorization: Bearer {token}
106 + ```
107 +
108 + ### Remove Device
109 +
110 + ```
111 + DELETE /api/sync/devices/{device_id}
112 + Authorization: Bearer {token}
113 + ```
114 +
115 + ## Push Changes
116 +
117 + Push encrypted changelog entries from the client to the server.
118 +
119 + ```
120 + POST /api/sync/push
121 + Authorization: Bearer {token}
122 + Content-Type: application/json
123 +
124 + {
125 + "device_id": "device-uuid",
126 + "changes": [
127 + {
128 + "table": "tasks",
129 + "op": "INSERT",
130 + "row_id": "row-uuid-string",
131 + "timestamp": "2026-03-13T10:00:00Z",
132 + "data": "base64-encrypted-json"
133 + }
134 + ]
135 + }
136 + ```
137 +
138 + Response:
139 +
140 + ```json
141 + {
142 + "cursor": 42
143 + }
144 + ```
145 +
146 + The `cursor` is the server-assigned sequence number of the last entry. Store it for subsequent pulls.
147 +
148 + ### Change Operations
149 +
150 + | Op | Meaning |
151 + |----|---------|
152 + | `INSERT` | New row |
153 + | `UPDATE` | Modified row |
154 + | `DELETE` | Removed row (data field omitted) |
155 +
156 + The `table` and `row_id` fields are opaque to the server. Use whatever identifiers make sense for your schema.
157 +
158 + The `data` field must be encrypted client-side. The server stores it as-is.
159 +
160 + ## Pull Changes
161 +
162 + Pull changelog entries since the last known cursor.
163 +
164 + ```
165 + POST /api/sync/pull
166 + Authorization: Bearer {token}
167 + Content-Type: application/json
168 +
169 + {
170 + "device_id": "device-uuid",
171 + "cursor": 0
172 + }
173 + ```
174 +
175 + Response:
176 +
177 + ```json
178 + {
179 + "changes": [
180 + {
181 + "seq": 1,
182 + "device_id": "originating-device-uuid",
183 + "table": "tasks",
184 + "op": "INSERT",
185 + "row_id": "row-uuid",
186 + "timestamp": "2026-03-13T10:00:00Z",
187 + "data": "base64-encrypted-json"
188 + }
189 + ],
190 + "cursor": 42,
191 + "has_more": false
192 + }
193 + ```
194 +
195 + When `has_more` is true, call pull again with the new cursor to fetch the next batch.
196 +
197 + ## Sync Status
198 +
199 + ```
200 + GET /api/sync/status
201 + Authorization: Bearer {token}
202 + ```
203 +
204 + Response:
205 +
206 + ```json
207 + {
208 + "total_changes": 1024,
209 + "latest_cursor": 1024
210 + }
211 + ```
212 +
213 + ## Key Management (E2E Encryption)
214 +
215 + The server stores an encrypted master key envelope per user. The plaintext master key never leaves the client.
216 +
217 + ### Store Encrypted Key
218 +
219 + ```
220 + PUT /api/sync/keys
221 + Authorization: Bearer {token}
222 + Content-Type: application/json
223 +
224 + {
225 + "encrypted_key": "json-envelope-string"
226 + }
227 + ```
228 +
229 + ### Retrieve Encrypted Key
230 +
231 + ```
232 + GET /api/sync/keys
233 + Authorization: Bearer {token}
234 + ```
235 +
236 + Response:
237 +
238 + ```json
239 + {
240 + "encrypted_key": "json-envelope-string"
241 + }
242 + ```
243 +
244 + ## Blob Storage
245 +
246 + For large files (images, audio, attachments), use blob storage with content-addressed deduplication.
247 +
248 + ### Request Upload URL
249 +
250 + ```
251 + POST /api/sync/blobs/upload
252 + Authorization: Bearer {token}
253 + Content-Type: application/json
254 +
255 + {
256 + "hash": "sha256-hex-string",
257 + "size_bytes": 1048576
258 + }
259 + ```
260 +
261 + Response:
262 +
263 + ```json
264 + {
265 + "upload_url": "https://s3.example.com/presigned-put-url",
266 + "already_exists": false
267 + }
268 + ```
269 +
270 + If `already_exists` is true, skip the upload.
271 +
272 + ### Upload Blob
273 +
274 + PUT the encrypted bytes to the presigned URL.
275 +
276 + ### Confirm Upload
277 +
278 + ```
279 + POST /api/sync/blobs/confirm
280 + Authorization: Bearer {token}
281 + Content-Type: application/json
282 +
283 + {
284 + "hash": "sha256-hex-string",
285 + "size_bytes": 1048576
286 + }
287 + ```
288 +
289 + ### Download Blob
290 +
291 + ```
292 + POST /api/sync/blobs/download
293 + Authorization: Bearer {token}
294 + Content-Type: application/json
295 +
296 + {
297 + "hash": "sha256-hex-string"
298 + }
299 + ```
300 +
301 + Response:
302 +
303 + ```json
304 + {
305 + "download_url": "https://s3.example.com/presigned-get-url"
306 + }
307 + ```
308 +
309 + ## Rate Limits
310 +
311 + | Endpoint | Limit |
312 + |----------|-------|
313 + | `/api/sync/auth` | 1 request/second, burst 3 |
314 + | All other sync endpoints | 200ms per request, burst 20 |
315 +
316 + Exceeding the limit returns HTTP 429.
317 +
318 + ## Error Responses
319 +
320 + All errors return JSON:
321 +
322 + ```json
323 + {
324 + "error": "descriptive message"
325 + }
326 + ```
327 +
328 + | Status | Meaning |
329 + |--------|---------|
330 + | 400 | Bad request (validation error) |
331 + | 401 | Unauthorized (missing or expired token) |
332 + | 429 | Rate limit exceeded |
333 + | 500 | Internal server error |
334 +
335 + ## Rust Client SDK
336 +
337 + For Rust applications, use the `synckit-client` crate instead of calling these endpoints directly. It handles authentication, encryption, retry logic, and OS keychain integration.
338 +
339 + ```toml
340 + [dependencies]
341 + synckit-client = { path = "../synckit-client" }
342 + ```
@@ -3453,7 +3453,7 @@ dependencies = [
3453 3453
3454 3454 [[package]]
3455 3455 name = "makenotwork"
3456 - version = "0.2.2"
3456 + version = "0.2.5"
3457 3457 dependencies = [
3458 3458 "ammonia",
3459 3459 "anyhow",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.2.3"
3 + version = "0.2.6"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -29,7 +29,7 @@ makenot.work {
29 29 # Referrer policy
30 30 Referrer-Policy "strict-origin-when-cross-origin"
31 31 # Content Security Policy
32 - 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:; connect-src 'self' https://api.stripe.com; frame-src https://js.stripe.com; base-uri 'self'; form-action 'self'"
32 + 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; connect-src 'self' https://api.stripe.com https://fsn1.your-objectstorage.com; media-src 'self' https://fsn1.your-objectstorage.com; frame-src https://js.stripe.com; base-uri 'self'; form-action 'self'"
33 33 }
34 34
35 35 # Static error pages when app is down
@@ -190,6 +190,73 @@ curl https://makenot.work/docs/
190 190
191 191 ---
192 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 +
193 260 ## Post-Deployment
194 261
195 262 ### Remove Demo Data
@@ -39,6 +39,12 @@ upload_config() {
39 39 ssh $SERVER "mkdir -p $REMOTE_DIR/error-pages"
40 40 scp $DEPLOY_DIR/error-pages/*.html $SERVER:$REMOTE_DIR/error-pages/
41 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 +
42 48 # Minify CSS for production (restore source on exit)
43 49 echo "[config] Minifying CSS..."
44 50 cp static/style.css static/style.css.src
@@ -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,61 @@
1 + #!/bin/bash
2 + # Firewall setup for Makenotwork production server (ufw).
3 + #
4 + # Rules:
5 + # - Allow all traffic on Tailscale interface (tailscale0)
6 + # - Allow SSH (port 22) from anywhere (needed for git SSH access)
7 + # - Allow HTTP/HTTPS (80/443) only from Cloudflare IP ranges
8 + # - Drop everything else
9 + #
10 + # Cloudflare IPs: https://www.cloudflare.com/ips-v4/
11 + # Run periodically or when Cloudflare publishes new ranges.
12 +
13 + set -e
14 +
15 + if [ "$(id -u)" -ne 0 ]; then
16 + echo "Error: Run as root"
17 + exit 1
18 + fi
19 +
20 + # Reset to defaults
21 + ufw --force reset
22 +
23 + # Default policies
24 + ufw default deny incoming
25 + ufw default allow outgoing
26 +
27 + # Tailscale — unrestricted
28 + ufw allow in on tailscale0
29 +
30 + # SSH — open from anywhere (git clone over SSH)
31 + ufw allow 22/tcp
32 +
33 + # HTTP/HTTPS — Cloudflare only
34 + CLOUDFLARE_IPS=(
35 + 173.245.48.0/20
36 + 103.21.244.0/22
37 + 103.22.200.0/22
38 + 103.31.4.0/22
39 + 141.101.64.0/18
40 + 108.162.192.0/18
41 + 190.93.240.0/20
42 + 188.114.96.0/20
43 + 197.234.240.0/22
44 + 198.41.128.0/17
45 + 162.158.0.0/15
46 + 104.16.0.0/13
47 + 104.24.0.0/14
48 + 172.64.0.0/13
49 + 131.0.72.0/22
50 + )
51 +
52 + for ip in "${CLOUDFLARE_IPS[@]}"; do
53 + ufw allow from "$ip" to any port 80,443 proto tcp
54 + done
55 +
56 + # Enable
57 + ufw --force enable
58 + ufw status verbose
59 +
60 + echo ""
61 + echo "Firewall configured. SSH open, HTTP/HTTPS Cloudflare-only."
@@ -1,9 +1,9 @@
1 1 #!/bin/bash
2 - # Setup SSH git push access on the production VPS.
2 + # Setup SSH git access on the production VPS.
3 3 #
4 - # Creates a `git` system user with git-shell, home directory at /opt/git/
5 - # so that `git@makenot.work:max/makenotwork.git` resolves to
6 - # /opt/git/max/makenotwork.git.
4 + # Creates a `git` system user with /bin/sh shell, home directory at /opt/git/.
5 + # SSH access is controlled by authorized_keys command= restrictions (managed
6 + # by mnw-admin rebuild-keys), not by the login shell.
7 7 #
8 8 # Run as root on the production VPS.
9 9
@@ -11,32 +11,23 @@ set -euo pipefail
11 11
12 12 GIT_HOME="/opt/git"
13 13
14 - # 1. Create git system user with git-shell (no interactive login)
14 + # 1. Create git system user (shell=/bin/sh — security via authorized_keys command=)
15 15 if id git &>/dev/null; then
16 16 echo "git user already exists"
17 17 else
18 - useradd --system --shell "$(which git-shell)" --home-dir "$GIT_HOME" --no-create-home git
18 + useradd --system --shell /bin/sh --home-dir "$GIT_HOME" --no-create-home git
19 19 echo "Created git user"
20 20 fi
21 21
22 - # Ensure home dir is set correctly (in case user existed with different home)
23 - usermod --home "$GIT_HOME" --shell "$(which git-shell)" git
22 + # Ensure home dir and shell are set correctly
23 + usermod --home "$GIT_HOME" --shell /bin/sh git
24 24
25 - # 2. Set up SSH authorized_keys
25 + # 2. Set up SSH directory (authorized_keys managed by mnw-admin rebuild-keys)
26 26 mkdir -p "$GIT_HOME/.ssh"
27 27 touch "$GIT_HOME/.ssh/authorized_keys"
28 28 chmod 700 "$GIT_HOME/.ssh"
29 29 chmod 600 "$GIT_HOME/.ssh/authorized_keys"
30 30
31 - # Add your SSH public key (replace with your actual key)
32 - if [ -f /root/.ssh/authorized_keys ]; then
33 - # Copy root's authorized keys as a starting point
34 - grep -v '^#' /root/.ssh/authorized_keys >> "$GIT_HOME/.ssh/authorized_keys" 2>/dev/null || true
35 - # Deduplicate
36 - sort -u "$GIT_HOME/.ssh/authorized_keys" -o "$GIT_HOME/.ssh/authorized_keys"
37 - echo "Copied SSH keys from root"
38 - fi
39 -
40 31 # 3. Ensure /opt/git/ repos are owned by git user
41 32 chown -R git:git "$GIT_HOME"
42 33
@@ -47,22 +38,7 @@ usermod -aG git makenotwork 2>/dev/null || true
47 38 # Ensure group read on repo dirs
48 39 chmod -R g+rX "$GIT_HOME"
49 40
50 - # 4. Create git-shell-commands directory (required for git-shell to work)
51 - mkdir -p "$GIT_HOME/git-shell-commands"
52 - # Add a no-interactive-login script
53 - cat > "$GIT_HOME/git-shell-commands/no-interactive-login" << 'SCRIPT'
54 - #!/bin/sh
55 - echo "Interactive login disabled. Use git push/pull/clone."
56 - exit 128
57 - SCRIPT
58 - chmod +x "$GIT_HOME/git-shell-commands/no-interactive-login"
59 - chown -R git:git "$GIT_HOME/git-shell-commands"
60 -
61 41 echo ""
62 42 echo "=== Git SSH setup complete ==="
63 43 echo ""
64 - echo "Test with:"
65 - echo " git clone git@makenot.work:max/makenotwork.git"
66 - echo " cd makenotwork && git push"
67 - echo ""
68 - echo "To add more SSH keys, edit: $GIT_HOME/.ssh/authorized_keys"
44 + echo "Next: run setup-ssh-keys.sh to configure sudoers for authorized_keys management"
@@ -0,0 +1,10 @@
1 + # Git SSH access — key-only, no interactive login
2 + # Drop-in for /etc/ssh/sshd_config.d/
3 + # Security: authorized_keys command= handles per-key routing to mnw-admin git-auth.
4 + # ForceCommand is NOT used — it overrides command= and strips the per-key ID.
5 + Match User git
6 + PasswordAuthentication no
7 + PermitEmptyPasswords no
8 + AllowTcpForwarding no
9 + X11Forwarding no
10 + PermitTTY no
@@ -0,0 +1,43 @@
1 + -- Lightweight issue tracker for git repos
2 +
3 + CREATE TABLE issues (
4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5 + repo_id UUID NOT NULL REFERENCES git_repos(id) ON DELETE CASCADE,
6 + number INT NOT NULL,
7 + author_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
8 + title VARCHAR(200) NOT NULL,
9 + body_markdown TEXT NOT NULL DEFAULT '',
10 + body_html TEXT NOT NULL DEFAULT '',
11 + status VARCHAR(20) NOT NULL DEFAULT 'open',
12 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
13 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
14 + UNIQUE (repo_id, number)
15 + );
16 +
17 + CREATE TABLE issue_comments (
18 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
19 + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
20 + author_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
21 + body_markdown TEXT NOT NULL,
22 + body_html TEXT NOT NULL,
23 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
24 + );
25 +
26 + CREATE TABLE issue_labels (
27 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
28 + repo_id UUID NOT NULL REFERENCES git_repos(id) ON DELETE CASCADE,
29 + name VARCHAR(50) NOT NULL,
30 + color VARCHAR(7) NOT NULL DEFAULT '#6c5ce7',
31 + UNIQUE (repo_id, name)
32 + );
33 +
34 + CREATE TABLE issue_label_assignments (
35 + issue_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
36 + label_id UUID NOT NULL REFERENCES issue_labels(id) ON DELETE CASCADE,
37 + PRIMARY KEY (issue_id, label_id)
38 + );
39 +
40 + CREATE INDEX idx_issues_repo_status ON issues(repo_id, status);
41 + CREATE INDEX idx_issues_author ON issues(author_user_id);
42 + CREATE INDEX idx_issue_comments_issue ON issue_comments(issue_id);
43 + CREATE INDEX idx_issue_labels_repo ON issue_labels(repo_id);
@@ -0,0 +1,2 @@
1 + -- Add description column to git_repos for web-editable repo descriptions.
2 + ALTER TABLE git_repos ADD COLUMN description TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,35 @@
1 + -- Platform-curated labels: commitments creators opt into voluntarily.
2 + -- Labels are distinct from tags: tags help people find work, labels are promises.
3 +
4 + CREATE TABLE labels (
5 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
6 + slug VARCHAR(100) NOT NULL UNIQUE,
7 + display_name VARCHAR(100) NOT NULL,
8 + definition TEXT NOT NULL,
9 + examples TEXT NOT NULL DEFAULT '',
10 + nonexamples TEXT NOT NULL DEFAULT '',
11 + sort_order INT NOT NULL DEFAULT 0,
12 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
13 + );
14 +
15 + CREATE TABLE project_labels (
16 + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
17 + label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
18 + confirmed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
19 + PRIMARY KEY (project_id, label_id)
20 + );
21 +
22 + CREATE INDEX idx_project_labels_label_id ON project_labels(label_id);
23 +
24 + -- Seed initial labels
25 + INSERT INTO labels (slug, display_name, definition, examples, nonexamples, sort_order) VALUES
26 + ('drm-free', 'DRM-Free', 'All content is delivered without digital rights management restrictions. Buyers own what they purchase with no usage limits, device locks, or online check-ins.', 'Direct file downloads, unrestricted MP3/FLAC, DRM-free PDF/EPUB', 'Streaming-only access, license key activation limits, cloud-locked content', 1),
27 + ('lifetime-warranty', 'Lifetime Warranty', 'The creator commits to fixing bugs and compatibility issues for the lifetime of the product. Does not require adding new features, only maintaining what was sold.', 'Bug fixes for sold software, compatibility patches for new OS versions', 'Free major version upgrades, unlimited feature requests, guaranteed response times', 2),
28 + ('no-llms-used', 'No LLMs Used', 'No large language models or generative AI were used in creating this content. All writing, code, art, and audio are human-made.', 'Hand-written code, human-composed music, hand-drawn illustrations', 'AI-assisted code completion, AI-generated placeholder text later edited, AI upscaling', 3),
29 + ('no-tracking', 'No Tracking', 'No analytics, telemetry, or user behavior tracking of any kind. No data is collected about how buyers use the product.', 'Fully offline software, static websites with no analytics, local-only apps', 'Anonymous usage stats, crash reporting, feature flag systems that phone home', 4),
30 + ('ad-free', 'Ad-Free', 'The product contains no advertisements, sponsored content, or affiliate links. Revenue comes entirely from direct sales.', 'Clean podcast episodes, ad-free articles, software with no upsell banners', 'Sponsored segments, affiliate links in show notes, in-app purchase prompts', 5),
31 + ('offline-capable', 'Offline-Capable', 'The product works fully without an internet connection after initial download or setup. No features are gated behind online access.', 'Desktop apps that work offline, downloadable reference guides, local-first software', 'Web apps that cache some data, apps that need login to unlock features, streaming services with download', 6),
32 + ('source-available', 'Source Available', 'The complete source code is available for inspection. Users can read, audit, and learn from the code. Does not require a specific open-source license.', 'Public git repository, source included with purchase, published build scripts', 'Obfuscated source, decompilable binaries, API documentation only', 7),
33 + ('no-subscription', 'No Subscription', 'One-time purchase only. No recurring fees, no expiring access, no subscription upsells. Pay once, own forever.', 'Buy-once software, permanent course access, one-time font license', 'Annual license renewals, subscription tiers, time-limited access codes', 8),
34 + ('solo-dev', 'Solo Dev', 'Made entirely by one person. No employees, contractors, or outsourced work. One human, start to finish.', 'One-person indie games, solo musician albums, single-author books', 'Small team projects credited to one person, freelancer-assisted work, label/publisher backed', 9),
35 + ('accessible', 'Accessible', 'Designed with accessibility in mind. Meets WCAG 2.1 AA or equivalent standards where applicable. Includes alt text, keyboard navigation, screen reader support, and sufficient contrast.', 'Keyboard-navigable apps, captioned videos, screen-reader-tested websites', 'Decorative alt text only, partial keyboard support, untested with assistive technology', 10);
@@ -0,0 +1,16 @@
1 + CREATE TABLE reports (
2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3 + reporter_user_id UUID NOT NULL REFERENCES users(id),
4 + target_type TEXT NOT NULL,
5 + target_id UUID NOT NULL,
6 + report_type TEXT NOT NULL,
7 + reason TEXT NOT NULL DEFAULT '',
8 + status TEXT NOT NULL DEFAULT 'open',
9 + admin_notes TEXT,
10 + resolved_by UUID REFERENCES users(id),
11 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12 + resolved_at TIMESTAMPTZ
13 + );
14 +
15 + CREATE INDEX idx_reports_status ON reports(status);
16 + CREATE INDEX idx_reports_target ON reports(target_type, target_id);
@@ -127,7 +127,12 @@ impl FromRequestParts<crate::AppState> for AuthUser {
127 127 }
128 128 }
129 129
130 - /// Extractor for optional authenticated users - returns None if not logged in
130 + /// Extractor for optional authenticated users — returns None if not logged in.
131 + ///
132 + /// **Security note:** Unlike `AuthUser`, this does NOT validate the session tracking ID
133 + /// against the database. Revoked sessions may still appear authenticated. Use only on
134 + /// read-only or public endpoints where this is acceptable. For any endpoint that modifies
135 + /// data or displays sensitive information, use `AuthUser` instead.
131 136 pub struct MaybeUser(pub Option<SessionUser>);
132 137
133 138 impl<S> FromRequestParts<S> for MaybeUser
@@ -347,6 +352,7 @@ mod tests {
347 352 git_repos_path: None,
348 353 postmark_webhook_token: None,
349 354 postmark_broadcast_webhook_token: None,
355 + git_ssh_host: None,
350 356 };
351 357 assert!(require_admin(&user, &config).is_ok());
352 358 }
@@ -394,6 +400,7 @@ mod tests {
394 400 git_repos_path: None,
395 401 postmark_webhook_token: None,
396 402 postmark_broadcast_webhook_token: None,
403 + git_ssh_host: None,
397 404 };
398 405 assert!(require_admin(&user, &config).is_err());
399 406 }
@@ -37,6 +37,8 @@ pub struct Config {
37 37 pub postmark_webhook_token: Option<String>,
38 38 /// Bearer token for authenticating Postmark broadcast stream webhooks (optional)
39 39 pub postmark_broadcast_webhook_token: Option<String>,
40 + /// Hostname for git SSH clone URLs (e.g., "git.makenot.work"). Hidden when not set.
41 + pub git_ssh_host: Option<String>,
40 42 }
41 43
42 44 /// S3-compatible storage configuration (Hetzner Object Storage)
@@ -122,6 +124,9 @@ impl Config {
122 124 // Postmark broadcast stream webhook token - optional, same endpoint accepts either token
123 125 let postmark_broadcast_webhook_token = std::env::var("POSTMARK_BROADCAST_WEBHOOK_TOKEN").ok();
124 126
127 + // Git SSH host - optional, SSH clone URL hidden when unset
128 + let git_ssh_host = std::env::var("GIT_SSH_HOST").ok();
129 +
125 130 Ok(Config {
126 131 host,
127 132 port,
@@ -138,6 +143,7 @@ impl Config {
138 143 git_repos_path,
139 144 postmark_webhook_token,
140 145 postmark_broadcast_webhook_token,
146 + git_ssh_host,
141 147 })
142 148 }
143 149
@@ -264,6 +270,7 @@ impl std::fmt::Debug for Config {
264 270 .field("git_repos_path", &self.git_repos_path)
265 271 .field("postmark_webhook_token", &self.postmark_webhook_token.as_ref().map(|_| "[REDACTED]"))
266 272 .field("postmark_broadcast_webhook_token", &self.postmark_broadcast_webhook_token.as_ref().map(|_| "[REDACTED]"))
273 + .field("git_ssh_host", &self.git_ssh_host)
267 274 .finish()
268 275 }
269 276 }
@@ -324,6 +331,7 @@ mod tests {
324 331 git_repos_path: None,
325 332 postmark_webhook_token: None,
326 333 postmark_broadcast_webhook_token: None,
334 + git_ssh_host: None,
327 335 };
328 336 let addr = config.socket_addr();
329 337 assert_eq!(addr.port(), 8080);
@@ -110,7 +110,10 @@ where
110 110
111 111 /// Middleware to validate CSRF tokens on state-changing requests
112 112 ///
113 - /// Validates POST, PUT, PATCH, DELETE requests (except for excluded paths)
113 + /// Validates POST, PUT, PATCH, DELETE requests (except for excluded paths).
114 + /// Checks the `X-CSRF-Token` header first (used by HTMX), then falls back to
115 + /// parsing the `_csrf` field from form-encoded request bodies (used by vanilla
116 + /// HTML forms).
114 117 pub async fn csrf_middleware(request: Request, next: Next) -> Response {
115 118 let method = request.method().clone();
116 119
@@ -145,40 +148,78 @@ pub async fn csrf_middleware(request: Request, next: Next) -> Response {
145 148 }
146 149 };
147 150
148 - // Get token from header
149 - let headers = request.headers().clone();
150 - let provided_token = headers
151 + // Try header first (HTMX requests)
152 + let header_token = request
153 + .headers()
151 154 .get("X-CSRF-Token")
152 155 .and_then(|v| v.to_str().ok())
153 156 .map(|s| s.to_string());
154 157
155 - // If no header token, we need to check form body - but we can't consume it here
156 - // So for now, we require the X-CSRF-Token header for HTMX requests
157 - // Form submissions without HTMX should include the token in the header via JS
158 - let token = match provided_token {
159 - Some(t) => t,
160 - None => {
161 - // Allow requests without CSRF token if they have no session user
162 - // This handles public API endpoints
163 - let has_user: bool = session
164 - .get::<crate::auth::SessionUser>("user")
165 - .await
166 - .ok()
167 - .flatten()
168 - .is_some();
169 -
170 - if !has_user {
171 - return next.run(request).await;
158 + if let Some(ref token) = header_token {
159 + return match validate_token(&session, token).await {
160 + Ok(true) => next.run(request).await,
161 + Ok(false) => {
162 + tracing::warn!(path = %path, "CSRF token mismatch");
163 + (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()
164 + }
165 + Err(e) => {
166 + tracing::error!(error = ?e, "CSRF validation error");
167 + (StatusCode::INTERNAL_SERVER_ERROR, "CSRF validation error").into_response()
172 168 }
169 + };
170 + }
171 +
172 + // No header token — check if the user is authenticated
173 + let has_user: bool = session
174 + .get::<crate::auth::SessionUser>("user")
175 + .await
176 + .ok()
177 + .flatten()
178 + .is_some();
179 +
180 + if !has_user {
181 + return next.run(request).await;
182 + }
183 +
184 + // Authenticated user without header token — check form body for _csrf field.
185 + // Only attempt body parsing for form-encoded content types.
186 + let is_form = request
187 + .headers()
188 + .get("content-type")
189 + .and_then(|v| v.to_str().ok())
190 + .is_some_and(|ct| ct.starts_with("application/x-www-form-urlencoded"));
191 +
192 + if !is_form {
193 + tracing::warn!(path = %path, "CSRF token missing for authenticated non-form request");
194 + return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
195 + }
196 +
197 + // Buffer the body to extract _csrf, then reconstruct the request
198 + let (parts, body) = request.into_parts();
199 + let bytes = match axum::body::to_bytes(body, 1024 * 64).await {
200 + Ok(b) => b,
201 + Err(_) => {
202 + return (StatusCode::BAD_REQUEST, "Request body too large").into_response();
203 + }
204 + };
205 +
206 + let body_str = String::from_utf8_lossy(&bytes);
207 + let body_token = extract_token_from_request(&HeaderMap::new(), Some(&body_str));
173 208
174 - tracing::warn!(path = %path, "CSRF token missing for authenticated request");
209 + let token = match body_token {
210 + Some(t) => t,
211 + None => {
212 + tracing::warn!(path = %path, "CSRF token missing from form body");
175 213 return (StatusCode::FORBIDDEN, "CSRF token required").into_response();
176 214 }
177 215 };
178 216
179 - // Validate token
180 217 match validate_token(&session, &token).await {
181 - Ok(true) => next.run(request).await,
218 + Ok(true) => {
219 + // Reconstruct request with the buffered body
220 + let request = Request::from_parts(parts, axum::body::Body::from(bytes));
221 + next.run(request).await
222 + }
182 223 Ok(false) => {
183 224 tracing::warn!(path = %path, "CSRF token mismatch");
184 225 (StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()