max / makenotwork
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 files changed,
+932 insertions,
-557 deletions
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //! Embeddable widget routes — public, no auth, iframe-friendly. | |
| 1 | + | //! Embeddable widget routes; public, no auth, iframe-friendly. | |
| 2 | 2 | //! | |
| 3 | 3 | //! All routes under `/embed/` serve self-contained HTML pages designed to be | |
| 4 | 4 | //! loaded inside iframes on external sites. They set permissive frame headers |
| @@ -23,7 +23,7 @@ | |||
| 23 | 23 | build_breadcrumbs, escape_html, fetch_linked_releases, format_size, parent_of, resolve_repo, | |
| 24 | 24 | }; | |
| 25 | 25 | ||
| 26 | - | /// `GET /git/{owner}/{repo}` — repo overview (tree at HEAD + README). | |
| 26 | + | /// `GET /git/{owner}/{repo}`: repo overview (tree at HEAD + README). | |
| 27 | 27 | #[tracing::instrument(skip_all, name = "git::repo_overview")] | |
| 28 | 28 | pub(super) async fn repo_overview( | |
| 29 | 29 | State(state): State<AppState>, | |
| @@ -71,7 +71,7 @@ | |||
| 71 | 71 | }) | |
| 72 | 72 | } | |
| 73 | 73 | ||
| 74 | - | /// `GET /git/{owner}/{repo}/tree/{ref}` — tree root at a specific ref. | |
| 74 | + | /// `GET /git/{owner}/{repo}/tree/{ref}`: tree root at a specific ref. | |
| 75 | 75 | #[tracing::instrument(skip_all, name = "git::tree_root")] | |
| 76 | 76 | pub(super) async fn tree_root( | |
| 77 | 77 | State(state): State<AppState>, | |
| @@ -117,7 +117,7 @@ | |||
| 117 | 117 | }) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | - | /// `GET /git/{owner}/{repo}/tree/{ref}/{*path}` — file or subdirectory. | |
| 120 | + | /// `GET /git/{owner}/{repo}/tree/{ref}/{*path}`: file or subdirectory. | |
| 121 | 121 | #[tracing::instrument(skip_all, name = "git::tree_or_file")] | |
| 122 | 122 | pub(super) async fn tree_or_file( | |
| 123 | 123 | State(state): State<AppState>, | |
| @@ -215,7 +215,7 @@ | |||
| 215 | 215 | page: Option<usize>, | |
| 216 | 216 | } | |
| 217 | 217 | ||
| 218 | - | /// `GET /git/{owner}/{repo}/commits/{ref}` — commit log with pagination. | |
| 218 | + | /// `GET /git/{owner}/{repo}/commits/{ref}`: commit log with pagination. | |
| 219 | 219 | #[tracing::instrument(skip_all, name = "git::commit_log")] | |
| 220 | 220 | pub(super) async fn commit_log( | |
| 221 | 221 | State(state): State<AppState>, | |
| @@ -257,7 +257,7 @@ | |||
| 257 | 257 | }) | |
| 258 | 258 | } | |
| 259 | 259 | ||
| 260 | - | /// `GET /git/{owner}/{repo}/commit/{oid}` — commit detail with inline diffs. | |
| 260 | + | /// `GET /git/{owner}/{repo}/commit/{oid}`: commit detail with inline diffs. | |
| 261 | 261 | #[tracing::instrument(skip_all, name = "git::commit_detail_page")] | |
| 262 | 262 | pub(super) async fn commit_detail_page( | |
| 263 | 263 | State(state): State<AppState>, | |
| @@ -306,7 +306,7 @@ | |||
| 306 | 306 | }) | |
| 307 | 307 | } | |
| 308 | 308 | ||
| 309 | - | /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}` — blame view. | |
| 309 | + | /// `GET /git/{owner}/{repo}/blame/{ref}/{*path}`: blame view. | |
| 310 | 310 | #[tracing::instrument(skip_all, name = "git::blame_view")] | |
| 311 | 311 | pub(super) async fn blame_view( | |
| 312 | 312 | State(state): State<AppState>, | |
| @@ -343,7 +343,7 @@ | |||
| 343 | 343 | }) | |
| 344 | 344 | } | |
| 345 | 345 | ||
| 346 | - | /// `GET /git/{owner}` — user's repository listing. | |
| 346 | + | /// `GET /git/{owner}`: user's repository listing. | |
| 347 | 347 | #[tracing::instrument(skip_all, name = "git::user_repos")] | |
| 348 | 348 | pub(super) async fn user_repos( | |
| 349 | 349 | State(state): State<AppState>, | |
| @@ -381,7 +381,7 @@ | |||
| 381 | 381 | page: Option<usize>, | |
| 382 | 382 | } | |
| 383 | 383 | ||
| 384 | - | /// `GET /git` — public explore page listing all public repos. | |
| 384 | + | /// `GET /git`: public explore page listing all public repos. | |
| 385 | 385 | #[tracing::instrument(skip_all, name = "git::git_explore")] | |
| 386 | 386 | pub(super) async fn git_landing( | |
| 387 | 387 | State(state): State<AppState>, | |
| @@ -410,7 +410,7 @@ | |||
| 410 | 410 | }) | |
| 411 | 411 | } | |
| 412 | 412 | ||
| 413 | - | /// `GET /git/{owner}/{repo}/log/{ref}/{*path}` — per-file commit history. | |
| 413 | + | /// `GET /git/{owner}/{repo}/log/{ref}/{*path}`: per-file commit history. | |
| 414 | 414 | #[tracing::instrument(skip_all, name = "git::file_log")] | |
| 415 | 415 | pub(super) async fn file_log( | |
| 416 | 416 | State(state): State<AppState>, |
| @@ -1,4 +1,4 @@ | |||
| 1 | - | //! Git source browser routes — public browsing of bare repos on disk. | |
| 1 | + | //! Git source browser routes; public browsing of bare repos on disk. | |
| 2 | 2 | ||
| 3 | 3 | mod browsing; | |
| 4 | 4 | mod raw; |
| @@ -18,7 +18,7 @@ | |||
| 18 | 18 | ||
| 19 | 19 | use super::{repos_root, resolve_repo, resolve_repo_name}; | |
| 20 | 20 | ||
| 21 | - | /// `GET /git/{owner}/{repo}/raw/{ref}/{*path}` — raw file download. | |
| 21 | + | /// `GET /git/{owner}/{repo}/raw/{ref}/{*path}`: raw file download. | |
| 22 | 22 | #[tracing::instrument(skip_all, name = "git::raw_file")] | |
| 23 | 23 | pub(super) async fn raw_file( | |
| 24 | 24 | State(state): State<AppState>, |
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | ||
| 22 | 22 | use super::default_ref; | |
| 23 | 23 | ||
| 24 | - | /// `GET /git/{owner}/{repo}/settings` — settings form (owner only). | |
| 24 | + | /// `GET /git/{owner}/{repo}/settings`: settings form (owner only). | |
| 25 | 25 | #[tracing::instrument(skip_all, name = "git_issues::repo_settings_form")] | |
| 26 | 26 | pub(super) async fn repo_settings_form( | |
| 27 | 27 | State(state): State<AppState>, | |
| @@ -61,7 +61,7 @@ | |||
| 61 | 61 | project_id: Option<String>, | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | - | /// `POST /git/{owner}/{repo}/settings` — save settings (owner only). | |
| 64 | + | /// `POST /git/{owner}/{repo}/settings`: save settings (owner only). | |
| 65 | 65 | #[tracing::instrument(skip_all, name = "git_issues::repo_settings_save")] | |
| 66 | 66 | pub(super) async fn repo_settings_save( | |
| 67 | 67 | State(state): State<AppState>, | |
| @@ -110,7 +110,7 @@ | |||
| 110 | 110 | Ok(Redirect::to(&format!("/git/{}/{}/settings", owner, repo_name))) | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | - | /// `POST /git/{owner}/{repo}/settings/delete` — delete repo (owner only). | |
| 113 | + | /// `POST /git/{owner}/{repo}/settings/delete`: delete repo (owner only). | |
| 114 | 114 | #[tracing::instrument(skip_all, name = "git_issues::repo_settings_delete")] | |
| 115 | 115 | pub(super) async fn repo_settings_delete( | |
| 116 | 116 | State(state): State<AppState>, |
| @@ -17,7 +17,7 @@ | |||
| 17 | 17 | AppState, | |
| 18 | 18 | }; | |
| 19 | 19 | ||
| 20 | - | /// GET /stripe/connect — Show disclaimer page before Stripe onboarding. | |
| 20 | + | /// GET /stripe/connect: Show disclaimer page before Stripe onboarding. | |
| 21 | 21 | #[tracing::instrument(skip_all, name = "stripe::connect_disclaimer")] | |
| 22 | 22 | pub(super) async fn stripe_connect_disclaimer( | |
| 23 | 23 | session: Session, | |
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | Ok(StripeConnectDisclaimerTemplate { csrf_token }.into_response()) | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | - | /// POST /stripe/connect/proceed — Create connected account (if needed) and | |
| 30 | + | /// POST /stripe/connect/proceed: Create connected account (if needed) and | |
| 31 | 31 | /// return the Stripe-hosted onboarding URL. | |
| 32 | 32 | /// | |
| 33 | 33 | /// Returns JSON with the URL instead of a redirect because `fetch()` cannot | |
| @@ -96,17 +96,17 @@ | |||
| 96 | 96 | url: String, | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | - | /// GET /stripe/connect/return — Creator finished (or left) Stripe onboarding. | |
| 99 | + | /// GET /stripe/connect/return: Creator finished (or left) Stripe onboarding. | |
| 100 | 100 | /// | |
| 101 | 101 | /// The actual onboarding status is determined by the `account.updated` webhook, | |
| 102 | 102 | /// not by the user landing here. The dashboard payments tab shows the real | |
| 103 | 103 | /// status (complete, pending review, action required) once it loads. | |
| 104 | 104 | /// | |
| 105 | - | /// No `AuthUser` guard and no server-side redirect — the browser arrives here | |
| 105 | + | /// No `AuthUser` guard and no server-side redirect; the browser arrives here | |
| 106 | 106 | /// via cross-site navigation from Stripe, and `SameSite=Strict` cookies are not | |
| 107 | 107 | /// sent on cross-site navigations (including server redirects that follow one). | |
| 108 | 108 | /// Instead, we return a minimal HTML page that does a client-side | |
| 109 | - | /// `window.location` — this initiates a fresh same-site navigation where the | |
| 109 | + | /// `window.location`; this initiates a fresh same-site navigation where the | |
| 110 | 110 | /// browser will include the session cookie. | |
| 111 | 111 | #[tracing::instrument(skip_all, name = "stripe::connect_return")] | |
| 112 | 112 | pub(super) async fn stripe_connect_return() -> axum::response::Html<&'static str> { | |
| @@ -121,11 +121,11 @@ | |||
| 121 | 121 | )) | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | - | /// GET /stripe/connect/refresh — Account Link expired or was already used. | |
| 124 | + | /// GET /stripe/connect/refresh: Account Link expired or was already used. | |
| 125 | 125 | /// | |
| 126 | 126 | /// Stripe redirects here cross-site, and `SameSite=Strict` cookies won't be | |
| 127 | 127 | /// present on a server-side redirect. Use the same client-side redirect | |
| 128 | - | /// pattern as `connect_return` — return minimal HTML that does | |
| 128 | + | /// pattern as `connect_return`; return minimal HTML that does | |
| 129 | 129 | /// `window.location.replace()` to initiate a fresh same-site navigation | |
| 130 | 130 | /// where the browser will include the session cookie. | |
| 131 | 131 | #[tracing::instrument(skip_all, name = "stripe::connect_refresh")] |
| @@ -19,7 +19,7 @@ | |||
| 19 | 19 | AppState, | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | - | /// POST /stripe/webhook/v2 — Handle Stripe v2 thin events | |
| 22 | + | /// POST /stripe/webhook/v2: Handle Stripe v2 thin events | |
| 23 | 23 | #[tracing::instrument(skip_all, name = "stripe::webhook_v2")] | |
| 24 | 24 | pub(super) async fn webhook_v2( | |
| 25 | 25 | State(state): State<AppState>, |
| @@ -20,7 +20,7 @@ | |||
| 20 | 20 | ||
| 21 | 21 | /// Create a new sync app and generate its API key. | |
| 22 | 22 | /// | |
| 23 | - | /// `POST /api/sync/apps` -- Session auth required. | |
| 23 | + | /// `POST /api/sync/apps`: Session auth required. | |
| 24 | 24 | /// Returns the app data plus the plaintext API key (shown only once). | |
| 25 | 25 | #[tracing::instrument(skip_all, name = "synckit::create_app")] | |
| 26 | 26 | pub(super) async fn create_app( | |
| @@ -44,7 +44,7 @@ | |||
| 44 | 44 | ||
| 45 | 45 | /// List all sync apps owned by the authenticated user. | |
| 46 | 46 | /// | |
| 47 | - | /// `GET /api/sync/apps` -- Session auth required. | |
| 47 | + | /// `GET /api/sync/apps`: Session auth required. | |
| 48 | 48 | #[tracing::instrument(skip_all, name = "synckit::list_apps")] | |
| 49 | 49 | pub(super) async fn list_apps( | |
| 50 | 50 | State(state): State<AppState>, | |
| @@ -57,7 +57,7 @@ | |||
| 57 | 57 | ||
| 58 | 58 | /// Regenerate the API key for a sync app, invalidating the old one. | |
| 59 | 59 | /// | |
| 60 | - | /// `POST /api/sync/apps/{id}/regenerate-key` -- Session auth required. | |
| 60 | + | /// `POST /api/sync/apps/{id}/regenerate-key`: Session auth required. | |
| 61 | 61 | #[tracing::instrument(skip_all, name = "synckit::regenerate_app_key")] | |
| 62 | 62 | pub(super) async fn regenerate_app_key( | |
| 63 | 63 | State(state): State<AppState>, | |
| @@ -80,7 +80,7 @@ | |||
| 80 | 80 | ||
| 81 | 81 | /// Delete a sync app and all its associated data. | |
| 82 | 82 | /// | |
| 83 | - | /// `DELETE /api/sync/apps/{id}` -- Session auth required. | |
| 83 | + | /// `DELETE /api/sync/apps/{id}`: Session auth required. | |
| 84 | 84 | #[tracing::instrument(skip_all, name = "synckit::delete_app")] | |
| 85 | 85 | pub(super) async fn delete_app( | |
| 86 | 86 | State(state): State<AppState>, | |
| @@ -102,7 +102,7 @@ | |||
| 102 | 102 | ||
| 103 | 103 | /// Update the project and/or item link for a sync app. | |
| 104 | 104 | /// | |
| 105 | - | /// `PUT /api/sync/apps/{id}/link` -- Session auth required. | |
| 105 | + | /// `PUT /api/sync/apps/{id}/link`: Session auth required. | |
| 106 | 106 | #[tracing::instrument(skip_all, name = "synckit::update_app_link")] | |
| 107 | 107 | pub(super) async fn update_app_link( | |
| 108 | 108 | State(state): State<AppState>, | |
| @@ -129,7 +129,7 @@ | |||
| 129 | 129 | ||
| 130 | 130 | /// Set the OTA slug for a sync app. | |
| 131 | 131 | /// | |
| 132 | - | /// `PUT /api/sync/apps/{id}/slug` -- Session auth required. | |
| 132 | + | /// `PUT /api/sync/apps/{id}/slug`: Session auth required. | |
| 133 | 133 | #[tracing::instrument(skip_all, name = "synckit::update_app_slug")] | |
| 134 | 134 | pub(super) async fn update_app_slug( | |
| 135 | 135 | State(state): State<AppState>, |
| @@ -2,13 +2,15 @@ | |||
| 2 | 2 | ||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::State, | |
| 5 | - | response::IntoResponse, | |
| 5 | + | http::StatusCode, | |
| 6 | + | response::{IntoResponse, Response}, | |
| 6 | 7 | Json, | |
| 7 | 8 | }; | |
| 9 | + | use serde_json::json; | |
| 8 | 10 | ||
| 9 | 11 | use crate::{ | |
| 10 | 12 | constants, | |
| 11 | - | db, | |
| 13 | + | db::{self, synckit_billing}, | |
| 12 | 14 | error::{AppError, Result, ResultExt}, | |
| 13 | 15 | synckit_auth::SyncUser, | |
| 14 | 16 | validation, | |
| @@ -49,24 +51,10 @@ | |||
| 49 | 51 | ))); | |
| 50 | 52 | } | |
| 51 | 53 | ||
| 52 | - | // Enforce blob storage quota from subscription tier | |
| 53 | - | let storage_limit = db::app_sync::get_blob_storage_limit( | |
| 54 | - | &state.db, sync_user.user_id, sync_user.app_id, | |
| 55 | - | ).await?; | |
| 56 | - | let storage_limit = storage_limit.unwrap_or(0); | |
| 57 | - | if storage_limit == 0 { | |
| 58 | - | return Err(AppError::PaymentRequired( | |
| 59 | - | "Blob sync requires an active subscription. Subscribe in your app settings.".to_string(), | |
| 60 | - | )); | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | let used = db::synckit::get_blob_storage_used(&state.db, sync_user.app_id, sync_user.user_id).await?; | |
| 64 | - | if used + req.size_bytes > storage_limit { | |
| 65 | - | return Err(AppError::BadRequest(format!( | |
| 66 | - | "Blob storage quota exceeded ({} GB limit). Upgrade your tier for more storage.", | |
| 67 | - | storage_limit / (1024 * 1024 * 1024) | |
| 68 | - | ))); | |
| 69 | - | } | |
| 54 | + | // Cap enforcement happens at confirm time (when we know the upload | |
| 55 | + | // succeeded). Presign returns a URL even when the upload would later be | |
| 56 | + | // rejected — cheap, and avoids leaking cap state to unauthenticated | |
| 57 | + | // S3 calls. See blob_confirm_upload for the gate. | |
| 70 | 58 | ||
| 71 | 59 | // Check dedup — if this hash already exists, skip upload | |
| 72 | 60 | if let Some(_existing) = db::synckit::get_sync_blob_by_hash( | |
| @@ -118,7 +106,7 @@ | |||
| 118 | 106 | State(state): State<AppState>, | |
| 119 | 107 | sync_user: SyncUser, | |
| 120 | 108 | Json(req): Json<BlobConfirmRequest>, | |
| 121 | - | ) -> Result<impl IntoResponse> { | |
| 109 | + | ) -> Result<Response> { | |
| 122 | 110 | let synckit_s3 = state | |
| 123 | 111 | .synckit_s3 | |
| 124 | 112 | .as_ref() | |
| @@ -126,6 +114,34 @@ | |||
| 126 | 114 | ||
| 127 | 115 | validation::validate_sync_blob_hash(&req.hash)?; | |
| 128 | 116 | ||
| 117 | + | // Billing + cap enforcement (skipped entirely for internal apps). | |
| 118 | + | let billing = synckit_billing::get_app_with_billing(&state.db, sync_user.app_id) | |
| 119 | + | .await? | |
| 120 | + | .ok_or(AppError::NotFound)?; | |
| 121 | + | ||
| 122 | + | if !billing.is_internal { | |
| 123 | + | if billing.billing_status != "active" { | |
| 124 | + | return Ok(( | |
| 125 | + | StatusCode::PAYMENT_REQUIRED, | |
| 126 | + | Json(json!({ "reason": "billing_inactive" })), | |
| 127 | + | ) | |
| 128 | + | .into_response()); | |
| 129 | + | } | |
| 130 | + | if let Some(exceeded) = synckit_billing::would_exceed_storage( | |
| 131 | + | &state.db, sync_user.app_id, req.size_bytes, | |
| 132 | + | ).await? { | |
| 133 | + | return Ok(( | |
| 134 | + | StatusCode::PAYMENT_REQUIRED, | |
| 135 | + | Json(json!({ | |
| 136 | + | "reason": "storage_limit_reached", | |
| 137 | + | "used": exceeded.used, | |
| 138 | + | "limit": exceeded.limit, | |
| 139 | + | })), | |
| 140 | + | ) | |
| 141 | + | .into_response()); | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 129 | 145 | let s3_key = format!("{}/{}/{}", sync_user.app_id, sync_user.user_id, req.hash); | |
| 130 | 146 | ||
| 131 | 147 | // Verify the object actually exists in S3 | |
| @@ -149,7 +165,18 @@ | |||
| 149 | 165 | ) | |
| 150 | 166 | .await?; | |
| 151 | 167 | ||
| 152 | - | Ok(axum::http::StatusCode::NO_CONTENT) | |
| 168 | + | // Update the rolling storage counter. We don't fail the request if this | |
| 169 | + | // breaks — the weekly drift correction job will reconcile from sync_blobs. | |
| 170 | + | // Skip for internal apps to keep the counter at 0 there. | |
| 171 | + | if !billing.is_internal { | |
| 172 | + | if let Err(e) = synckit_billing::add_bytes_stored( | |
| 173 | + | &state.db, sync_user.app_id, req.size_bytes, | |
| 174 | + | ).await { | |
| 175 | + | tracing::error!(error = ?e, app_id = %sync_user.app_id, "failed to bump bytes_stored"); | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | Ok(StatusCode::NO_CONTENT.into_response()) | |
| 153 | 180 | } | |
| 154 | 181 | ||
| 155 | 182 | /// Request a pre-signed S3 download URL for a blob by hash. | |
| @@ -163,7 +190,7 @@ | |||
| 163 | 190 | State(state): State<AppState>, | |
| 164 | 191 | sync_user: SyncUser, | |
| 165 | 192 | Json(req): Json<BlobDownloadUrlRequest>, | |
| 166 | - | ) -> Result<impl IntoResponse> { | |
| 193 | + | ) -> Result<Response> { | |
| 167 | 194 | let synckit_s3 = state | |
| 168 | 195 | .synckit_s3 | |
| 169 | 196 | .as_ref() | |
| @@ -180,6 +207,42 @@ | |||
| 180 | 207 | .await? | |
| 181 | 208 | .ok_or(AppError::NotFound)?; | |
| 182 | 209 | ||
| 210 | + | // Billing + egress cap enforcement (internal apps bypass). | |
| 211 | + | let billing = synckit_billing::get_app_with_billing(&state.db, sync_user.app_id) | |
| 212 | + | .await? | |
| 213 | + | .ok_or(AppError::NotFound)?; | |
| 214 | + | if !billing.is_internal { | |
| 215 | + | if billing.billing_status != "active" { | |
| 216 | + | return Ok(( | |
| 217 | + | StatusCode::PAYMENT_REQUIRED, | |
| 218 | + | Json(json!({ "reason": "billing_inactive" })), | |
| 219 | + | ) | |
| 220 | + | .into_response()); | |
| 221 | + | } | |
| 222 | + | if let Some(exceeded) = synckit_billing::would_exceed_egress( | |
| 223 | + | &state.db, sync_user.app_id, blob.size_bytes, | |
| 224 | + | ).await? { | |
| 225 | + | return Ok(( | |
| 226 | + | StatusCode::PAYMENT_REQUIRED, | |
| 227 | + | Json(json!({ | |
| 228 | + | "reason": "egress_limit_reached", | |
| 229 | + | "used": exceeded.used, | |
| 230 | + | "limit": exceeded.limit, | |
| 231 | + | })), | |
| 232 | + | ) | |
| 233 | + | .into_response()); | |
| 234 | + | } | |
| 235 | + | // Count egress optimistically at presign time. The client may not | |
| 236 | + | // actually download (especially if a retry hits dedup-cached content), | |
| 237 | + | // so this overcounts slightly. Trade-off: simpler than streaming | |
| 238 | + | // bytes-out from S3 access logs, and conservative on the cap side. | |
| 239 | + | if let Err(e) = synckit_billing::add_bytes_egress( | |
| 240 | + | &state.db, sync_user.app_id, blob.size_bytes, | |
| 241 | + | ).await { | |
| 242 | + | tracing::error!(error = ?e, app_id = %sync_user.app_id, "failed to bump bytes_egress_period"); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 183 | 246 | let download_url = synckit_s3 | |
| 184 | 247 | .presign_download( | |
| 185 | 248 | &blob.s3_key, | |
| @@ -188,5 +251,12 @@ | |||
| 188 | 251 | .await | |
| 189 | 252 | .context("presign download for sync blob")?; | |
| 190 | 253 | ||
| 191 | - | Ok(Json(BlobDownloadUrlResponse { download_url })) | |
| 254 | + | Ok(Json(BlobDownloadUrlResponse { download_url }).into_response()) | |
| 192 | 255 | } | |
| 256 | + | ||
| 257 | + | // NOTE: No active blob-delete API path was found in v1. Storage counters can | |
| 258 | + | // only grow (until period rollover or manual reset via reset_period_usage, | |
| 259 | + | // which only resets egress, not storage). The weekly drift correction job in | |
| 260 | + | // `db::synckit_billing::recalculate_synckit_app_storage` reconciles | |
| 261 | + | // `bytes_stored` against `sync_blobs` and is the only way storage can shrink | |
| 262 | + | // without DB intervention. Known limitation; revisit when blob deletion ships. |
| @@ -16,9 +16,10 @@ | |||
| 16 | 16 | ||
| 17 | 17 | pub(crate) mod apps; | |
| 18 | 18 | pub(crate) mod auth; | |
| 19 | + | pub(crate) mod billing; | |
| 19 | 20 | pub(crate) mod blobs; | |
| 21 | + | pub(crate) mod keys; | |
| 20 | 22 | mod subscribe; | |
| 21 | - | mod subscription; | |
| 22 | 23 | pub(crate) mod sync; | |
| 23 | 24 | ||
| 24 | 25 | use axum::{ | |
| @@ -95,10 +96,10 @@ | |||
| 95 | 96 | #[schema(value_type = String)] | |
| 96 | 97 | pub device_id: SyncDeviceId, | |
| 97 | 98 | pub cursor: i64, | |
| 98 | - | /// Optional table name filter — only return entries for these tables. | |
| 99 | + | /// Optional table name filter; only return entries for these tables. | |
| 99 | 100 | #[serde(default)] | |
| 100 | 101 | pub tables: Option<Vec<String>>, | |
| 101 | - | /// Optional timestamp filter — only return entries at or after this time. | |
| 102 | + | /// Optional timestamp filter; only return entries at or after this time. | |
| 102 | 103 | #[serde(default)] | |
| 103 | 104 | #[schema(value_type = Option<String>)] | |
| 104 | 105 | pub since: Option<DateTime<Utc>>, | |
| @@ -295,73 +296,124 @@ | |||
| 295 | 296 | download_url: String, | |
| 296 | 297 | } | |
| 297 | 298 | ||
| 299 | + | // ── Developer billing types ── | |
| 300 | + | ||
| 301 | + | /// Request body for `POST /api/sync/apps/{id}/billing/activate`. Knob shape | |
| 302 | + | /// matches the columns added in migration 117. `key_cap` is required iff | |
| 303 | + | /// `enforcement_mode = "per_key"`. | |
| 304 | + | #[derive(Deserialize)] | |
| 305 | + | pub(crate) struct BillingActivateRequest { | |
| 306 | + | pub storage_gb_cap: u32, | |
| 307 | + | pub egress_multiple: f64, | |
| 308 | + | pub enforcement_mode: String, | |
| 309 | + | pub key_cap: Option<u32>, | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// Request body for `PATCH /api/sync/apps/{id}/billing`; same shape as | |
| 313 | + | /// activate. (Reused via alias for clarity at call sites.) | |
| 314 | + | pub(crate) type BillingPatchRequest = BillingActivateRequest; | |
| 315 | + | ||
| 316 | + | /// Response from `POST /api/sync/apps/{id}/billing/setup`. | |
| 317 | + | #[derive(Serialize)] | |
| 318 | + | pub(crate) struct BillingSetupResponse { | |
| 319 | + | pub stripe_customer_id: String, | |
| 320 | + | pub billing_portal_url: String, | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | /// Response from `POST /api/sync/apps/{id}/billing/activate` and | |
| 324 | + | /// `PATCH /api/sync/apps/{id}/billing`. | |
| 325 | + | #[derive(Serialize)] | |
| 326 | + | pub(crate) struct BillingUpdatedResponse { | |
| 327 | + | pub monthly_price_cents: i64, | |
| 328 | + | pub billing_status: String, | |
| 329 | + | pub stripe_subscription_id: Option<String>, | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | /// Response from `GET /api/sync/apps/{id}/billing`. | |
| 333 | + | #[derive(Serialize)] | |
| 334 | + | pub(crate) struct BillingStatusResponse { | |
| 335 | + | pub app_id: SyncAppId, | |
| 336 | + | pub billing_status: String, | |
| 337 | + | pub is_internal: bool, | |
| 338 | + | pub storage_gb_cap: Option<u32>, | |
| 339 | + | pub egress_multiple: Option<f64>, | |
| 340 | + | pub enforcement_mode: String, | |
| 341 | + | pub key_cap: Option<u32>, | |
| 342 | + | pub bytes_stored: i64, | |
| 343 | + | pub bytes_egress_period: i64, | |
| 344 | + | pub keys_claimed: u32, | |
| 345 | + | pub last_warning_pct: u8, | |
| 346 | + | pub current_period_start: Option<DateTime<Utc>>, | |
| 347 | + | pub current_period_end: Option<DateTime<Utc>>, | |
| 348 | + | /// Monthly price as computed by `synckit_billing::monthly_price_cents`. | |
| 349 | + | /// `None` while in draft (knobs not yet set). | |
| 350 | + | pub monthly_price_cents: Option<i64>, | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // ── Key claim types ── | |
| 354 | + | ||
| 355 | + | /// Request body for `POST /api/sync/keys/claim`. Server-to-server: developer's | |
| 356 | + | /// backend sends the SyncKit app's `api_key` alongside the SDK key being | |
| 357 | + | /// claimed. | |
| 358 | + | #[derive(Deserialize)] | |
| 359 | + | pub(crate) struct ClaimKeyRequest { | |
| 360 | + | pub api_key: String, | |
| 361 | + | pub key: String, | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /// Response body for `POST /api/sync/keys/claim`. | |
| 365 | + | #[derive(Serialize)] | |
| 366 | + | pub(crate) struct ClaimKeyResponse { | |
| 367 | + | pub newly_claimed: bool, | |
| 368 | + | pub total_claimed: i32, | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// Request body for `POST /api/sync/keys/release`. | |
| 372 | + | #[derive(Deserialize)] | |
| 373 | + | pub(crate) struct ReleaseKeyRequest { | |
| 374 | + | pub api_key: String, | |
| 375 | + | pub key: String, | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// Response body for `POST /api/sync/keys/release`. | |
| 379 | + | #[derive(Serialize)] | |
| 380 | + | pub(crate) struct ReleaseKeyResponse { | |
| 381 | + | pub newly_released: bool, | |
| 382 | + | pub total_claimed: i32, | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | /// Request body for `POST /api/sync/keys/list`. POST + body (not GET + query) | |
| 386 | + | /// to keep the api_key out of access logs. | |
| 387 | + | #[derive(Deserialize)] | |
| 388 | + | pub(crate) struct ListKeysRequest { | |
| 389 | + | pub api_key: String, | |
| 390 | + | pub limit: Option<u32>, | |
| 391 | + | pub offset: Option<u32>, | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | /// One row in the active-key list returned by `POST /api/sync/keys/list`. | |
| 395 | + | #[derive(Serialize)] | |
| 396 | + | pub(crate) struct KeyInfo { | |
| 397 | + | pub id: uuid::Uuid, | |
| 398 | + | pub key: String, | |
| 399 | + | pub claimed_at: DateTime<Utc>, | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | /// Response body for `POST /api/sync/keys/list`. | |
| 403 | + | #[derive(Serialize)] | |
| 404 | + | pub(crate) struct ListKeysResponse { | |
| 405 | + | pub keys: Vec<KeyInfo>, | |
| 406 | + | } | |
| 407 | + | ||
| 298 | 408 | /// Response for create/regenerate that includes the plaintext API key (shown only once). | |
| 299 | 409 | #[derive(Serialize)] | |
| 300 | 410 | pub(super) struct AppWithKey { | |
| 301 | 411 | #[serde(flatten)] | |
| 302 | 412 | pub app: db::DbSyncApp, | |
| 303 | - | /// The plaintext API key. Only returned on create and regenerate — not stored. | |
| 413 | + | /// The plaintext API key. Only returned on create and regenerate; not stored. | |
| 304 | 414 | pub api_key: String, | |
| 305 | 415 | } | |
| 306 | 416 | ||
| 307 | - | // ── Subscription types ── | |
| 308 | - | ||
| 309 | - | #[derive(Deserialize)] | |
| 310 | - | pub(super) struct SubscriptionCheckoutRequest { | |
| 311 | - | /// Tier to subscribe to: "standard" for GO/BB, "light"/"standard"/"large" for AF | |
| 312 | - | pub tier: String, | |
| 313 | - | /// Billing interval: "monthly" or "annual" | |
| 314 | - | pub interval: String, | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | /// Individual tier info returned by the tiers endpoint. | |
| 318 | - | #[derive(Serialize)] | |
| 319 | - | pub(super) struct TierInfo { | |
| 320 | - | pub id: String, | |
| 321 | - | pub label: String, | |
| 322 | - | pub description: String, | |
| 323 | - | pub storage_bytes: Option<i64>, | |
| 324 | - | pub monthly_price_cents: i64, | |
| 325 | - | pub annual_price_cents: i64, | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | /// Response from the app tiers endpoint. | |
| 329 | - | #[derive(Serialize)] | |
| 330 | - | pub(super) struct AppTiersResponse { | |
| 331 | - | pub app_name: String, | |
| 332 | - | pub tiers: Vec<TierInfo>, | |
| 333 | - | } | |
| 334 | - | ||
| 335 | - | #[derive(Deserialize)] | |
| 336 | - | pub(super) struct SubscriptionChangeTierRequest { | |
| 337 | - | /// New tier: "light", "standard", or "large" | |
| 338 | - | pub tier: String, | |
| 339 | - | /// Billing interval: "monthly" or "annual" | |
| 340 | - | pub interval: String, | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | #[derive(Serialize)] | |
| 344 | - | pub(super) struct SubscriptionCheckoutResponse { | |
| 345 | - | /// Stripe Checkout URL to redirect the user to | |
| 346 | - | pub checkout_url: String, | |
| 347 | - | } | |
| 348 | - | ||
| 349 | - | #[derive(Serialize)] | |
| 350 | - | pub(super) struct SubscriptionStatusResponse { | |
| 351 | - | /// Whether the user has an active sync subscription for this app | |
| 352 | - | pub active: bool, | |
| 353 | - | /// Subscription tier (if active) | |
| 354 | - | pub tier: Option<String>, | |
| 355 | - | /// Subscription status string | |
| 356 | - | pub status: Option<String>, | |
| 357 | - | /// Blob storage limit in bytes (AF only) | |
| 358 | - | pub storage_limit_bytes: Option<i64>, | |
| 359 | - | /// Blob storage used in bytes | |
| 360 | - | pub storage_used_bytes: Option<i64>, | |
| 361 | - | /// End of current billing period | |
| 362 | - | pub current_period_end: Option<DateTime<Utc>>, | |
| 363 | - | } | |
| 364 | - | ||
| 365 | 417 | // ── Helper ── | |
| 366 | 418 | ||
| 367 | 419 | pub(super) fn generate_api_key() -> String { | |
| @@ -394,8 +446,13 @@ | |||
| 394 | 446 | .route("/api/v1/sync/auth", post(auth::sync_auth)) | |
| 395 | 447 | .route("/api/sync/validate-app", post(auth::validate_app)) | |
| 396 | 448 | .route("/api/v1/sync/validate-app", post(auth::validate_app)) | |
| 397 | - | .route("/api/sync/app/tiers", post(subscription::get_app_tiers)) | |
| 398 | - | .route("/api/v1/sync/app/tiers", post(subscription::get_app_tiers)) | |
| 449 | + | // Server-to-server SDK key claim/release/list (api_key in body, no JWT). | |
| 450 | + | .route("/api/sync/keys/claim", post(keys::claim)) | |
| 451 | + | .route("/api/v1/sync/keys/claim", post(keys::claim)) | |
| 452 | + | .route("/api/sync/keys/release", post(keys::release)) | |
| 453 | + | .route("/api/v1/sync/keys/release", post(keys::release)) | |
| 454 | + | .route("/api/sync/keys/list", post(keys::list)) | |
| 455 | + | .route("/api/v1/sync/keys/list", post(keys::list)) | |
| 399 | 456 | .route_layer(GovernorLayer { | |
| 400 | 457 | config: auth_rate_limit, | |
| 401 | 458 | }); | |
| @@ -440,12 +497,6 @@ | |||
| 440 | 497 | .route("/api/v1/sync/blobs/confirm", post(blobs::blob_confirm_upload)) | |
| 441 | 498 | .route("/api/sync/blobs/download", post(blobs::blob_download_url)) | |
| 442 | 499 | .route("/api/v1/sync/blobs/download", post(blobs::blob_download_url)) | |
| 443 | - | .route("/api/sync/subscription", get(subscription::get_subscription_status)) | |
| 444 | - | .route("/api/v1/sync/subscription", get(subscription::get_subscription_status)) | |
| 445 | - | .route("/api/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 446 | - | .route("/api/v1/sync/subscription/checkout", post(subscription::create_checkout)) | |
| 447 | - | .route("/api/sync/subscription/change", post(subscription::change_tier)) | |
| 448 | - | .route("/api/v1/sync/subscription/change", post(subscription::change_tier)) | |
| 449 | 500 | // Per-app rate limit (inner layer runs first): prevents one developer's | |
| 450 | 501 | // app from starving other apps. Extracts app ID from JWT payload. | |
| 451 | 502 | .route_layer(GovernorLayer { | |
| @@ -470,7 +521,20 @@ | |||
| 470 | 521 | .route("/api/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 471 | 522 | .route("/api/v1/sync/apps/{id}/slug", put(apps::update_app_slug)) | |
| 472 | 523 | .route("/api/sync/apps/{id}", delete(apps::delete_app)) | |
| 473 | - | .route("/api/v1/sync/apps/{id}", delete(apps::delete_app)); | |
| 524 | + | .route("/api/v1/sync/apps/{id}", delete(apps::delete_app)) | |
| 525 | + | // Developer billing (session auth, dashboard-driven). | |
| 526 | + | .route("/api/sync/apps/{id}/billing/setup", post(billing::setup)) | |
| 527 | + | .route("/api/v1/sync/apps/{id}/billing/setup", post(billing::setup)) | |
| 528 | + | .route("/api/sync/apps/{id}/billing/activate", post(billing::activate)) | |
| 529 | + | .route("/api/v1/sync/apps/{id}/billing/activate", post(billing::activate)) | |
| 530 | + | .route("/api/sync/apps/{id}/billing", axum::routing::patch(billing::patch)) | |
| 531 | + | .route("/api/v1/sync/apps/{id}/billing", axum::routing::patch(billing::patch)) | |
| 532 | + | .route("/api/sync/apps/{id}/billing", delete(billing::cancel)) | |
| 533 | + | .route("/api/v1/sync/apps/{id}/billing", delete(billing::cancel)) | |
| 534 | + | .route("/api/sync/apps/{id}/billing", get(billing::get)) | |
| 535 | + | .route("/api/v1/sync/apps/{id}/billing", get(billing::get)) | |
| 536 | + | .route("/api/sync/apps/{id}/billing/portal", get(billing::portal)) | |
| 537 | + | .route("/api/v1/sync/apps/{id}/billing/portal", get(billing::portal)); | |
| 474 | 538 | ||
| 475 | 539 | auth_routes.merge(sync_routes).merge(app_routes) | |
| 476 | 540 | } |