max / makenotwork
7 files changed,
+112 insertions,
-17 deletions
| @@ -347,6 +347,21 @@ During rotation, clients may receive a mix of old-key and new-key entries. Be pr | |||
| 347 | 347 | ||
| 348 | 348 | Configured per app. Apps linked to a free item (or no item) have unrestricted sync access. Apps linked to a paid item or membership tier require an active purchase or membership. If the membership lapses, push/pull return `403`. Device registration and key storage remain accessible. | |
| 349 | 349 | ||
| 350 | + | ## Billing & Pricing | |
| 351 | + | ||
| 352 | + | SyncKit bills the developer, not your end users. You buy a storage budget for the whole app and allocate it however you like; how you charge your own users (flat, freemium, usage-based, nothing) is your concern. | |
| 353 | + | ||
| 354 | + | One rate: **$0.03 per GB per month**. Ingress and egress are included (absorbed in the rate's margin). API requests are unmetered. Every invoice is floored at **$0.31/month** — the smallest charge that clears Stripe's per-transaction fee. | |
| 355 | + | ||
| 356 | + | Billing is configured from the app's dashboard panel, in one of two modes: | |
| 357 | + | ||
| 358 | + | - **Bulk** — set one knob, `storage_gb_cap` (total GB). Price = `storage_gb_cap × $0.03`. At the cap, **uploads are refused** (`402`, `reason: "storage_limit_reached"`); existing data still reads. | |
| 359 | + | - **Per-key** — set `key_cap` (max active SDK keys) and `gb_per_key` (allotment per key). Price = `key_cap × gb_per_key × $0.03`. A `claim_key` past the cap returns `402 key_limit_reached`; a key over its allotment returns `402 storage_limit_reached` with `dimension: "storage_per_key"`. | |
| 360 | + | ||
| 361 | + | Caps are bounded: each mode's priced total must be between 1 GB and 10 TB (10240 GB). The bill is fixed for the period — there are no overages. Increasing a cap takes effect immediately; decreasing it applies at the next billing cycle. Cancellation takes effect at period end, and a standard export is available at any time. | |
| 362 | + | ||
| 363 | + | First-party apps use a separate end-user subscription model and are not configured here. | |
| 364 | + | ||
| 350 | 365 | ## See Also | |
| 351 | 366 | ||
| 352 | 367 | - [OTA Updates](./ota.md): auto-update your app through SyncKit |
| @@ -367,13 +367,17 @@ fn validate_knobs( | |||
| 367 | 367 | key_cap: Option<u32>, | |
| 368 | 368 | gb_per_key: Option<u32>, | |
| 369 | 369 | ) -> Result<()> { | |
| 370 | + | // Upper bound on the priced storage so a developer can't provision an | |
| 371 | + | // absurd Stripe subscription. Bulk: `storage_gb_cap`. Per-key: the | |
| 372 | + | // `key_cap × gb_per_key` product (the value the price is computed from). | |
| 373 | + | let max = crate::synckit_billing::MAX_STORAGE_GB; | |
| 370 | 374 | match enforcement_mode { | |
| 371 | 375 | "bulk" => { | |
| 372 | 376 | match storage_gb_cap { | |
| 373 | - | Some(v) if v > 0 => {} | |
| 374 | - | _ => return Err(AppError::BadRequest( | |
| 375 | - | "storage_gb_cap (> 0) is required when enforcement_mode = bulk".to_string(), | |
| 376 | - | )), | |
| 377 | + | Some(v) if v > 0 && i64::from(v) <= max => {} | |
| 378 | + | _ => return Err(AppError::BadRequest(format!( | |
| 379 | + | "storage_gb_cap must be between 1 and {max} GB for enforcement_mode = bulk" | |
| 380 | + | ))), | |
| 377 | 381 | } | |
| 378 | 382 | if key_cap.is_some() || gb_per_key.is_some() { | |
| 379 | 383 | return Err(AppError::BadRequest( | |
| @@ -382,17 +386,23 @@ fn validate_knobs( | |||
| 382 | 386 | } | |
| 383 | 387 | } | |
| 384 | 388 | "per_key" => { | |
| 385 | - | match key_cap { | |
| 386 | - | Some(v) if v > 0 => {} | |
| 389 | + | let k = match key_cap { | |
| 390 | + | Some(v) if v > 0 => v, | |
| 387 | 391 | _ => return Err(AppError::BadRequest( | |
| 388 | 392 | "key_cap (> 0) is required when enforcement_mode = per_key".to_string(), | |
| 389 | 393 | )), | |
| 390 | - | } | |
| 391 | - | match gb_per_key { | |
| 392 | - | Some(v) if v > 0 => {} | |
| 394 | + | }; | |
| 395 | + | let g = match gb_per_key { | |
| 396 | + | Some(v) if v > 0 => v, | |
| 393 | 397 | _ => return Err(AppError::BadRequest( | |
| 394 | 398 | "gb_per_key (> 0) is required when enforcement_mode = per_key".to_string(), | |
| 395 | 399 | )), | |
| 400 | + | }; | |
| 401 | + | // u64 to avoid overflow before the bound check. | |
| 402 | + | if u64::from(k) * u64::from(g) > max as u64 { | |
| 403 | + | return Err(AppError::BadRequest(format!( | |
| 404 | + | "key_cap × gb_per_key must not exceed {max} GB total" | |
| 405 | + | ))); | |
| 396 | 406 | } | |
| 397 | 407 | if storage_gb_cap.is_some() { | |
| 398 | 408 | return Err(AppError::BadRequest( | |
| @@ -408,3 +418,30 @@ fn validate_knobs( | |||
| 408 | 418 | } | |
| 409 | 419 | Ok(()) | |
| 410 | 420 | } | |
| 421 | + | ||
| 422 | + | #[cfg(test)] | |
| 423 | + | mod tests { | |
| 424 | + | use super::validate_knobs; | |
| 425 | + | use crate::synckit_billing::MAX_STORAGE_GB; | |
| 426 | + | ||
| 427 | + | #[test] | |
| 428 | + | fn bulk_cap_is_bounded() { | |
| 429 | + | assert!(validate_knobs("bulk", Some(100), None, None).is_ok()); | |
| 430 | + | assert!(validate_knobs("bulk", Some(MAX_STORAGE_GB as u32), None, None).is_ok()); | |
| 431 | + | assert!(validate_knobs("bulk", Some(0), None, None).is_err()); | |
| 432 | + | // One past the ceiling, and the absurd value the report flagged. | |
| 433 | + | assert!(validate_knobs("bulk", Some(MAX_STORAGE_GB as u32 + 1), None, None).is_err()); | |
| 434 | + | assert!(validate_knobs("bulk", Some(u32::MAX), None, None).is_err()); | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn per_key_product_is_bounded() { | |
| 439 | + | assert!(validate_knobs("per_key", None, Some(100), Some(1)).is_ok()); | |
| 440 | + | // key_cap × gb_per_key exactly at the ceiling. | |
| 441 | + | assert!(validate_knobs("per_key", None, Some(MAX_STORAGE_GB as u32), Some(1)).is_ok()); | |
| 442 | + | // Product over the ceiling, including the u32-overflow case. | |
| 443 | + | assert!(validate_knobs("per_key", None, Some(MAX_STORAGE_GB as u32), Some(2)).is_err()); | |
| 444 | + | assert!(validate_knobs("per_key", None, Some(u32::MAX), Some(u32::MAX)).is_err()); | |
| 445 | + | assert!(validate_knobs("per_key", None, Some(0), Some(1)).is_err()); | |
| 446 | + | } | |
| 447 | + | } |
| @@ -17,6 +17,13 @@ | |||
| 17 | 17 | /// ingress/egress cost variance, so we don't need a separate egress price. | |
| 18 | 18 | pub const STORAGE_RATE_CENTS_PER_GB: i64 = 3; | |
| 19 | 19 | ||
| 20 | + | /// Upper bound on a developer-billing storage cap, in GB. Applies to the bulk | |
| 21 | + | /// `storage_gb_cap` and to the `key_cap × gb_per_key` product in per_key mode. | |
| 22 | + | /// 10 TiB — matches the end-user `MAX_CAP_BYTES` and the picker slider's `max`. | |
| 23 | + | /// Without it, `validate_knobs` accepted any value `> 0`, so a developer could | |
| 24 | + | /// provision a Stripe subscription priced in the billions per month. | |
| 25 | + | pub const MAX_STORAGE_GB: i64 = 10 * 1024; | |
| 26 | + | ||
| 20 | 27 | /// Stripe-fee-cover floor in cents. Stripe charges 2.9% + $0.30 per | |
| 21 | 28 | /// successful charge. We pick the smallest invoice `F` (cents) such that the | |
| 22 | 29 | /// remainder after Stripe fees is non-negative: |
| @@ -673,6 +673,21 @@ impl super::dashboard::SyncAppBillingView { | |||
| 673 | 673 | default_storage_gb: b.storage_gb_cap.unwrap_or(10), | |
| 674 | 674 | default_key_cap: b.key_cap.unwrap_or(100), | |
| 675 | 675 | default_gb_per_key: b.gb_per_key.unwrap_or(1), | |
| 676 | + | // Pricing constants, single-sourced from `synckit_billing` so the | |
| 677 | + | // picker can't drift from the server's price formula. | |
| 678 | + | storage_rate_cents: crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB, | |
| 679 | + | base_floor_cents: crate::synckit_billing::BASE_FLOOR_CENTS, | |
| 680 | + | max_storage_gb: crate::synckit_billing::MAX_STORAGE_GB as i32, | |
| 681 | + | rate_per_gb_display: format!( | |
| 682 | + | "${}.{:02}", | |
| 683 | + | crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB / 100, | |
| 684 | + | crate::synckit_billing::STORAGE_RATE_CENTS_PER_GB % 100 | |
| 685 | + | ), | |
| 686 | + | floor_display: format!( | |
| 687 | + | "${}.{:02}", | |
| 688 | + | crate::synckit_billing::BASE_FLOOR_CENTS / 100, | |
| 689 | + | crate::synckit_billing::BASE_FLOOR_CENTS % 100 | |
| 690 | + | ), | |
| 676 | 691 | // Populated by the dashboard route for `per_key` mode after this | |
| 677 | 692 | // call; default is empty so `from_db` stays usable in contexts | |
| 678 | 693 | // that don't need the breakdown (API responses, tests). |
| @@ -143,6 +143,16 @@ pub struct SyncAppBillingView { | |||
| 143 | 143 | pub default_storage_gb: i32, | |
| 144 | 144 | pub default_key_cap: i32, | |
| 145 | 145 | pub default_gb_per_key: i32, | |
| 146 | + | /// Pricing constants rendered into the picker so the client never hardcodes | |
| 147 | + | /// them (single source of truth = `crate::synckit_billing`). `data-*` attrs | |
| 148 | + | /// drive the JS price preview; the display strings drive the human hint. | |
| 149 | + | pub storage_rate_cents: i64, | |
| 150 | + | pub base_floor_cents: i64, | |
| 151 | + | pub max_storage_gb: i32, | |
| 152 | + | /// e.g. "$0.03" — per-GB rate for the hint text. | |
| 153 | + | pub rate_per_gb_display: String, | |
| 154 | + | /// e.g. "$0.31" — invoice floor for the hint text. | |
| 155 | + | pub floor_display: String, | |
| 146 | 156 | /// Top per-key gauges, shown in `per_key` mode under the app-aggregate | |
| 147 | 157 | /// storage gauge. Empty for `bulk` mode and for per_key apps that have | |
| 148 | 158 | /// not yet recorded any storage. Pre-sorted descending by usage; capped |
| @@ -111,6 +111,14 @@ pub fn validate_sync_app_name(name: &str) -> Result<(), AppError> { | |||
| 111 | 111 | limits::SYNC_APP_NAME_MAX | |
| 112 | 112 | ))); | |
| 113 | 113 | } | |
| 114 | + | // Reject control characters (NUL, newlines, bidi/RTL overrides) so the one | |
| 115 | + | // user-controlled string with no other content rules can't carry a payload | |
| 116 | + | // into any future non-escaping sink. Mirrors `validate_synckit_key`. | |
| 117 | + | if name.chars().any(|c| c.is_control()) { | |
| 118 | + | return Err(AppError::validation( | |
| 119 | + | "App name must not contain control characters".to_string(), | |
| 120 | + | )); | |
| 121 | + | } | |
| 114 | 122 | Ok(()) | |
| 115 | 123 | } | |
| 116 | 124 | ||
| @@ -216,6 +224,9 @@ mod tests { | |||
| 216 | 224 | assert!(validate_sync_app_name("").is_err()); // empty | |
| 217 | 225 | assert!(validate_sync_app_name(&"a".repeat(100)).is_ok()); // at limit | |
| 218 | 226 | assert!(validate_sync_app_name(&"a".repeat(101)).is_err()); // over limit | |
| 227 | + | assert!(validate_sync_app_name("bad\nname").is_err()); // newline (control) | |
| 228 | + | assert!(validate_sync_app_name("bad\0name").is_err()); // NUL | |
| 229 | + | assert!(validate_sync_app_name("ok name 123").is_ok()); // spaces/digits fine | |
| 219 | 230 | } | |
| 220 | 231 | ||
| 221 | 232 | #[test] |
| @@ -19,8 +19,8 @@ | |||
| 19 | 19 | data-storage-gb="{{ b.default_storage_gb }}" | |
| 20 | 20 | data-key-cap="{{ b.default_key_cap }}" | |
| 21 | 21 | data-gb-per-key="{{ b.default_gb_per_key }}" | |
| 22 | - | data-storage-rate="3" | |
| 23 | - | data-base-floor-cents="31"> | |
| 22 | + | data-storage-rate="{{ b.storage_rate_cents }}" | |
| 23 | + | data-base-floor-cents="{{ b.base_floor_cents }}"> | |
| 24 | 24 | <summary class="synckit-billing-summary"> | |
| 25 | 25 | <span class="synckit-billing-status synckit-billing-status--{{ b.status }}"> | |
| 26 | 26 | {% if b.is_internal %}Internal{% else if b.status == "draft" %}Draft{% else if b.status == "active" %}Active{% else if b.status == "suspended_unpaid" %}Past due{% else if b.status == "canceled" %}Canceled{% else %}{{ b.status }}{% endif %} | |
| @@ -67,10 +67,10 @@ | |||
| 67 | 67 | {% if b.enforcement_mode != "bulk" %}hidden{% endif %}> | |
| 68 | 68 | <label for="synckit-storage-{{ app.id }}">Storage</label> | |
| 69 | 69 | <input type="range" id="synckit-storage-{{ app.id }}" | |
| 70 | - | class="synckit-storage-slider" min="1" max="10240" step="1" | |
| 70 | + | class="synckit-storage-slider" min="1" max="{{ b.max_storage_gb }}" step="1" | |
| 71 | 71 | value="{{ b.default_storage_gb }}"> | |
| 72 | 72 | <input type="number" class="synckit-storage-input input--sm w-100" | |
| 73 | - | min="1" step="1" value="{{ b.default_storage_gb }}"> | |
| 73 | + | min="1" max="{{ b.max_storage_gb }}" step="1" value="{{ b.default_storage_gb }}"> | |
| 74 | 74 | <span class="form-hint">GB total</span> | |
| 75 | 75 | </div> | |
| 76 | 76 | ||
| @@ -78,7 +78,7 @@ | |||
| 78 | 78 | {% if b.enforcement_mode != "per_key" %}hidden{% endif %}> | |
| 79 | 79 | <label>Key cap</label> | |
| 80 | 80 | <input type="number" class="synckit-key-cap-input input--sm w-100" | |
| 81 | - | min="1" step="1" value="{{ b.default_key_cap }}"> | |
| 81 | + | min="1" max="{{ b.max_storage_gb }}" step="1" value="{{ b.default_key_cap }}"> | |
| 82 | 82 | <span class="form-hint">max active keys</span> | |
| 83 | 83 | </div> | |
| 84 | 84 | ||
| @@ -86,13 +86,13 @@ | |||
| 86 | 86 | {% if b.enforcement_mode != "per_key" %}hidden{% endif %}> | |
| 87 | 87 | <label>GB per key</label> | |
| 88 | 88 | <input type="number" class="synckit-gb-per-key-input input--sm w-100" | |
| 89 | - | min="1" step="1" value="{{ b.default_gb_per_key }}"> | |
| 89 | + | min="1" max="{{ b.max_storage_gb }}" step="1" value="{{ b.default_gb_per_key }}"> | |
| 90 | 90 | <span class="form-hint">storage allotment per key</span> | |
| 91 | 91 | </div> | |
| 92 | 92 | ||
| 93 | 93 | <div class="synckit-billing-summary-line"> | |
| 94 | - | <span class="synckit-price-preview">$0.31</span> | |
| 95 | - | <span class="form-hint">/ month — $0.03/GB, floored at $0.31 (Stripe fee)</span> | |
| 94 | + | <span class="synckit-price-preview">{{ b.floor_display }}</span> | |
| 95 | + | <span class="form-hint">/ month — {{ b.rate_per_gb_display }}/GB, floored at {{ b.floor_display }} (Stripe fee)</span> | |
| 96 | 96 | </div> | |
| 97 | 97 | ||
| 98 | 98 | <div class="synckit-billing-actions"> |