Skip to main content

max / makenotwork

synckit: bound developer billing caps + single-source the pricing picker (ultra-fuzz Run 3 UX #6) Phase 4 of the SyncKit ultra-fuzz remediation (UX axis B+ -> A). #6 MEDIUM — validate_knobs only checked `> 0`, so a developer could set storage_gb_cap (or key_cap × gb_per_key) to billions of GB and provision a Stripe subscription priced in the billions per month. New MAX_STORAGE_GB (10 TiB, matching the end-user MAX_CAP_BYTES and the slider) bounds both the bulk cap and the per_key product (computed in u64 to avoid overflow). The picker's number inputs gain matching `max=` attrs. UX cold spots closed to earn the A: - Pricing constants were triplicated (template data-* + hint string vs the Rust STORAGE_RATE_CENTS_PER_GB/BASE_FLOOR_CENTS). The picker now renders the rate, floor, and max from the view, single-sourced from synckit_billing — the JS already reads the data-* attrs, so the price preview can't drift from the server formula. - validate_sync_app_name rejects control characters (NUL/newlines/bidi), so the one unrestricted user string can't carry a payload into a future sink. - Added a Billing & Pricing section to the developer docs (modes, $0.03/GB rate, $0.31 floor, 1 GB-10 TB bounds, at-limit behavior) — previously dashboard-only. Tests: validate_knobs bounds (bulk + per_key product incl. u32 overflow), app-name control-char rejection. 50/50 synckit + lib unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 17:55 UTC
Commit: b07943d45f13bba61ac7e5d501a4287051b65e24
Parent: 0660bce
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">