max / makenotwork
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() |