Skip to main content

max / makenotwork

Seal pricing-format/slug drift and add Cloudflare quarantine cache-purge Phase A structural seals for the recurring drift chronic: - route every cents->dollars conversion through formatting:: helpers (new format_dollars_plain) and add a grep guard banning ad-hoc `as f64 / 100.0` - create_project auto-suffixes via insert_with_unique_slug; create_collection maps the slug unique-violation to a clean 400 for all callers, killing the CLI raw 500 on collision - BoundedRecipients<T> enforces the broadcast cap in its constructor (grep guard confines the cap constant); both broadcast handlers route through it - refund-by-payment-intent/by-id and the git repo openers tightened to pub(crate) with scope docs Phase C: new cloudflare module edge-purges a quarantined object's CDN URL after origin deletion; no-op + warn until CF_API_TOKEN/CF_ZONE_ID are set. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 19:11 UTC
Commit: 14b7fbf2c163d6fe3e6f7d43ce455a9f72bd9ae1
Parent: b07943d
26 files changed, +483 insertions, -101 deletions
@@ -448,10 +448,8 @@ async fn cmd_revenue(pool: &PgPool) -> anyhow::Result<()> {
448 448 let (revenue_cents, completed, refunded) =
449 449 db::transactions::get_platform_revenue_stats(pool).await?;
450 450
451 - let dollars = revenue_cents as f64 / 100.0;
452 -
453 451 println!("Platform Revenue");
454 - println!(" Total revenue: ${:.2}", dollars);
452 + println!(" Total revenue: {}", makenotwork::formatting::format_revenue(revenue_cents));
455 453 println!(" Total sales: {}", completed);
456 454 println!(" Total refunds: {}", refunded);
457 455
@@ -499,9 +497,9 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<(
499 497 }
500 498
501 499 println!(
502 - "\n{} transaction(s), ${:.2} total revenue.",
500 + "\n{} transaction(s), {} total revenue.",
503 501 txs.len(),
504 - total_cents as f64 / 100.0
502 + makenotwork::formatting::format_revenue(total_cents)
505 503 );
506 504 Ok(())
507 505 }
@@ -0,0 +1,90 @@
1 + //! Cloudflare edge-cache purge client.
2 + //!
3 + //! Deleting a quarantined object from origin stops origin serving, but
4 + //! Cloudflare caches `cdn.makenot.work/{key}` immutably for up to a year, so an
5 + //! already-edge-cached malicious object keeps serving from the edge until its
6 + //! TTL lapses (ultra-fuzz CDN scan-serve residual). Purging the specific URL
7 + //! closes that window immediately.
8 + //!
9 + //! Optional by design: when `CF_API_TOKEN` / `CF_ZONE_ID` are not configured,
10 + //! [`CloudflarePurger::from_env`] returns `None` and every purge becomes a
11 + //! logged no-op — the malware-quarantine WAM ticket remains the manual
12 + //! fallback, exactly the posture before this wiring existed.
13 +
14 + use crate::helpers::HTTP_CLIENT;
15 +
16 + /// Cloudflare's documented per-request file cap for `purge_cache` by URL.
17 + const PURGE_FILES_PER_REQUEST: usize = 30;
18 +
19 + /// Holds the credentials needed to call the Cloudflare cache-purge API for one
20 + /// zone. Cheap to clone (two owned strings); intended to live in shared context.
21 + #[derive(Clone)]
22 + pub struct CloudflarePurger {
23 + zone_id: String,
24 + api_token: String,
25 + }
26 +
27 + impl std::fmt::Debug for CloudflarePurger {
28 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 + f.debug_struct("CloudflarePurger")
30 + .field("zone_id", &self.zone_id)
31 + .field("api_token", &"[REDACTED]")
32 + .finish()
33 + }
34 + }
35 +
36 + impl CloudflarePurger {
37 + /// Build from `CF_API_TOKEN` + `CF_ZONE_ID`. Returns `None` if either is
38 + /// unset or empty, in which case purges become logged no-ops.
39 + pub fn from_env() -> Option<Self> {
40 + let api_token = std::env::var("CF_API_TOKEN").ok().filter(|s| !s.is_empty())?;
41 + let zone_id = std::env::var("CF_ZONE_ID").ok().filter(|s| !s.is_empty())?;
42 + Some(Self { zone_id, api_token })
43 + }
44 +
45 + /// Purge specific absolute URLs from Cloudflare's edge cache.
46 + ///
47 + /// Fire-and-forget at the call site (callers should not block enforcement on
48 + /// it); failures are logged, not propagated — the object is already gone from
49 + /// origin, so a failed edge purge degrades to the prior TTL-bounded residual
50 + /// rather than a serving failure. Batches to Cloudflare's per-request cap.
51 + pub async fn purge_urls(&self, urls: Vec<String>) {
52 + for chunk in urls.chunks(PURGE_FILES_PER_REQUEST) {
53 + self.purge_chunk(chunk).await;
54 + }
55 + }
56 +
57 + async fn purge_chunk(&self, urls: &[String]) {
58 + if urls.is_empty() {
59 + return;
60 + }
61 + let endpoint = format!(
62 + "https://api.cloudflare.com/client/v4/zones/{}/purge_cache",
63 + self.zone_id
64 + );
65 + let body = serde_json::json!({ "files": urls });
66 + match HTTP_CLIENT
67 + .post(&endpoint)
68 + .bearer_auth(&self.api_token)
69 + .json(&body)
70 + .send()
71 + .await
72 + {
73 + Ok(resp) if resp.status().is_success() => {
74 + tracing::info!(count = urls.len(), "cloudflare edge-cache purge succeeded");
75 + }
76 + Ok(resp) => {
77 + let status = resp.status();
78 + let detail = resp.text().await.unwrap_or_default();
79 + tracing::error!(
80 + %status, detail = %detail, count = urls.len(),
81 + "cloudflare edge-cache purge returned an error; quarantined URL may stay edge-cached until TTL"
82 + );
83 + }
84 + Err(e) => tracing::error!(
85 + error = ?e, count = urls.len(),
86 + "cloudflare edge-cache purge request failed; quarantined URL may stay edge-cached until TTL"
87 + ),
88 + }
89 + }
90 + }
@@ -16,6 +16,12 @@ pub async fn create_collection(
16 16 description: Option<&str>,
17 17 is_public: bool,
18 18 ) -> Result<DbCollection> {
19 + // Collections intentionally REJECT a slug clash rather than auto-suffix
20 + // (unlike projects/items/sections): the slug is user-chosen and they should
21 + // be told it's taken. Map the per-user unique violation to a clean
22 + // validation error here so every caller — web and the CLI/service route —
23 + // gets the same 400 instead of a raw 500. This is the seal: no caller can
24 + // surface an unhandled 23505 from a collection insert.
19 25 let collection = sqlx::query_as::<_, DbCollection>(
20 26 r#"
21 27 INSERT INTO collections (user_id, slug, title, description, is_public)
@@ -29,7 +35,17 @@ pub async fn create_collection(
29 35 .bind(description)
30 36 .bind(is_public)
31 37 .fetch_one(pool)
32 - .await?;
38 + .await
39 + .map_err(|e| {
40 + let e = crate::error::AppError::from(e);
41 + if crate::helpers::is_unique_violation(&e) {
42 + crate::error::AppError::validation(
43 + "You already have a collection with this slug".to_string(),
44 + )
45 + } else {
46 + e
47 + }
48 + })?;
33 49
34 50 Ok(collection)
35 51 }
@@ -20,23 +20,32 @@ pub async fn create_project(
20 20 features: &[String],
21 21 ) -> Result<DbProject> {
22 22 let project_type = super::ProjectFeature::derive_project_type(features);
23 - let project = sqlx::query_as::<_, DbProject>(
24 - r#"
25 - INSERT INTO projects (user_id, slug, title, description, project_type, features)
26 - VALUES ($1, $2, $3, $4, $5, $6)
27 - RETURNING *
28 - "#,
29 - )
30 - .bind(user_id)
31 - .bind(slug)
32 - .bind(title)
33 - .bind(description)
34 - .bind(project_type)
35 - .bind(features)
36 - .fetch_one(pool)
37 - .await?;
38 -
39 - Ok(project)
23 + // Slug uniqueness is enforced by the per-table unique indexes — including the
24 + // cross-creator `idx_projects_public_slug` (migration 062). Route the bare
25 + // INSERT through `insert_with_unique_slug` so a collision auto-suffixes
26 + // (`slug`, `slug-2`, ...) and retries instead of surfacing a raw 500 (the
27 + // CHRONIC slug-dedup drift, ultra-fuzz Run 2 UX). This is the seal: there is
28 + // no public bare-insert constructor for projects.
29 + crate::helpers::insert_with_unique_slug(slug.as_str(), |candidate| async move {
30 + let candidate = Slug::from_trusted(candidate);
31 + sqlx::query_as::<_, DbProject>(
32 + r#"
33 + INSERT INTO projects (user_id, slug, title, description, project_type, features)
34 + VALUES ($1, $2, $3, $4, $5, $6)
35 + RETURNING *
36 + "#,
37 + )
38 + .bind(user_id)
39 + .bind(&candidate)
40 + .bind(title)
41 + .bind(description)
42 + .bind(project_type)
43 + .bind(features)
44 + .fetch_one(pool)
45 + .await
46 + .map_err(Into::into)
47 + })
48 + .await
40 49 }
41 50
42 51 /// Fetch a project by primary key. Returns `None` if not found.
@@ -835,8 +835,13 @@ pub async fn get_transaction_by_id(
835 835 ///
836 836 /// Returns ALL refunded transactions (handles cart checkouts where multiple
837 837 /// transactions share the same payment_intent_id).
838 + ///
839 + /// FULL-INTENT scope, and `pub(crate)` so only in-crate webhook handlers can
840 + /// mint it: a single cart line must use the line-scoped
841 + /// [`refund_transaction_by_id`] instead, never this PI-wide UPDATE (the Run #2
842 + /// Payments SERIOUS that refunded a whole cart from one line's event).
838 843 #[tracing::instrument(skip_all)]
839 - pub async fn refund_transaction_by_payment_intent<'e>(
844 + pub(crate) async fn refund_transaction_by_payment_intent<'e>(
840 845 executor: impl sqlx::PgExecutor<'e>,
841 846 payment_intent_id: &str,
842 847 ) -> Result<Vec<(super::TransactionId, Option<ItemId>)>> {
@@ -865,7 +870,7 @@ pub async fn refund_transaction_by_payment_intent<'e>(
865 870 /// payment_intent, so refunding one line must touch only its own row — never the
866 871 /// PI-wide [`refund_transaction_by_payment_intent`] (Run #2 Payments SERIOUS).
867 872 #[tracing::instrument(skip_all)]
868 - pub async fn refund_transaction_by_id<'e>(
873 + pub(crate) async fn refund_transaction_by_id<'e>(
869 874 executor: impl sqlx::PgExecutor<'e>,
870 875 id: TransactionId,
871 876 ) -> Result<Option<(super::TransactionId, Option<ItemId>)>> {
@@ -16,6 +16,48 @@ fn greeting(name: Option<&str>) -> String {
16 16 name.map(|n| format!(" {}", n)).unwrap_or_default()
17 17 }
18 18
19 + /// A recipient list proven to be within [`BROADCAST_MAX_RECIPIENTS`](crate::constants::BROADCAST_MAX_RECIPIENTS).
20 + ///
21 + /// Broadcasts must not materialize an unbounded recipient set in memory and
22 + /// fan out unthrottled email. The only way to obtain this type is
23 + /// [`BoundedRecipients::new`], which enforces the cap at construction — so a new
24 + /// broadcast site physically cannot skip the check. This replaces the
25 + /// copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` guards that had drifted
26 + /// between the public and internal broadcast handlers (the cap constant now
27 + /// lives only in this constructor; a grep guard below enforces that). On
28 + /// overflow `new` returns the actual recipient count so the caller can build a
29 + /// user-facing message and roll back any rate-limit slot it already consumed.
30 + #[derive(Debug)]
31 + pub struct BoundedRecipients<T>(Vec<T>);
32 +
33 + impl<T> BoundedRecipients<T> {
34 + /// Construct from a raw recipient list, enforcing the broadcast cap.
35 + /// Returns `Err(count)` with the actual recipient count on overflow.
36 + pub fn new(recipients: Vec<T>) -> std::result::Result<Self, usize> {
37 + let n = recipients.len();
38 + if n > crate::constants::BROADCAST_MAX_RECIPIENTS {
39 + Err(n)
40 + } else {
41 + Ok(Self(recipients))
42 + }
43 + }
44 +
45 + /// Number of recipients (always `<= BROADCAST_MAX_RECIPIENTS`).
46 + pub fn len(&self) -> usize {
47 + self.0.len()
48 + }
49 +
50 + /// Whether the recipient list is empty.
51 + pub fn is_empty(&self) -> bool {
52 + self.0.is_empty()
53 + }
54 +
55 + /// Consume into the inner recipient vector for fan-out iteration.
56 + pub fn into_inner(self) -> Vec<T> {
57 + self.0
58 + }
59 + }
60 +
19 61 /// Email service configuration
20 62 #[derive(Clone)]
21 63 pub struct EmailConfig {
@@ -338,3 +380,91 @@ impl EmailTransport for PostmarkTransport {
338 380 self.send_with_unsub_inner(to, subject, body, unsub_url, Some("broadcast")).await
339 381 }
340 382 }
383 +
384 + #[cfg(test)]
385 + mod bounded_recipients_tests {
386 + use super::*;
387 +
388 + #[test]
389 + fn accepts_under_cap() {
390 + let r = BoundedRecipients::new(vec![1, 2, 3]).expect("under cap");
391 + assert_eq!(r.len(), 3);
392 + assert!(!r.is_empty());
393 + assert_eq!(r.into_inner(), vec![1, 2, 3]);
394 + }
395 +
396 + #[test]
397 + fn accepts_exactly_at_cap() {
398 + let v = vec![0u8; crate::constants::BROADCAST_MAX_RECIPIENTS];
399 + assert!(BoundedRecipients::new(v).is_ok());
400 + }
401 +
402 + #[test]
403 + fn rejects_over_cap_with_count() {
404 + let n = crate::constants::BROADCAST_MAX_RECIPIENTS + 1;
405 + let v = vec![0u8; n];
406 + assert_eq!(BoundedRecipients::new(v).unwrap_err(), n);
407 + }
408 +
409 + #[test]
410 + fn empty_is_allowed() {
411 + let r = BoundedRecipients::<u8>::new(vec![]).expect("empty ok");
412 + assert!(r.is_empty());
413 + }
414 + }
415 +
416 + /// Seal for the broadcast recipient cap.
417 + ///
418 + /// The cap was a copy-pasted `if count > BROADCAST_MAX_RECIPIENTS` check that had
419 + /// drifted between the public and internal broadcast handlers. The fix routes
420 + /// both through [`BoundedRecipients`], so the constant must appear ONLY in its
421 + /// definition (`constants.rs`) and this module's constructor. This test fails
422 + /// the build if any other file references the constant — forcing a new broadcast
423 + /// site through the sealed constructor instead of re-inlining the check.
424 + #[cfg(test)]
425 + mod broadcast_cap_seal_guard {
426 + use std::path::Path;
427 +
428 + #[test]
429 + fn cap_constant_used_only_in_sealed_constructor() {
430 + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
431 + let allowed = [
432 + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/constants.rs"),
433 + Path::new(env!("CARGO_MANIFEST_DIR")).join("src/email/mod.rs"),
434 + ];
435 + let mut offenders = Vec::new();
436 + walk(&src_dir, &mut |path, contents| {
437 + if allowed.iter().any(|a| a == path) {
438 + return;
439 + }
440 + for (i, line) in contents.lines().enumerate() {
441 + if line.contains("BROADCAST_MAX_RECIPIENTS") {
442 + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
443 + }
444 + }
445 + });
446 + assert!(
447 + offenders.is_empty(),
448 + "broadcast-cap seal violated — enforce the recipient cap via \
449 + email::BoundedRecipients::new, never by referencing BROADCAST_MAX_RECIPIENTS \
450 + directly in a handler. Offending lines:\n{}",
451 + offenders.join("\n")
452 + );
453 + }
454 +
455 + fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
456 + let Ok(entries) = std::fs::read_dir(dir) else {
457 + return;
458 + };
459 + for entry in entries.flatten() {
460 + let path = entry.path();
461 + if path.is_dir() {
462 + walk(&path, f);
463 + } else if path.extension().is_some_and(|e| e == "rs")
464 + && let Ok(contents) = std::fs::read_to_string(&path)
465 + {
466 + f(&path, &contents);
467 + }
468 + }
469 + }
470 + }
@@ -46,6 +46,25 @@ pub fn format_revenue(cents: i64) -> String {
46 46 format!("{sign}${dollars}.{frac:02}")
47 47 }
48 48
49 + /// Format a price in cents as a plain decimal string: no currency symbol, no
50 + /// thousands separators, always two decimal places (e.g. "9.99", "1234.50").
51 + ///
52 + /// For CSV cells and form `value=` attributes where a bare numeric is required
53 + /// and the surrounding context (spreadsheet column, template `$` prefix)
54 + /// supplies its own framing. For human-facing display use [`format_price`]
55 + /// (shows "Free" / drops trailing `.00`) or [`format_revenue`] (always `$X.XX`).
56 + ///
57 + /// This is the canonical cents→decimal conversion: bypassing it with a raw
58 + /// `as f64 / 100.0` is what the pricing-format drift chronic keeps re-finding,
59 + /// so a grep-enforcing test (below) bans that idiom outside this module.
60 + pub fn format_dollars_plain(cents: impl Into<i64>) -> String {
61 + let cents: i64 = cents.into();
62 + let neg = cents < 0;
63 + let abs = cents.unsigned_abs();
64 + let sign = if neg { "-" } else { "" };
65 + format!("{sign}{}.{:02}", abs / 100, (abs % 100) as u32)
66 + }
67 +
49 68 /// Format a byte count as a human-readable file size string.
50 69 /// Returns "N/A" for zero bytes (useful for optional file sizes).
51 70 pub fn format_file_size(bytes: i64) -> String {
@@ -168,6 +187,29 @@ pub fn sanitize_csv_cell(value: &str) -> String {
168 187 mod tests {
169 188 use super::*;
170 189
190 + // ── format_dollars_plain ──
191 +
192 + #[test]
193 + fn dollars_plain_basic() {
194 + assert_eq!(format_dollars_plain(0), "0.00");
195 + assert_eq!(format_dollars_plain(999), "9.99");
196 + assert_eq!(format_dollars_plain(100), "1.00");
197 + assert_eq!(format_dollars_plain(1), "0.01");
198 + }
199 +
200 + #[test]
201 + fn dollars_plain_no_thousands_separator() {
202 + // CSV cells and form values must stay machine-parseable — no commas.
203 + assert_eq!(format_dollars_plain(123_456), "1234.56");
204 + assert_eq!(format_dollars_plain(1_000_000), "10000.00");
205 + }
206 +
207 + #[test]
208 + fn dollars_plain_negative() {
209 + assert_eq!(format_dollars_plain(-999), "-9.99");
210 + assert_eq!(format_dollars_plain(-5), "-0.05");
211 + }
212 +
171 213 // ── format_price ──
172 214
173 215 #[test]
@@ -714,3 +756,64 @@ mod tests {
714 756 }
715 757 }
716 758 }
759 +
760 + /// Seal for the pricing-format drift chronic.
761 + ///
762 + /// The audit kept re-finding ad-hoc `cents as f64 / 100.0` conversions scattered
763 + /// across routes, exports, and admin tooling — each one a place where a future
764 + /// edit could silently reintroduce a rounding or display inconsistency. This
765 + /// test fails the build if that idiom appears anywhere under `src/` outside this
766 + /// module, forcing every cents→dollars conversion through [`format_price`],
767 + /// [`format_revenue`], or [`format_dollars_plain`]. The seal is resolved when the
768 + /// drifted variant can no longer compile-and-pass.
769 + #[cfg(test)]
770 + mod pricing_format_seal_guard {
771 + use std::path::Path;
772 +
773 + #[test]
774 + fn no_ad_hoc_cents_to_dollars_conversion() {
775 + let src_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
776 + let this_file = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/formatting.rs");
777 + let mut offenders = Vec::new();
778 + walk(&src_dir, &mut |path, contents| {
779 + // This module documents the banned idiom; skip it.
780 + if path == this_file {
781 + return;
782 + }
783 + for (i, line) in contents.lines().enumerate() {
784 + // Comment/doc lines may legitimately mention the idiom.
785 + if line.trim_start().starts_with("//") {
786 + continue;
787 + }
788 + // Normalize whitespace so spacing variations all match.
789 + let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect();
790 + if squished.contains("asf64/100") {
791 + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
792 + }
793 + }
794 + });
795 + assert!(
796 + offenders.is_empty(),
797 + "pricing-format seal violated — convert cents to dollars via \
798 + formatting::format_price / format_revenue / format_dollars_plain, never a raw \
799 + `as f64 / 100.0`. Offending lines:\n{}",
800 + offenders.join("\n")
801 + );
802 + }
803 +
804 + fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
805 + let Ok(entries) = std::fs::read_dir(dir) else {
806 + return;
807 + };
808 + for entry in entries.flatten() {
809 + let path = entry.path();
810 + if path.is_dir() {
811 + walk(&path, f);
812 + } else if path.extension().is_some_and(|e| e == "rs")
813 + && let Ok(contents) = std::fs::read_to_string(&path)
814 + {
815 + f(&path, &contents);
816 + }
817 + }
818 + }
819 + }
@@ -235,7 +235,12 @@ fn validate_segment(s: &str) -> Result<(), GitError> {
235 235 // ============================================================================
236 236
237 237 /// Open a bare repository with path traversal validation.
238 - pub fn open_repo(repos_root: &Path, owner: &str, repo: &str) -> Result<Repository, GitError> {
238 + ///
239 + /// `pub(crate)` by design: `git2::Repository` is `!Send` and blocks, so every
240 + /// caller must hold it inside a `spawn_blocking` closure (see
241 + /// `routes::git::GitState::with_repo`). Keeping the opener crate-private marks
242 + /// it as an internal primitive, not a general-purpose entry point.
243 + pub(crate) fn open_repo(repos_root: &Path, owner: &str, repo: &str) -> Result<Repository, GitError> {
239 244 validate_segment(owner)?;
240 245 validate_segment(repo)?;
241 246
@@ -260,7 +265,7 @@ pub fn open_repo(repos_root: &Path, owner: &str, repo: &str) -> Result<Repositor
260 265 /// returned by [`repo_disk_path`]). Used to (re)open the repo inside a
261 266 /// `spawn_blocking` closure, since `git2::Repository` is `!Send` and cannot
262 267 /// cross the await boundary.
263 - pub fn open_repo_at(repo_path: &Path) -> Result<Repository, GitError> {
268 + pub(crate) fn open_repo_at(repo_path: &Path) -> Result<Repository, GitError> {
264 269 Repository::open_bare(repo_path).map_err(|_| GitError::RepoNotFound)
265 270 }
266 271
@@ -4,6 +4,7 @@ pub mod access_gate;
4 4 pub mod auth;
5 5 pub mod background;
6 6 pub mod build_runner;
7 + pub mod cloudflare;
7 8 pub mod config;
8 9 pub mod constants;
9 10 pub mod csrf;
@@ -402,6 +402,8 @@ async fn main() {
402 402 scan_semaphore: state.scan_semaphore.clone(),
403 403 wam: state.wam.clone(),
404 404 clamav_healthy: clamav_healthy.clone(),
405 + cloudflare: makenotwork::cloudflare::CloudflarePurger::from_env(),
406 + cdn_base_url: state.config.cdn_base_url.as_deref().map(std::sync::Arc::from),
405 407 });
406 408 let worker_count = makenotwork::constants::SCAN_WORKER_COUNT;
407 409 let worker_shutdown_rx = shutdown_tx.subscribe();
@@ -117,8 +117,8 @@ pub struct SynckitAppSubCheckoutParams<'a> {
117 117 pub(crate) fn check_min_charge(amount_cents: i64) -> Result<()> {
118 118 if amount_cents > 0 && amount_cents < constants::STRIPE_MINIMUM_CHARGE_CENTS {
119 119 return Err(AppError::BadRequest(format!(
120 - "Minimum purchase amount is ${:.2}",
121 - constants::STRIPE_MINIMUM_CHARGE_CENTS as f64 / 100.0
120 + "Minimum purchase amount is {}",
121 + crate::formatting::format_revenue(constants::STRIPE_MINIMUM_CHARGE_CENTS)
122 122 )));
123 123 }
124 124 Ok(())
@@ -319,8 +319,8 @@ impl PricingModel for PwywPricing {
319 319 let min = self.min_cents.unwrap_or(0);
320 320 if amount_cents < min {
321 321 return Err(format!(
322 - "Amount must be at least ${:.2}",
323 - min as f64 / 100.0
322 + "Amount must be at least {}",
323 + crate::formatting::format_revenue(min as i64)
324 324 ));
325 325 }
326 326 // Cap at $10,000 (same ceiling as tips) to prevent accidental mega-charges
@@ -106,7 +106,9 @@ pub(super) async fn create_collection(
106 106 )));
107 107 }
108 108
109 - let collection = match db::collections::create_collection(
109 + // `create_collection` maps the per-user slug unique-violation to a clean
110 + // validation error (the seal lives in the db layer), so callers just `?`.
111 + let collection = db::collections::create_collection(
110 112 &state.db,
111 113 user.id,
112 114 &slug,
@@ -114,18 +116,7 @@ pub(super) async fn create_collection(
114 116 description,
115 117 req.is_public,
116 118 )
117 - .await
118 - {
119 - Ok(c) => c,
120 - Err(crate::error::AppError::Database(sqlx::Error::Database(ref db_err)))
121 - if db_err.code().as_deref() == Some("23505") =>
122 - {
123 - return Err(AppError::validation(
124 - "You already have a collection with this slug".to_string(),
125 - ));
126 - }
127 - Err(e) => return Err(e),
128 - };
119 + .await?;
129 120
130 121 Ok((
131 122 StatusCode::CREATED,
@@ -542,10 +542,10 @@ pub(super) async fn export_subscriptions(
542 542 };
543 543
544 544 csv_content.push_str(&format!(
545 - "{},{},{:.2},{},{},{},{},{},{}\n",
545 + "{},{},{},{},{},{},{},{},{}\n",
546 546 sanitize_csv_cell(&s.project_title),
547 547 sanitize_csv_cell(&s.tier_name),
548 - s.price_cents as f64 / 100.0,
548 + crate::formatting::format_dollars_plain(s.price_cents),
549 549 sanitize_csv_cell(&s.username),
550 550 sanitize_csv_cell(&s.status.to_string()),
551 551 fmt_opt(s.current_period_start),
@@ -583,11 +583,11 @@ pub(super) async fn export_contacts(
583 583 let mut csv_content = String::from("Username,Email,Purchases,Total Spent,Last Purchase\n");
584 584 for c in &contacts {
585 585 csv_content.push_str(&format!(
586 - "{},{},{},{:.2},{}\n",
586 + "{},{},{},{},{}\n",
587 587 sanitize_csv_cell(&c.username),
588 588 sanitize_csv_cell(&c.email),
589 589 c.total_purchases,
590 - c.total_spent_cents as f64 / 100.0,
590 + crate::formatting::format_dollars_plain(c.total_spent_cents),
591 591 c.last_purchase_at.format("%Y-%m-%d"),
592 592 ));
593 593 }
@@ -179,22 +179,25 @@ pub(super) async fn send_broadcast(
179 179 return Err(AppError::validation("You can only send one broadcast per 24 hours".to_string()));
180 180 }
181 181
182 + // Enforce the broadcast recipient cap at the type level — the same
183 + // `BoundedRecipients` seal the public twin (routes/api/users/broadcast.rs)
184 + // uses, so the two handlers can no longer drift on the cap check.
182 185 let followers = db::follows::get_follower_emails(&state.db, req.user_id).await?;
183 - let count = followers.len();
184 -
185 - if count > constants::BROADCAST_MAX_RECIPIENTS {
186 - // Mirror the public twin (routes/api/users/broadcast.rs): refuse to
187 - // materialize an unbounded recipient set in memory, and roll back the
188 - // 24h rate-limit slot so the creator can retry once the cap is lifted.
189 - let _ = db::users::clear_broadcast_at(&state.db, req.user_id).await;
190 - return Err(AppError::validation(format!(
191 - "Broadcast would reach {count} followers, above the per-send limit of {}. \
192 - Email info@makenot.work to lift the cap for your account.",
193 - constants::BROADCAST_MAX_RECIPIENTS
194 - )));
195 - }
186 + let recipients = match crate::email::BoundedRecipients::new(followers) {
187 + Ok(r) => r,
188 + Err(count) => {
189 + // Roll back the 24h rate-limit slot so the creator can retry once the cap is lifted.
190 + let _ = db::users::clear_broadcast_at(&state.db, req.user_id).await;
191 + return Err(AppError::validation(format!(
192 + "Broadcast would reach {count} followers, above the per-send limit of 10,000. \
193 + Email info@makenot.work to lift the cap for your account."
194 + )));
195 + }
196 + };
197 + let count = recipients.len();
196 198
197 199 if count > 0 {
200 + let followers = recipients.into_inner();
198 201 let creator_name = db_user.display_name.as_deref()
199 202 .unwrap_or(&db_user.username)
200 203 .to_string();
@@ -118,7 +118,7 @@ pub(super) async fn create_promo_code(
118 118 return Err(AppError::BadRequest("Fixed discount must be at least 1 cent".to_string()));
119 119 }
120 120 if dv > crate::constants::MAX_PRICE_CENTS {
121 - return Err(AppError::BadRequest(format!("Fixed discount must be at most ${:.2}", crate::constants::MAX_PRICE_CENTS as f64 / 100.0)));
121 + return Err(AppError::BadRequest(format!("Fixed discount must be at most {}", crate::formatting::format_revenue(crate::constants::MAX_PRICE_CENTS as i64))));
122 122 }
123 123 }
124 124 }
@@ -66,9 +66,23 @@ pub(in crate::routes::api) async fn broadcast_send(
66 66 }.render_string()).into_response());
67 67 }
68 68
69 - // Get follower emails
69 + // Get follower emails, enforcing the broadcast recipient cap at the type
70 + // level — `BoundedRecipients::new` is the only way to obtain a sendable list.
70 71 let followers = db::follows::get_follower_emails(&state.db, user.id).await?;
71 - let count = followers.len();
72 + let recipients = match crate::email::BoundedRecipients::new(followers) {
73 + Ok(r) => r,
74 + Err(count) => {
75 + // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap.
76 + let _ = db::users::clear_broadcast_at(&state.db, user.id).await;
77 + return Ok(Html(FormStatusTemplate {
78 + success: false,
79 + message: format!(
80 + "Broadcast would reach {count} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account."
81 + ),
82 + }.render_string()).into_response());
83 + }
84 + };
85 + let count = recipients.len();
72 86
73 87 if count == 0 {
74 88 return Ok(Html(FormStatusTemplate {
@@ -77,18 +91,6 @@ pub(in crate::routes::api) async fn broadcast_send(
77 91 }.render_string()).into_response());
78 92 }
79 93
80 - if count > constants::BROADCAST_MAX_RECIPIENTS {
81 - // Roll back the 24h rate-limit slot so the creator can try again after lifting the cap.
82 - let _ = db::users::clear_broadcast_at(&state.db, user.id).await;
83 - return Ok(Html(FormStatusTemplate {
84 - success: false,
85 - message: format!(
86 - "Broadcast would reach {} followers, above the per-send limit of 10,000. Email info@makenot.work to lift the cap for your account.",
87 - count
88 - ),
89 - }.render_string()).into_response());
90 - }
91 -
92 94 // Get creator name
93 95 let db_user = db::users::get_user_by_id(&state.db, user.id)
94 96 .await?
@@ -105,6 +107,7 @@ pub(in crate::routes::api) async fn broadcast_send(
105 107 let host_url = state.config.host_url.clone();
106 108 let signing_secret = state.config.signing_secret.clone();
107 109
110 + let followers = recipients.into_inner();
108 111 tokio::spawn(async move {
109 112 let mut set = tokio::task::JoinSet::new();
110 113 let chunk_delay = std::time::Duration::from_millis(constants::BROADCAST_CHUNK_DELAY_MS);
@@ -203,15 +203,14 @@ mod tests {
203 203
204 204 #[test]
205 205 fn price_display_fixed() {
206 - let cents = 1999;
207 - let price = format!("${:.2}", cents as f64 / 100.0);
206 + let price = crate::formatting::format_revenue(1999);
208 207 assert_eq!(price, "$19.99");
209 208 }
210 209
211 210 #[test]
212 211 fn price_display_pwyw() {
213 - let cents = 500;
214 - let price = format!("${:.2}+", cents as f64 / 100.0);
212 + // Embed PWYW prices append "+" to the sealed revenue string.
213 + let price = format!("{}+", crate::formatting::format_revenue(500));
215 214 assert_eq!(price, "$5.00+");
216 215 }
217 216
@@ -338,12 +338,12 @@ pub(super) async fn project_tab_settings(
338 338
339 339 let pricing_model = db_project.pricing_model.to_string();
340 340 let price_dollars = if db_project.price_cents > 0 {
341 - format!("{:.2}", db_project.price_cents as f64 / 100.0)
341 + crate::formatting::format_dollars_plain(db_project.price_cents)
342 342 } else {
343 343 String::new()
344 344 };
345 345 let pwyw_min_dollars = match db_project.pwyw_min_cents {
346 - Some(c) if c > 0 => format!("{:.2}", c as f64 / 100.0),
346 + Some(c) if c > 0 => crate::formatting::format_dollars_plain(c),
347 347 _ => String::new(),
348 348 };
349 349
@@ -179,15 +179,15 @@ pub(super) async fn purchase_page(
179 179 // Calculate fee breakdown for transparency
180 180 let (stripe_fee_cents, creator_receives_cents) =
181 181 crate::helpers::estimate_stripe_fee(price_cents);
182 - let stripe_fee = format!("{:.2}", stripe_fee_cents as f64 / 100.0);
183 - let creator_receives = format!("{:.2}", creator_receives_cents as f64 / 100.0);
182 + let stripe_fee = crate::formatting::format_dollars_plain(stripe_fee_cents);
183 + let creator_receives = crate::formatting::format_dollars_plain(creator_receives_cents);
184 184
185 185 let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?;
186 186 let item = Item::from_db_list(&db_item, &purchase_tags, price_cents == 0, false);
187 187
188 - let suggested_price = format!("{:.2}", db_item.price_cents as f64 / 100.0);
188 + let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
189 189 let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0);
190 - let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0);
190 + let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min);
191 191
192 192 let pending_started = if let Some(ref u) = maybe_user {
193 193 match db::transactions::get_pending_item_purchase(&state.db, u.id, id).await? {
@@ -261,7 +261,7 @@ pub(super) async fn receipt_page(
261 261 let amount = if is_free {
262 262 "Free".to_string()
263 263 } else {
264 - format!("${:.2}", amount_cents as f64 / 100.0)
264 + crate::formatting::format_revenue(amount_cents)
265 265 };
266 266
267 267 let item_id = tx.item_id.map(|id| id.to_string()).unwrap_or_default();
@@ -365,9 +365,9 @@ pub(super) async fn buy_page(
365 365 let purchase_tags = db::tags::get_tags_for_item(&state.db, id).await?;
366 366 let item = Item::from_db_list(&db_item, &purchase_tags, db_item.price_cents == 0, false);
367 367
368 - let suggested_price = format!("{:.2}", db_item.price_cents as f64 / 100.0);
368 + let suggested_price = crate::formatting::format_dollars_plain(db_item.price_cents);
369 369 let pwyw_min = db_item.pwyw_min_cents.unwrap_or(0);
370 - let pwyw_min_dollars = format!("{:.2}", pwyw_min as f64 / 100.0);
370 + let pwyw_min_dollars = crate::formatting::format_dollars_plain(pwyw_min);
371 371
372 372 Ok(BuyPageTemplate {
373 373 item,