Skip to main content

max / makenotwork

v0.3.8: I3/I4, tier enforcement, wizards, dev docs, bug fixes Platform integration I3 (mailing lists, migration 039-040) + I4 (content newsletters, migration 041). Phase 11C tier enforcement (migration 042, storage tracking, per-tier limits, grace period, dashboard bar). Phase 25 creation wizards + Phase 26 join wizard. Developer docs section + hosted rustdoc. Onboarding checklist recovery. Bug hunt fixes: version file_size trust (storage.rs), grace period blocking grandfathered users (creator_tiers.rs), storage TOCTOU race with atomic try_increment_storage, ota-publish.sh JSON injection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-23 01:24 UTC
Commit: ef0109bcdd8d3b531e270a8b6aae0b707820df8b
Parent: 3ffcd3d
67 files changed, +4359 insertions, -1057 deletions
M .gitignore +3
@@ -24,3 +24,6 @@ Thumbs.db
24 24
25 25 # Generated template partial (build.rs output)
26 26 server_code/makenotwork/templates/_head_assets.html
27 +
28 + # Generated rustdoc output
29 + rustdoc-out/
@@ -0,0 +1,67 @@
1 + # Developer API Overview
2 +
3 + Makenot.work exposes several APIs for building integrations, desktop applications, and developer tools. This page covers authentication methods, error handling, and rate limits shared across all endpoints.
4 +
5 + ## Authentication Methods
6 +
7 + ### Session Cookies
8 +
9 + The primary authentication for the web dashboard. Set via `/login`, required for all creator-facing endpoints (projects, items, files, analytics). Write operations require a CSRF token in the `_csrf` form field or `X-CSRF-Token` header.
10 +
11 + Session-authenticated endpoints are designed for the HTMX frontend. When called without the `HX-Request` header, they return JSON instead of HTML fragments.
12 +
13 + ### SyncKit JWT
14 +
15 + Used by [SyncKit](./synckit.md) cloud sync and [OTA updates](./ota.md). Obtain a token via `POST /api/sync/auth` (email + password + API key) or the [OAuth2 PKCE flow](./oauth.md). Pass it as `Authorization: Bearer <token>`. Tokens expire after 30 days.
16 +
17 + ### No Authentication
18 +
19 + Public endpoints that require no auth:
20 + - [License Key API](./license-keys.md) — key validation, activation, deactivation
21 + - [OTA update check](./ota.md) — Tauri-compatible update endpoint
22 + - [OAuth authorize](./oauth.md) — authorization page and code exchange
23 +
24 + ## Error Format
25 +
26 + All API errors return JSON:
27 +
28 + ```json
29 + {
30 + "error": "Description of what went wrong"
31 + }
32 + ```
33 +
34 + | Status | Meaning |
35 + |--------|---------|
36 + | 400 | Invalid request body or parameters |
37 + | 401 | Missing or invalid authentication |
38 + | 403 | Insufficient permissions |
39 + | 404 | Resource not found |
40 + | 413 | File too large |
41 + | 422 | Validation error |
42 + | 429 | Rate limit exceeded |
43 + | 500 | Internal error |
44 +
45 + Internal errors return a generic message — no stack traces or database details are leaked.
46 +
47 + ## Rate Limits
48 +
49 + Rate limits vary by endpoint category:
50 +
51 + | Category | Burst | Sustained |
52 + |----------|-------|-----------|
53 + | Authentication | 5 | 5/sec |
54 + | Write (POST/PUT/DELETE) | 10 | 2/sec |
55 + | License key validation | 5 | 1/sec |
56 + | Export (CSV/JSON) | 3 | 1/sec |
57 + | OTA update check | 20 | 5/sec |
58 +
59 + Exceeding a limit returns HTTP 429. Implement exponential backoff in your client.
60 +
61 + ## API Reference
62 +
63 + - [SyncKit Cloud Sync](./synckit.md) — push/pull encrypted data, device management, blob storage
64 + - [OTA Updates](./ota.md) — app auto-update server (Tauri-compatible protocol)
65 + - [OAuth2 PKCE](./oauth.md) — "Log in with Makenot.work"
66 + - [License Key API](./license-keys.md) — validate, activate, and deactivate license keys
67 + - [Rustdoc API Reference](/rustdoc/synckit_client/) — SyncKit client SDK documentation
@@ -0,0 +1,154 @@
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 |
150 +
151 + ## See Also
152 +
153 + - [Pricing & Monetization](../guide/03-selling.md) — License key setup for creators
154 + - [API Overview](./api-overview.md) — Authentication and rate limits
@@ -0,0 +1,164 @@
1 + # OAuth2 PKCE
2 +
3 + Makenot.work supports OAuth2 Authorization Code with PKCE for "Log in with Makenot.work" flows. This lets third-party applications authenticate users without handling their passwords directly.
4 +
5 + ## Overview
6 +
7 + 1. Your app generates a PKCE code verifier and challenge
8 + 2. User is redirected to `makenot.work/oauth/authorize` to log in and consent
9 + 3. Makenot.work redirects back with an authorization code
10 + 4. Your app exchanges the code for a JWT access token
11 + 5. Use the token to call SyncKit or userinfo endpoints
12 +
13 + ## Client Registration
14 +
15 + Your OAuth client ID is the API key of your SyncKit app. Create a SyncKit app from the Makenot.work dashboard to get one.
16 +
17 + ### Redirect URIs
18 +
19 + **Localhost**: `http://127.0.0.1:{port}/...` and `http://localhost:{port}/...` are always allowed without registration. Use these for desktop apps.
20 +
21 + **Remote**: Non-localhost redirect URIs must be registered on your SyncKit app. Contact support to add them.
22 +
23 + ## Authorization Request
24 +
25 + Redirect the user to the authorize endpoint:
26 +
27 + ```
28 + GET /oauth/authorize
29 + ?response_type=code
30 + &client_id=<your-api-key>
31 + &redirect_uri=http://127.0.0.1:8765/callback
32 + &state=<random-string>
33 + &code_challenge=<S256-challenge>
34 + &code_challenge_method=S256
35 + ```
36 +
37 + | Parameter | Required | Description |
38 + |-----------|----------|-------------|
39 + | `response_type` | Yes | Must be `code` |
40 + | `client_id` | Yes | Your SyncKit app API key |
41 + | `redirect_uri` | Yes | Where to send the authorization code |
42 + | `state` | Yes | Random string to prevent CSRF — verify it in the callback |
43 + | `code_challenge` | Yes | Base64url-encoded SHA-256 hash of the code verifier |
44 + | `code_challenge_method` | Yes | Must be `S256` |
45 +
46 + The user sees a consent page. After logging in and approving, they are redirected to:
47 +
48 + ```
49 + {redirect_uri}?code=<authorization-code>&state=<your-state>
50 + ```
51 +
52 + ## Token Exchange
53 +
54 + Exchange the authorization code for an access token:
55 +
56 + ```
57 + POST /oauth/token
58 + Content-Type: application/json
59 +
60 + {
61 + "grant_type": "authorization_code",
62 + "code": "<authorization-code>",
63 + "redirect_uri": "http://127.0.0.1:8765/callback",
64 + "code_verifier": "<original-code-verifier>",
65 + "client_id": "<your-api-key>"
66 + }
67 + ```
68 +
69 + Response:
70 +
71 + ```json
72 + {
73 + "access_token": "eyJ...",
74 + "token_type": "Bearer",
75 + "expires_in": 2592000,
76 + "user_id": "550e8400-...",
77 + "app_id": "660f9500-..."
78 + }
79 + ```
80 +
81 + The authorization code is single-use and consumed atomically. The server verifies `SHA256(code_verifier) == code_challenge` before issuing a token.
82 +
83 + ## User Info
84 +
85 + Retrieve the authenticated user's profile:
86 +
87 + ```
88 + GET /oauth/userinfo
89 + Authorization: Bearer <access_token>
90 + ```
91 +
92 + Response:
93 +
94 + ```json
95 + {
96 + "user_id": "550e8400-...",
97 + "username": "alice",
98 + "display_name": "Alice",
99 + "avatar_url": "https://makenot.work/static/avatars/alice.jpg"
100 + }
101 + ```
102 +
103 + ## PKCE Implementation
104 +
105 + PKCE (Proof Key for Code Exchange) prevents authorization code interception. Here is the flow:
106 +
107 + 1. Generate a random code verifier (43-128 characters, URL-safe)
108 + 2. Compute `code_challenge = BASE64URL(SHA256(code_verifier))`
109 + 3. Send `code_challenge` in the authorization request
110 + 4. Send `code_verifier` in the token exchange
111 +
112 + The server rejects token requests where the verifier does not match the challenge.
113 +
114 + ### Example (Rust)
115 +
116 + ```rust
117 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
118 + use sha2::{Digest, Sha256};
119 +
120 + let verifier: String = (0..64)
121 + .map(|_| rand::random::<u8>())
122 + .map(|b| format!("{:02x}", b))
123 + .collect();
124 +
125 + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
126 + ```
127 +
128 + ## Token Usage
129 +
130 + The access token works with all SyncKit endpoints:
131 +
132 + - [Cloud Sync](./synckit.md) — push/pull data, manage devices
133 + - [OTA Updates](./ota.md) — manage releases and artifacts
134 + - User info (above)
135 +
136 + Tokens expire after 30 days. After expiration, redirect the user through the authorization flow again.
137 +
138 + ## Error Handling
139 +
140 + Authorization errors redirect to `redirect_uri` with an `error` parameter:
141 +
142 + ```
143 + {redirect_uri}?error=access_denied&state=<your-state>
144 + ```
145 +
146 + Token exchange errors return JSON:
147 +
148 + ```json
149 + {
150 + "error": "invalid_grant"
151 + }
152 + ```
153 +
154 + | Error | Meaning |
155 + |-------|---------|
156 + | `access_denied` | User denied consent |
157 + | `invalid_client` | Unknown client_id |
158 + | `invalid_grant` | Code expired, already used, or verifier mismatch |
159 + | `invalid_request` | Missing required parameters |
160 +
161 + ## See Also
162 +
163 + - [API Overview](./api-overview.md) — authentication methods and rate limits
164 + - [SyncKit Cloud Sync](./synckit.md) — using the token for data sync
@@ -0,0 +1,170 @@
1 + # OTA Updates
2 +
3 + SyncKit OTA provides an auto-update server for desktop applications. It implements the Tauri updater protocol, so Tauri apps work out of the box. Non-Tauri apps can use the same HTTP endpoints directly.
4 +
5 + ## Setup
6 +
7 + ### 1. Register a Slug
8 +
9 + Each app needs a URL slug for the update endpoint. Set it on your SyncKit app:
10 +
11 + ```
12 + PUT /api/sync/ota/apps/{app_id}/slug
13 + Authorization: Bearer <token>
14 + Content-Type: application/json
15 +
16 + {
17 + "slug": "my-app"
18 + }
19 + ```
20 +
21 + Slugs must be 3-40 characters, lowercase alphanumeric with hyphens. This slug appears in the public update check URL.
22 +
23 + ### 2. Create a Release
24 +
25 + ```
26 + POST /api/sync/ota/apps/{app_id}/releases
27 + Authorization: Bearer <token>
28 + Content-Type: application/json
29 +
30 + {
31 + "version": "1.2.0",
32 + "notes": "Bug fixes and performance improvements",
33 + "signature": "<base64-encoded-signature>"
34 + }
35 + ```
36 +
37 + Response:
38 +
39 + ```json
40 + {
41 + "id": "880b1700-...",
42 + "version": "1.2.0",
43 + "notes": "Bug fixes and performance improvements",
44 + "signature": "<base64-encoded-signature>",
45 + "pub_date": "2026-03-13T12:00:00Z",
46 + "created_at": "2026-03-13T12:00:00Z"
47 + }
48 + ```
49 +
50 + Version must be valid semver (`X.Y.Z`, optionally with pre-release or build metadata).
51 +
52 + ### 3. Upload Artifacts
53 +
54 + For each platform/architecture combination, request a presigned upload URL and upload the binary:
55 +
56 + ```
57 + POST /api/sync/ota/apps/{app_id}/releases/{release_id}/artifacts
58 + Authorization: Bearer <token>
59 + Content-Type: application/json
60 +
61 + {
62 + "target": "darwin",
63 + "arch": "aarch64",
64 + "file_size": 52428800
65 + }
66 + ```
67 +
68 + Response:
69 +
70 + ```json
71 + {
72 + "upload_url": "https://s3.example.com/...",
73 + "s3_key": "ota/my-app/1.2.0/darwin-aarch64"
74 + }
75 + ```
76 +
77 + Upload the binary directly to the presigned URL with a PUT request.
78 +
79 + Target values: `linux`, `darwin`, `windows`. Arch values: `x86_64`, `aarch64`.
80 +
81 + ## Update Check Endpoint
82 +
83 + Public, no authentication required. Compatible with Tauri's built-in updater.
84 +
85 + ```
86 + GET /api/sync/ota/{slug}/{target}/{arch}/{current_version}
87 + ```
88 +
89 + Example:
90 +
91 + ```
92 + GET /api/sync/ota/my-app/darwin/aarch64/1.1.0
93 + ```
94 +
95 + ### Update Available (200)
96 +
97 + ```json
98 + {
99 + "version": "1.2.0",
100 + "url": "https://makenot.work/api/sync/ota/my-app/download/880b1700-.../darwin/aarch64",
101 + "signature": "<base64-encoded-signature>",
102 + "notes": "Bug fixes and performance improvements",
103 + "pub_date": "2026-03-13T12:00:00Z"
104 + }
105 + ```
106 +
107 + ### No Update (204)
108 +
109 + Empty response — the current version is already the latest.
110 +
111 + The endpoint compares versions using semver. It only returns an update if the latest release version is strictly greater than `current_version`.
112 +
113 + ## Download Endpoint
114 +
115 + ```
116 + GET /api/sync/ota/{slug}/download/{release_id}/{target}/{arch}
117 + ```
118 +
119 + Returns a 302 redirect to a presigned S3 download URL.
120 +
121 + ## Managing Releases
122 +
123 + ### List Releases
124 +
125 + ```
126 + GET /api/sync/ota/apps/{app_id}/releases
127 + Authorization: Bearer <token>
128 + ```
129 +
130 + ### Delete a Release
131 +
132 + ```
133 + DELETE /api/sync/ota/apps/{app_id}/releases/{release_id}
134 + Authorization: Bearer <token>
135 + ```
136 +
137 + Deleting a release also removes all its artifacts from S3.
138 +
139 + ## Tauri Integration
140 +
141 + For Tauri 2 apps, configure the updater plugin to point at your OTA endpoint:
142 +
143 + ```json
144 + {
145 + "plugins": {
146 + "updater": {
147 + "endpoints": [
148 + "https://makenot.work/api/sync/ota/my-app/{{target}}/{{arch}}/{{current_version}}"
149 + ]
150 + }
151 + }
152 + }
153 + ```
154 +
155 + Tauri handles the update check, download, signature verification, and restart automatically.
156 +
157 + ## Signature Verification
158 +
159 + The `signature` field in the release is passed through to the update check response. For Tauri apps, this is the Ed25519 signature produced by `tauri signer sign`. Your app verifies it against the public key embedded at build time.
160 +
161 + For non-Tauri apps, you can use any signature scheme — the server stores and returns the signature without interpretation.
162 +
163 + ## Publish Script
164 +
165 + The `deploy/ota-publish.sh` script automates the full publish flow: authenticate, create release, request presigned upload URLs, upload artifacts, and verify. See the script for usage details.
166 +
167 + ## See Also
168 +
169 + - [SyncKit Cloud Sync](./synckit.md) — data sync for your app
170 + - [API Overview](./api-overview.md) — authentication and rate limits
@@ -0,0 +1,290 @@
1 + # SyncKit Cloud Sync
2 +
3 + SyncKit provides cloud sync and encrypted data storage for desktop applications. It handles device registration, changelog-based sync, end-to-end encryption, and content-addressed blob storage — all through a REST API backed by your Makenot.work account.
4 +
5 + For the Rust client SDK, see the [API reference](/rustdoc/synckit_client/).
6 +
7 + ## Concepts
8 +
9 + - **Sync App** — A registered application on Makenot.work. Each app has its own API key, data namespace, and device list.
10 + - **Device** — A named installation of your app (e.g., "Alice's MacBook"). Each device syncs independently.
11 + - **Changelog** — An append-only log of changes. Each entry records a table name, operation, row ID, timestamp, and encrypted data blob.
12 + - **Cursor** — An opaque position in the changelog. Pull from cursor to get only new changes.
13 + - **Blob** — A content-addressed encrypted file stored in S3. Referenced by SHA-256 hash.
14 +
15 + ## Creating a Sync App
16 +
17 + From the Makenot.work dashboard, go to Settings and create a new SyncKit app. You receive an API key — save it securely, it is shown only once. You can regenerate it later, but all existing clients will need to re-authenticate.
18 +
19 + Optionally link the app to a project or item to associate sync data with a specific product.
20 +
21 + ## Authentication
22 +
23 + ### Direct Authentication
24 +
25 + For desktop apps where the user enters their Makenot.work credentials:
26 +
27 + ```
28 + POST /api/sync/auth
29 + Content-Type: application/json
30 +
31 + {
32 + "email": "user@example.com",
33 + "password": "their-password",
34 + "api_key": "your-app-api-key"
35 + }
36 + ```
37 +
38 + Response:
39 +
40 + ```json
41 + {
42 + "token": "eyJ...",
43 + "user_id": "550e8400-...",
44 + "app_id": "660f9500-..."
45 + }
46 + ```
47 +
48 + Direct auth does not work for accounts with 2FA enabled — those must use [OAuth2 PKCE](./oauth.md).
49 +
50 + ### OAuth2 PKCE
51 +
52 + For apps that want browser-based login (recommended for better UX), use the [OAuth2 PKCE flow](./oauth.md). The resulting access token works with all SyncKit endpoints.
53 +
54 + ## Device Registration
55 +
56 + Register a device before pushing or pulling data:
57 +
58 + ```
59 + POST /api/sync/devices
60 + Authorization: Bearer <token>
61 + Content-Type: application/json
62 +
63 + {
64 + "device_name": "Alice's MacBook",
65 + "platform": "macos"
66 + }
67 + ```
68 +
69 + Platform values: `macos`, `windows`, `linux`, `ios`, `android`, `web`.
70 +
71 + Response:
72 +
73 + ```json
74 + {
75 + "id": "770a0600-...",
76 + "device_name": "Alice's MacBook",
77 + "created_at": "2026-03-13T10:00:00Z"
78 + }
79 + ```
80 +
81 + If the same app + user + device_name combination already exists, the existing device is returned (upsert behavior).
82 +
83 + ### Listing and Removing Devices
84 +
85 + ```
86 + GET /api/sync/devices
87 + Authorization: Bearer <token>
88 + ```
89 +
90 + ```
91 + DELETE /api/sync/devices/{device_id}
92 + Authorization: Bearer <token>
93 + ```
94 +
95 + ## Push/Pull Sync
96 +
97 + ### Pushing Changes
98 +
99 + Send local changes to the server:
100 +
101 + ```
102 + POST /api/sync/push
103 + Authorization: Bearer <token>
104 + Content-Type: application/json
105 +
106 + {
107 + "device_id": "770a0600-...",
108 + "changes": [
109 + {
110 + "table": "tasks",
111 + "op": "insert",
112 + "row_id": "task-001",
113 + "timestamp": "2026-03-13T10:05:00Z",
114 + "data": "<encrypted-blob>"
115 + }
116 + ]
117 + }
118 + ```
119 +
120 + Response:
121 +
122 + ```json
123 + {
124 + "cursor": "abc123..."
125 + }
126 + ```
127 +
128 + Limits: maximum 1000 changes per push. Table names must be 3-64 characters, row IDs 1-256 characters. The server validates device ownership.
129 +
130 + ### Pulling Changes
131 +
132 + Fetch changes from other devices:
133 +
134 + ```
135 + POST /api/sync/pull
136 + Authorization: Bearer <token>
137 + Content-Type: application/json
138 +
139 + {
140 + "device_id": "770a0600-...",
141 + "cursor": "abc123..."
142 + }
143 + ```
144 +
145 + Response:
146 +
147 + ```json
148 + {
149 + "changes": [
150 + {
151 + "table": "tasks",
152 + "op": "update",
153 + "row_id": "task-001",
154 + "timestamp": "2026-03-13T10:10:00Z",
155 + "data": "<encrypted-blob>"
156 + }
157 + ],
158 + "cursor": "def456...",
159 + "has_more": false
160 + }
161 + ```
162 +
163 + Page size is 500 entries. Keep pulling while `has_more` is `true`.
164 +
165 + ### Checking Sync Status
166 +
167 + ```
168 + GET /api/sync/status
169 + Authorization: Bearer <token>
170 + ```
171 +
172 + Response:
173 +
174 + ```json
175 + {
176 + "total_changes": 1523,
177 + "latest_cursor": "ghi789..."
178 + }
179 + ```
180 +
181 + ## End-to-End Encryption
182 +
183 + SyncKit is designed for E2E encryption. The server stores only encrypted blobs in the `data` field — it never sees plaintext user data.
184 +
185 + The SyncKit client SDK uses ChaCha20-Poly1305 for data encryption and Argon2 for key derivation. The encrypted master key envelope is stored server-side so users can set up new devices without re-entering a passphrase.
186 +
187 + ### Key Storage
188 +
189 + Store and retrieve the encrypted master key:
190 +
191 + ```
192 + PUT /api/sync/keys
193 + Authorization: Bearer <token>
194 + Content-Type: application/json
195 +
196 + {
197 + "encrypted_key": "<base64-encoded-encrypted-master-key>"
198 + }
199 + ```
200 +
201 + ```
202 + GET /api/sync/keys
203 + Authorization: Bearer <token>
204 + ```
205 +
206 + Response:
207 +
208 + ```json
209 + {
210 + "encrypted_key": "<base64-encoded-encrypted-master-key>",
211 + "key_version": 1
212 + }
213 + ```
214 +
215 + Maximum key size: 4KB. Returns 404 if no key has been stored yet.
216 +
217 + ## Blob Storage
218 +
219 + Content-addressed blob storage for files, images, and other binary data. Blobs are stored in S3 and deduplicated by hash.
220 +
221 + ### Upload Flow
222 +
223 + 1. Request a presigned upload URL:
224 +
225 + ```
226 + POST /api/sync/blobs/upload
227 + Authorization: Bearer <token>
228 + Content-Type: application/json
229 +
230 + {
231 + "hash": "sha256-hex-string",
232 + "size_bytes": 1048576
233 + }
234 + ```
235 +
236 + Response:
237 +
238 + ```json
239 + {
240 + "upload_url": "https://s3.example.com/...",
241 + "already_exists": false
242 + }
243 + ```
244 +
245 + If `already_exists` is `true`, the blob is already stored — skip the upload.
246 +
247 + 2. Upload the file directly to the presigned URL (PUT request to S3).
248 +
249 + 3. Confirm the upload:
250 +
251 + ```
252 + POST /api/sync/blobs/confirm
253 + Authorization: Bearer <token>
254 + Content-Type: application/json
255 +
256 + {
257 + "hash": "sha256-hex-string",
258 + "size_bytes": 1048576
259 + }
260 + ```
261 +
262 + Maximum blob size: 100MB.
263 +
264 + ### Downloading Blobs
265 +
266 + ```
267 + POST /api/sync/blobs/download
268 + Authorization: Bearer <token>
269 + Content-Type: application/json
270 +
271 + {
272 + "hash": "sha256-hex-string"
273 + }
274 + ```
275 +
276 + Response:
277 +
278 + ```json
279 + {
280 + "download_url": "https://s3.example.com/..."
281 + }
282 + ```
283 +
284 + The download URL is a presigned S3 URL valid for a limited time.
285 +
286 + ## See Also
287 +
288 + - [OTA Updates](./ota.md) — auto-update your app through SyncKit
289 + - [OAuth2 PKCE](./oauth.md) — browser-based login for SyncKit apps
290 + - [SyncKit Client SDK](/rustdoc/synckit_client/) — Rust client library documentation
@@ -145,7 +145,7 @@ For software products, enable license keys in item settings:
145 145 - Fans see their keys in their library
146 146 - You can revoke keys from the dashboard
147 147
148 - See [License Key API](./license-keys.md) for integration details.
148 + See [License Key API](../developer/license-keys.md) for integration details.
149 149
150 150 ## Deleting an Item
151 151
@@ -1,153 +0,0 @@
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 |
150 -
151 - ## See Also
152 -
153 - - [Pricing & Monetization](./03-selling.md) — License key setup for creators
@@ -15,7 +15,7 @@ command -v pandoc >/dev/null 2>&1 || {
15 15 }
16 16
17 17 # Sections in display order (dir:DisplayName)
18 - SECTIONS=("about:About" "guide:Guide" "legal:Legal" "support:Support")
18 + SECTIONS=("about:About" "guide:Guide" "developer:Developer" "legal:Legal" "support:Support")
19 19
20 20 # Shared HTML pieces
21 21 FONTS='<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,700;1,400&amp;family=Lato:wght@400;700&amp;family=Young+Serif&amp;display=swap" rel="stylesheet">'
@@ -400,9 +400,9 @@ dependencies = [
400 400
401 401 [[package]]
402 402 name = "aws-lc-rs"
403 - version = "1.16.1"
403 + version = "1.16.2"
404 404 source = "registry+https://github.com/rust-lang/crates.io-index"
405 - checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
405 + checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc"
406 406 dependencies = [
407 407 "aws-lc-sys",
408 408 "zeroize",
@@ -410,9 +410,9 @@ dependencies = [
410 410
411 411 [[package]]
412 412 name = "aws-lc-sys"
413 - version = "0.38.0"
413 + version = "0.39.0"
414 414 source = "registry+https://github.com/rust-lang/crates.io-index"
415 - checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
415 + checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a"
416 416 dependencies = [
417 417 "cc",
418 418 "cmake",
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.5"
3353 + version = "0.3.8"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -4708,7 +4708,7 @@ dependencies = [
4708 4708 "aws-lc-rs",
4709 4709 "once_cell",
4710 4710 "rustls-pki-types",
4711 - "rustls-webpki 0.103.9",
4711 + "rustls-webpki 0.103.10",
4712 4712 "subtle",
4713 4713 "zeroize",
4714 4714 ]
@@ -4746,9 +4746,9 @@ dependencies = [
4746 4746
4747 4747 [[package]]
4748 4748 name = "rustls-webpki"
4749 - version = "0.103.9"
4749 + version = "0.103.10"
4750 4750 source = "registry+https://github.com/rust-lang/crates.io-index"
4751 - checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
4751 + checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
4752 4752 dependencies = [
4753 4753 "aws-lc-rs",
4754 4754 "ring",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.6"
3 + version = "0.3.8"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -65,6 +65,12 @@ upload_config() {
65 65 echo "[config] Uploading documentation..."
66 66 rsync -az --delete ../../docs/public/ $SERVER:$REMOTE_DIR/docs/public/
67 67
68 + # Rustdoc (API reference for library crates)
69 + echo "[config] Generating rustdoc..."
70 + "$DEPLOY_DIR/generate-rustdoc.sh"
71 + echo "[config] Uploading rustdoc..."
72 + rsync -az --delete ../../rustdoc-out/ $SERVER:$REMOTE_DIR/rustdoc/
73 +
68 74 # Reload systemd and restart Caddy
69 75 ssh $SERVER "systemctl daemon-reload && systemctl restart caddy"
70 76 echo "[config] Done"
@@ -0,0 +1,40 @@
1 + #!/bin/bash
2 + # Generate rustdoc for library crates (synckit-client, docengine, tagtree).
3 + # Output goes to ../../rustdoc-out/ (relative to server_code/makenotwork/).
4 + # Run from server_code/makenotwork/ directory.
5 +
6 + set -euo pipefail
7 +
8 + if [ ! -f "Cargo.toml" ]; then
9 + echo "Error: Run this script from server_code/makenotwork/"
10 + exit 1
11 + fi
12 +
13 + OUT_DIR="$(cd ../.. && pwd)/rustdoc-out"
14 + ACTIVE_DIR="$(cd ../.. && pwd)/active"
15 +
16 + CRATES=("synckit-client" "docengine" "tagtree")
17 +
18 + rm -rf "$OUT_DIR"
19 + mkdir -p "$OUT_DIR"
20 +
21 + for crate in "${CRATES[@]}"; do
22 + crate_dir="$ACTIVE_DIR/$crate"
23 + if [ ! -d "$crate_dir" ]; then
24 + echo "Warning: $crate_dir not found, skipping"
25 + continue
26 + fi
27 +
28 + echo "Generating docs for $crate..."
29 + (cd "$crate_dir" && cargo doc --no-deps --target-dir "$OUT_DIR/.target" 2>&1 | tail -1)
30 + done
31 +
32 + # Move generated docs from target/doc/ to output root
33 + if [ -d "$OUT_DIR/.target/doc" ]; then
34 + cp -r "$OUT_DIR/.target/doc/"* "$OUT_DIR/"
35 + rm -rf "$OUT_DIR/.target"
36 + fi
37 +
38 + echo ""
39 + echo "Rustdoc generated in $OUT_DIR/"
40 + ls -1 "$OUT_DIR/" | head -20
@@ -93,7 +93,7 @@ echo " Authenticated (app: $APP_ID)"
93 93
94 94 # Step 2: Create release
95 95 echo " Creating release v$VERSION..."
96 - RELEASE_BODY=$(printf '{"version":"%s","notes":"%s","signature":"%s"}' "$VERSION" "$NOTES" "$SIGNATURE")
96 + RELEASE_BODY=$(python3 -c "import json,sys; print(json.dumps({'version':sys.argv[1],'notes':sys.argv[2],'signature':sys.argv[3]}))" "$VERSION" "$NOTES" "$SIGNATURE")
97 97 RELEASE_RESPONSE=$(curl -sf -X POST "$SERVER/api/sync/ota/apps/$APP_ID/releases" \
98 98 -H "Content-Type: application/json" \
99 99 -H "Authorization: Bearer $TOKEN" \
@@ -0,0 +1,25 @@
1 + -- Mailing list infrastructure for per-project email lists (I3).
2 + -- Lists are created automatically on project creation (content + devlog).
3 + -- Subscribers are tracked separately from the follow social graph.
4 +
5 + CREATE TABLE mailing_lists (
6 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7 + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
8 + list_type TEXT NOT NULL CHECK (list_type IN ('content', 'devlog', 'patches')),
9 + name VARCHAR(200) NOT NULL,
10 + description TEXT,
11 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12 + UNIQUE (project_id, list_type)
13 + );
14 +
15 + CREATE TABLE mailing_list_subscribers (
16 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17 + list_id UUID NOT NULL REFERENCES mailing_lists(id) ON DELETE CASCADE,
18 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
19 + subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
20 + UNIQUE (list_id, user_id)
21 + );
22 +
23 + CREATE INDEX idx_mailing_list_subscribers_user ON mailing_list_subscribers (user_id);
24 + CREATE INDEX idx_mailing_list_subscribers_list ON mailing_list_subscribers (list_id);
25 + CREATE INDEX idx_mailing_lists_project ON mailing_lists (project_id);
@@ -0,0 +1,9 @@
1 + -- Backfill: subscribe existing project followers to content mailing lists.
2 + -- Safe to run on empty data (no-op if no followers or no mailing lists exist).
3 +
4 + INSERT INTO mailing_list_subscribers (list_id, user_id)
5 + SELECT ml.id, f.follower_id
6 + FROM follows f
7 + JOIN mailing_lists ml ON ml.project_id = f.target_id AND ml.list_type = 'content'
8 + WHERE f.target_type = 'project'
9 + ON CONFLICT (list_id, user_id) DO NOTHING;
@@ -0,0 +1,7 @@
1 + -- I4: Content Newsletter Emails
2 + -- Items: web_only flag (publish without emailing subscribers)
3 + ALTER TABLE items ADD COLUMN web_only BOOLEAN NOT NULL DEFAULT false;
4 +
5 + -- Blog posts: web_only flag + idempotent announcement guard
6 + ALTER TABLE blog_posts ADD COLUMN web_only BOOLEAN NOT NULL DEFAULT false;
7 + ALTER TABLE blog_posts ADD COLUMN release_announced_at TIMESTAMPTZ;
@@ -0,0 +1,23 @@
1 + -- Storage tracking
2 + ALTER TABLE users ADD COLUMN storage_used_bytes BIGINT NOT NULL DEFAULT 0;
3 + ALTER TABLE users ADD COLUMN max_file_override_bytes BIGINT;
4 + ALTER TABLE users ADD COLUMN grandfathered_until TIMESTAMPTZ;
5 +
6 + -- File sizes on items (audio + cover — versions already have file_size_bytes)
7 + ALTER TABLE items ADD COLUMN audio_file_size_bytes BIGINT;
8 + ALTER TABLE items ADD COLUMN cover_file_size_bytes BIGINT;
9 +
10 + -- Grace period enforcement marker
11 + ALTER TABLE creator_subscriptions ADD COLUMN grace_enforced_at TIMESTAMPTZ;
12 +
13 + -- Backfill storage_used from versions + insertions (items backfilled separately via S3 HEAD)
14 + UPDATE users u SET storage_used_bytes = COALESCE((
15 + SELECT SUM(v.file_size_bytes) FROM versions v
16 + JOIN items i ON v.item_id = i.id JOIN projects p ON i.project_id = p.id
17 + WHERE p.user_id = u.id AND v.file_size_bytes IS NOT NULL
18 + ), 0) + COALESCE((
19 + SELECT SUM(ci.file_size) FROM content_insertions ci WHERE ci.user_id = u.id
20 + ), 0);
21 +
22 + -- Grandfather all existing creators for 6 months
23 + UPDATE users SET grandfathered_until = '2026-09-22T00:00:00Z' WHERE can_create_projects = true;
@@ -19,13 +19,14 @@ pub async fn create_blog_post(
19 19 body_markdown: &str,
20 20 body_html: &str,
21 21 publish: bool,
22 + web_only: bool,
22 23 ) -> Result<DbBlogPost> {
23 24 let published_at = if publish { Some(Utc::now()) } else { None };
24 25
25 26 let post = sqlx::query_as::<_, DbBlogPost>(
26 27 r#"
27 - INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at)
28 - VALUES ($1, $2, $3, $4, $5, $6, $7)
28 + INSERT INTO blog_posts (project_id, author_id, title, slug, body_markdown, body_html, published_at, web_only)
29 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
29 30 RETURNING *
30 31 "#,
31 32 )
@@ -36,6 +37,7 @@ pub async fn create_blog_post(
36 37 .bind(body_markdown)
37 38 .bind(body_html)
38 39 .bind(published_at)
40 + .bind(web_only)
39 41 .fetch_one(pool)
40 42 .await?;
41 43
@@ -104,6 +106,8 @@ pub async fn get_published_blog_posts_by_project(
104 106 /// `publish_at` uses a double-Option: `None` = no change, `Some(None)` = clear schedule,
105 107 /// `Some(Some(dt))` = set schedule. When a schedule is set, `published_at` stays NULL
106 108 /// (the scheduler will set it when the time comes).
109 + ///
110 + /// `web_only` uses `Option<bool>`: `None` = no change, `Some(v)` = update.
107 111 #[allow(clippy::too_many_arguments)]
108 112 pub async fn update_blog_post(
109 113 pool: &PgPool,
@@ -114,6 +118,7 @@ pub async fn update_blog_post(
114 118 body_html: &str,
115 119 publish: bool,
116 120 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
121 + web_only: Option<bool>,
117 122 ) -> Result<DbBlogPost> {
118 123 let update_publish_at = publish_at.is_some();
119 124 let publish_at_value = publish_at.flatten();
@@ -137,6 +142,7 @@ pub async fn update_blog_post(
137 142 ELSE published_at
138 143 END,
139 144 publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END,
145 + web_only = COALESCE($9, web_only),
140 146 updated_at = NOW()
141 147 WHERE id = $1
142 148 RETURNING *
@@ -150,6 +156,7 @@ pub async fn update_blog_post(
150 156 .bind(publish)
151 157 .bind(update_publish_at)
152 158 .bind(publish_at_value)
159 + .bind(web_only)
153 160 .fetch_one(pool)
154 161 .await?;
155 162
@@ -211,6 +218,19 @@ pub async fn has_published_posts(pool: &PgPool, project_id: ProjectId) -> Result
211 218 Ok(exists)
212 219 }
213 220
221 + /// Atomically mark a blog post as having had its release announced.
222 + /// Returns false if already announced (prevents duplicate announcements on unpublish/republish).
223 + pub async fn mark_blog_post_announced(pool: &PgPool, post_id: BlogPostId) -> Result<bool> {
224 + let result = sqlx::query(
225 + "UPDATE blog_posts SET release_announced_at = NOW() WHERE id = $1 AND release_announced_at IS NULL",
226 + )
227 + .bind(post_id)
228 + .execute(pool)
229 + .await?;
230 +
231 + Ok(result.rows_affected() > 0)
232 + }
233 +
214 234 /// Check if a slug already exists for a project.
215 235 pub async fn blog_post_slug_exists(
216 236 pool: &PgPool,
@@ -1,12 +1,14 @@
1 - //! Creator tier subscription queries.
1 + //! Creator tier subscription queries and storage enforcement.
2 2
3 3 use chrono::{DateTime, Utc};
4 4 use sqlx::PgPool;
5 5
6 6 use super::enums::CreatorTier;
7 7 use super::id_types::*;
8 - use super::models::DbCreatorSubscription;
9 - use crate::error::Result;
8 + use super::models::{DbCreatorSubscription, StorageBreakdown};
9 + use crate::error::{AppError, Result};
10 + use crate::helpers::format_bytes;
11 + use crate::storage::FileType;
10 12
11 13 /// Create a new creator tier subscription record.
12 14 ///
@@ -165,3 +167,372 @@ pub async fn sync_user_creator_tier(pool: &PgPool, user_id: UserId) -> Result<()
165 167
166 168 Ok(())
167 169 }
170 +
171 + // ============================================================================
172 + // Storage tracking
173 + // ============================================================================
174 +
175 + /// Get the current storage_used_bytes for a user.
176 + pub async fn get_storage_used(pool: &PgPool, user_id: UserId) -> Result<i64> {
177 + let used: i64 = sqlx::query_scalar(
178 + "SELECT storage_used_bytes FROM users WHERE id = $1",
179 + )
180 + .bind(user_id)
181 + .fetch_one(pool)
182 + .await?;
183 +
184 + Ok(used)
185 + }
186 +
187 + /// Atomically check storage cap and increment the user's storage counter.
188 + /// Returns an error if the increment would exceed `max_storage_bytes`.
189 + pub async fn try_increment_storage(
190 + pool: &PgPool,
191 + user_id: UserId,
192 + bytes: i64,
193 + max_storage_bytes: i64,
194 + ) -> Result<()> {
195 + let result = sqlx::query(
196 + "UPDATE users SET storage_used_bytes = storage_used_bytes + $2 \
197 + WHERE id = $1 AND storage_used_bytes + $2 <= $3",
198 + )
199 + .bind(user_id)
200 + .bind(bytes)
201 + .bind(max_storage_bytes)
202 + .execute(pool)
203 + .await?;
204 +
205 + if result.rows_affected() == 0 {
206 + let used = get_storage_used(pool, user_id).await?;
207 + return Err(AppError::BadRequest(format!(
208 + "You've used {} of {} storage. Delete files or upgrade your tier.",
209 + format_bytes(used),
210 + format_bytes(max_storage_bytes),
211 + )));
212 + }
213 +
214 + Ok(())
215 + }
216 +
217 + /// Atomically decrement the user's storage counter (clamped to 0).
218 + pub async fn decrement_storage_used<'e>(
219 + executor: impl sqlx::PgExecutor<'e>,
220 + user_id: UserId,
221 + bytes: i64,
222 + ) -> Result<()> {
223 + sqlx::query(
224 + "UPDATE users SET storage_used_bytes = GREATEST(0, storage_used_bytes - $2) WHERE id = $1",
225 + )
226 + .bind(user_id)
227 + .bind(bytes)
228 + .execute(executor)
229 + .await?;
230 +
231 + Ok(())
232 + }
233 +
234 + /// Get the admin-set per-file size override for a user.
235 + pub async fn get_max_file_override(pool: &PgPool, user_id: UserId) -> Result<Option<i64>> {
236 + let val: Option<i64> = sqlx::query_scalar(
237 + "SELECT max_file_override_bytes FROM users WHERE id = $1",
238 + )
239 + .bind(user_id)
240 + .fetch_one(pool)
241 + .await?;
242 +
243 + Ok(val)
244 + }
245 +
246 + /// Set or clear the admin per-file size override.
247 + pub async fn set_max_file_override(
248 + pool: &PgPool,
249 + user_id: UserId,
250 + bytes: Option<i64>,
251 + ) -> Result<()> {
252 + sqlx::query(
253 + "UPDATE users SET max_file_override_bytes = $2 WHERE id = $1",
254 + )
255 + .bind(user_id)
256 + .bind(bytes)
257 + .execute(pool)
258 + .await?;
259 +
260 + Ok(())
261 + }
262 +
263 + /// Get the grandfathering deadline for a user.
264 + pub async fn get_grandfathered_until(
265 + pool: &PgPool,
266 + user_id: UserId,
267 + ) -> Result<Option<DateTime<Utc>>> {
268 + let val: Option<DateTime<Utc>> = sqlx::query_scalar(
269 + "SELECT grandfathered_until FROM users WHERE id = $1",
270 + )
271 + .bind(user_id)
272 + .fetch_one(pool)
273 + .await?;
274 +
275 + Ok(val)
276 + }
277 +
278 + /// Recompute storage_used_bytes from versions + insertions + item audio/cover files.
279 + /// Returns the corrected total.
280 + pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result<i64> {
281 + let total: i64 = sqlx::query_scalar(
282 + r#"
283 + WITH version_bytes AS (
284 + SELECT COALESCE(SUM(v.file_size_bytes), 0) AS total
285 + FROM versions v
286 + JOIN items i ON v.item_id = i.id
287 + JOIN projects p ON i.project_id = p.id
288 + WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
289 + ),
290 + insertion_bytes AS (
291 + SELECT COALESCE(SUM(ci.file_size), 0) AS total
292 + FROM content_insertions ci
293 + WHERE ci.user_id = $1
294 + ),
295 + audio_bytes AS (
296 + SELECT COALESCE(SUM(i.audio_file_size_bytes), 0) AS total
297 + FROM items i
298 + JOIN projects p ON i.project_id = p.id
299 + WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
300 + ),
301 + cover_bytes AS (
302 + SELECT COALESCE(SUM(i.cover_file_size_bytes), 0) AS total
303 + FROM items i
304 + JOIN projects p ON i.project_id = p.id
305 + WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
306 + )
307 + SELECT (SELECT total FROM version_bytes)
308 + + (SELECT total FROM insertion_bytes)
309 + + (SELECT total FROM audio_bytes)
310 + + (SELECT total FROM cover_bytes) AS total
311 + "#,
312 + )
313 + .bind(user_id)
314 + .fetch_one(pool)
315 + .await?;
316 +
317 + sqlx::query(
318 + "UPDATE users SET storage_used_bytes = $2 WHERE id = $1",
319 + )
320 + .bind(user_id)
321 + .bind(total)
322 + .execute(pool)
323 + .await?;
324 +
325 + Ok(total)
326 + }
327 +
328 + /// Get a per-category storage breakdown for the creator dashboard.
329 + pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<StorageBreakdown> {
330 + let audio: i64 = sqlx::query_scalar(
331 + r#"
332 + SELECT COALESCE(SUM(i.audio_file_size_bytes), 0)
333 + FROM items i JOIN projects p ON i.project_id = p.id
334 + WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
335 + "#,
336 + )
337 + .bind(user_id)
338 + .fetch_one(pool)
339 + .await?;
340 +
341 + let cover: i64 = sqlx::query_scalar(
342 + r#"
343 + SELECT COALESCE(SUM(i.cover_file_size_bytes), 0)
344 + FROM items i JOIN projects p ON i.project_id = p.id
345 + WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
346 + "#,
347 + )
348 + .bind(user_id)
349 + .fetch_one(pool)
350 + .await?;
351 +
352 + let download: i64 = sqlx::query_scalar(
353 + r#"
354 + SELECT COALESCE(SUM(v.file_size_bytes), 0)
355 + FROM versions v
356 + JOIN items i ON v.item_id = i.id
357 + JOIN projects p ON i.project_id = p.id
358 + WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
359 + "#,
360 + )
361 + .bind(user_id)
362 + .fetch_one(pool)
363 + .await?;
364 +
365 + let insertion: i64 = sqlx::query_scalar(
366 + "SELECT COALESCE(SUM(file_size), 0) FROM content_insertions WHERE user_id = $1",
367 + )
368 + .bind(user_id)
369 + .fetch_one(pool)
370 + .await?;
371 +
372 + Ok(StorageBreakdown {
373 + audio_bytes: audio,
374 + cover_bytes: cover,
375 + download_bytes: download,
376 + insertion_bytes: insertion,
377 + total_bytes: audio + cover + download + insertion,
378 + })
379 + }
380 +
381 + /// Get user IDs of creators with canceled subscriptions 30+ days ago
382 + /// whose items have not yet been hidden.
383 + pub async fn get_expired_grace_creators(pool: &PgPool) -> Result<Vec<UserId>> {
384 + let ids: Vec<UserId> = sqlx::query_scalar(
385 + r#"
386 + SELECT user_id FROM creator_subscriptions
387 + WHERE status = 'canceled'
388 + AND canceled_at IS NOT NULL
389 + AND canceled_at < NOW() - INTERVAL '30 days'
390 + AND grace_enforced_at IS NULL
391 + "#,
392 + )
393 + .fetch_all(pool)
394 + .await?;
395 +
396 + Ok(ids)
397 + }
398 +
399 + /// Mark a creator's post-grace enforcement as applied.
400 + pub async fn mark_grace_enforced(pool: &PgPool, user_id: UserId) -> Result<()> {
401 + sqlx::query(
402 + "UPDATE creator_subscriptions SET grace_enforced_at = NOW() WHERE user_id = $1",
403 + )
404 + .bind(user_id)
405 + .execute(pool)
406 + .await?;
407 +
408 + Ok(())
409 + }
410 +
411 + /// Check whether a user is in the 30-day cancellation grace period.
412 + ///
413 + /// Returns `true` if the subscription is canceled but within 30 days of cancellation
414 + /// and enforcement has not yet been applied.
415 + pub async fn is_in_grace_period(pool: &PgPool, user_id: UserId) -> Result<bool> {
416 + let in_grace: bool = sqlx::query_scalar(
417 + r#"
418 + SELECT EXISTS(
419 + SELECT 1 FROM creator_subscriptions
420 + WHERE user_id = $1
421 + AND status = 'canceled'
422 + AND canceled_at IS NOT NULL
423 + AND canceled_at > NOW() - INTERVAL '30 days'
424 + AND grace_enforced_at IS NULL
425 + )
426 + "#,
427 + )
428 + .bind(user_id)
429 + .fetch_one(pool)
430 + .await?;
431 +
432 + Ok(in_grace)
433 + }
434 +
435 + /// Get all creator user IDs (for periodic storage recalculation).
436 + pub async fn get_all_creator_user_ids(pool: &PgPool) -> Result<Vec<UserId>> {
437 + let ids: Vec<UserId> = sqlx::query_scalar(
438 + "SELECT id FROM users WHERE can_create_projects = true",
439 + )
440 + .fetch_all(pool)
441 + .await?;
442 +
443 + Ok(ids)
444 + }
445 +
446 + // ============================================================================
447 + // Enforcement
448 + // ============================================================================
449 +
450 + /// Check whether a file upload is allowed based on the user's tier, storage,
451 + /// and grandfathering status. Returns the tier's `max_storage_bytes` on success
452 + /// (for use with `try_increment_storage`), or an appropriate `AppError` if rejected.
453 + ///
454 + /// Covers (file_type == Cover) bypass tier checks but respect the 10 MB limit
455 + /// enforced separately in the storage module.
456 + pub async fn check_upload_allowed(
457 + pool: &PgPool,
458 + user_id: UserId,
459 + file_type: FileType,
460 + file_size_bytes: i64,
461 + ) -> Result<i64> {
462 + // Covers are always allowed (size checked separately)
463 + if file_type == FileType::Cover {
464 + return Ok(i64::MAX);
465 + }
466 +
467 + // Resolve effective tier
468 + let active_tier = get_active_creator_tier(pool, user_id).await?;
469 + let grandfathered = get_grandfathered_until(pool, user_id).await?;
470 +
471 + let effective_tier = match active_tier {
472 + Some(tier) => Some(tier),
473 + None => {
474 + // Check grandfathering
475 + if let Some(until) = grandfathered {
476 + if Utc::now() < until {
477 + Some(CreatorTier::SmallFiles) // grandfathered as SmallFiles-equivalent
478 + } else {
479 + None
480 + }
481 + } else {
482 + None
483 + }
484 + }
485 + };
486 +
487 + // Grace period check (canceled sub, within 30 days)
488 + // Use effective_tier so grandfathered users aren't blocked
489 + if effective_tier.is_none() {
490 + let in_grace = is_in_grace_period(pool, user_id).await?;
491 + if in_grace {
492 + return Err(AppError::Forbidden);
493 + }
494 + }
495 +
496 + // No tier and not grandfathered → reject
497 + let tier = match effective_tier {
498 + Some(t) => t,
499 + None => {
500 + return Err(AppError::BadRequest(
501 + "A creator tier subscription is required to upload files.".to_string(),
502 + ));
503 + }
504 + };
505 +
506 + // Basic tier is text-only (no non-cover uploads)
507 + if !tier.allows_file_uploads() {
508 + return Err(AppError::BadRequest(
509 + "Basic tier is text-only. Upgrade to Small Files or higher to upload files.".to_string(),
510 + ));
511 + }
512 +
513 + // Per-file size check
514 + let max_override = get_max_file_override(pool, user_id).await?;
515 + let max_file = max_override.unwrap_or(tier.max_file_bytes());
516 + if file_size_bytes > max_file {
517 + return Err(AppError::FileTooLarge(format!(
518 + "File size ({}) exceeds the {} per-file limit of {}.",
519 + format_bytes(file_size_bytes),
520 + tier.label(),
521 + format_bytes(max_file),
522 + )));
523 + }
524 +
525 + // Storage cap pre-check (non-atomic fast-fail; the atomic enforcement
526 + // happens in try_increment_storage after scanning completes)
527 + let used = get_storage_used(pool, user_id).await?;
528 + let max_storage = tier.max_storage_bytes();
529 + if used + file_size_bytes > max_storage {
530 + return Err(AppError::BadRequest(format!(
531 + "You've used {} of {} storage. Delete files or upgrade your tier.",
532 + format_bytes(used),
533 + format_bytes(max_storage),
534 + )));
535 + }
536 +
537 + Ok(max_storage)
538 + }