max / makenotwork
- Co-Authored-By
- Claude Opus 4.8 (1M context) <noreply@anthropic.com>
76 files changed,
+1382 insertions,
-309 deletions
| @@ -142,7 +142,7 @@ | |||
| 142 | 142 | async-stripe-shared = { version = "1.0.0-rc.6", features = ["deserialize"] } | |
| 143 | 143 | async-stripe-billing = { version = "1.0.0-rc.6", features = ["deserialize", "subscription", "billing_portal_session"] } | |
| 144 | 144 | async-stripe-checkout = { version = "1.0.0-rc.6", features = ["deserialize", "checkout_session"] } | |
| 145 | - | async-stripe-connect = { version = "1.0.0-rc.6", features = ["deserialize", "account", "account_link", "transfer"] } | |
| 145 | + | async-stripe-connect = { version = "1.0.0-rc.6", features = ["deserialize", "account", "account_link", "transfer", "transfer_reversal"] } | |
| 146 | 146 | async-stripe-core = { version = "1.0.0-rc.6", features = ["deserialize", "balance", "refund", "customer"] } | |
| 147 | 147 | async-stripe-payment = { version = "1.0.0-rc.6", features = ["deserialize"] } | |
| 148 | 148 | async-stripe-product = { version = "1.0.0-rc.6", features = ["deserialize", "product", "price"] } |
| @@ -224,9 +224,15 @@ | |||
| 224 | 224 | let stripe = StripeConfig::from_env(); | |
| 225 | 225 | ||
| 226 | 226 | // Load admin user ID - optional, if unset admin routes return 404 | |
| 227 | - | let admin_user_id = std::env::var("ADMIN_USER_ID") | |
| 228 | - | .ok() | |
| 229 | - | .and_then(|s| s.parse::<UserId>().ok()); | |
| 227 | + | let admin_user_id = std::env::var("ADMIN_USER_ID").ok().and_then(|s| { | |
| 228 | + | s.parse::<UserId>() | |
| 229 | + | .map_err(|_| { | |
| 230 | + | tracing::warn!( | |
| 231 | + | "ADMIN_USER_ID is set but is not a valid UserId — ignoring it; admin routes will return 404" | |
| 232 | + | ); | |
| 233 | + | }) | |
| 234 | + | .ok() | |
| 235 | + | }); | |
| 230 | 236 | ||
| 231 | 237 | // SyncKit JWT secret - optional, sync endpoints return 503 if unset. | |
| 232 | 238 | // When set it IS the HS256 symmetric signing key for SyncKit/OAuth | |
| @@ -562,10 +568,17 @@ | |||
| 562 | 568 | .unwrap_or(true), | |
| 563 | 569 | abuse_ch_auth_key: std::env::var("ABUSE_CH_AUTH_KEY").ok().filter(|s| !s.is_empty()), | |
| 564 | 570 | metadefender_api_key: std::env::var("METADEFENDER_API_KEY").ok().filter(|s| !s.is_empty()), | |
| 565 | - | yara_min_rule_files: std::env::var("YARA_MIN_RULE_FILES") | |
| 566 | - | .ok() | |
| 567 | - | .and_then(|v| v.parse().ok()) | |
| 568 | - | .unwrap_or(DEFAULT_YARA_MIN_RULE_FILES), | |
| 571 | + | yara_min_rule_files: match std::env::var("YARA_MIN_RULE_FILES") { | |
| 572 | + | Ok(v) => v.parse().unwrap_or_else(|_| { | |
| 573 | + | tracing::warn!( | |
| 574 | + | value = %v, | |
| 575 | + | "YARA_MIN_RULE_FILES is set but is not a valid number — using default {}", | |
| 576 | + | DEFAULT_YARA_MIN_RULE_FILES | |
| 577 | + | ); | |
| 578 | + | DEFAULT_YARA_MIN_RULE_FILES | |
| 579 | + | }), | |
| 580 | + | Err(_) => DEFAULT_YARA_MIN_RULE_FILES, | |
| 581 | + | }, | |
| 569 | 582 | }) | |
| 570 | 583 | } | |
| 571 | 584 | } |
| @@ -12,6 +12,27 @@ | |||
| 12 | 12 | pub const DB_MAX_LIFETIME_SECS: u64 = 1800; | |
| 13 | 13 | /// Prune idle connections after 10 minutes. | |
| 14 | 14 | pub const DB_IDLE_TIMEOUT_SECS: u64 = 600; | |
| 15 | + | /// Per-statement wall-clock ceiling, applied to every pooled connection via | |
| 16 | + | /// `SET statement_timeout`. `acquire_timeout` only bounds *getting* a | |
| 17 | + | /// connection, not *running* a query — without this a single wedged query pins | |
| 18 | + | /// its connection forever and 25 of them exhaust the pool with no recovery. | |
| 19 | + | /// Generous so legitimate maintenance sweeps and boot migrations aren't killed; | |
| 20 | + | /// a job that genuinely needs longer sets `SET LOCAL statement_timeout = 0` in | |
| 21 | + | /// its own transaction. | |
| 22 | + | pub const DB_STATEMENT_TIMEOUT_SECS: u64 = 120; | |
| 23 | + | /// Ceiling on how long a statement waits to acquire a lock (`SET lock_timeout`). | |
| 24 | + | /// A lock-contended query fails fast instead of blocking a connection | |
| 25 | + | /// indefinitely — the most common way the pool wedges. | |
| 26 | + | pub const DB_LOCK_TIMEOUT_SECS: u64 = 30; | |
| 27 | + | ||
| 28 | + | /// Largest object a single presigned `PutObject` can carry. S3 / Ceph (Hetzner | |
| 29 | + | /// Object Storage) reject a single PUT above 5 GiB — larger objects require | |
| 30 | + | /// multipart. The browser upload paths issue exactly one presigned PUT, so this | |
| 31 | + | /// bounds them regardless of the (higher) per-tier `max_file_bytes`; files above | |
| 32 | + | /// it upload through the CLI / desktop clients, which chunk them. Keeping the | |
| 33 | + | /// browser cap here means a too-big browser upload is refused up front with a | |
| 34 | + | /// clear pointer rather than handed a presigned URL that fails at S3. | |
| 35 | + | pub const S3_SINGLE_PUT_MAX_BYTES: u64 = 5 * 1024 * 1024 * 1024; | |
| 15 | 36 | ||
| 16 | 37 | /// Lifetime of an internal-API actor assertion, minted at `ssh-key-lookup` and | |
| 17 | 38 | /// forwarded by the CLI for the session. 24h comfortably exceeds any SSH session. |
| @@ -772,44 +772,66 @@ | |||
| 772 | 772 | ||
| 773 | 773 | #[test] | |
| 774 | 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"); | |
| 775 | + | let root = Path::new(env!("CARGO_MANIFEST_DIR")); | |
| 776 | + | let this_file = root.join("src/formatting.rs"); | |
| 777 | 777 | let mut offenders = Vec::new(); | |
| 778 | - | walk(&src_dir, &mut |path, contents| { | |
| 779 | - | // This module documents the banned idiom; skip it. | |
| 778 | + | ||
| 779 | + | // The float cents→dollars idiom (rounding-risk) in ANY form, in Rust — | |
| 780 | + | // the raw cast `as f64 / 100.0` AND the newtype accessor `.as_f64() / 100` | |
| 781 | + | // that slipped past the earlier `as f64` pattern (the seal's own blind | |
| 782 | + | // spot; `src/bin/` and the exports used it). Integer `cents / 100` manual | |
| 783 | + | // formatting is a separate style item, not this float-drift seal. | |
| 784 | + | let mut rust_check = |path: &Path, contents: &str| { | |
| 780 | 785 | if path == this_file { | |
| 781 | 786 | return; | |
| 782 | 787 | } | |
| 783 | 788 | for (i, line) in contents.lines().enumerate() { | |
| 784 | - | // Comment/doc lines may legitimately mention the idiom. | |
| 785 | 789 | if line.trim_start().starts_with("//") { | |
| 786 | 790 | continue; | |
| 787 | 791 | } | |
| 788 | - | // Normalize whitespace so spacing variations all match. | |
| 789 | 792 | let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 790 | - | if squished.contains("asf64/100") { | |
| 793 | + | if squished.contains("asf64/100") || squished.contains("as_f64()/100") { | |
| 791 | 794 | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 792 | 795 | } | |
| 793 | 796 | } | |
| 794 | - | }); | |
| 797 | + | }; | |
| 798 | + | walk(&root.join("src"), "rs", &mut rust_check); | |
| 799 | + | ||
| 800 | + | // Templates must never do cents math — prices arrive pre-formatted from | |
| 801 | + | // Rust. A `{{ price_cents / 100 }}` bypasses the centralized formatters in | |
| 802 | + | // the one surface the original seal never scanned. | |
| 803 | + | let mut template_check = |path: &Path, contents: &str| { | |
| 804 | + | for (i, line) in contents.lines().enumerate() { | |
| 805 | + | if line.trim_start().starts_with("{#") { | |
| 806 | + | continue; | |
| 807 | + | } | |
| 808 | + | let squished: String = line.chars().filter(|c| !c.is_whitespace()).collect(); | |
| 809 | + | if squished.contains("cents/100") || squished.contains("asf64/100") { | |
| 810 | + | offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim())); | |
| 811 | + | } | |
| 812 | + | } | |
| 813 | + | }; | |
| 814 | + | walk(&root.join("templates"), "html", &mut template_check); | |
| 815 | + | ||
| 795 | 816 | assert!( | |
| 796 | 817 | offenders.is_empty(), | |
| 797 | 818 | "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{}", | |
| 819 | + | formatting::format_price / format_revenue / format_dollars_plain (never a raw \ | |
| 820 | + | `as f64 / 100.0` or `.as_f64() / 100` in Rust, never cents math in a template). \ | |
| 821 | + | Offending lines:\n{}", | |
| 800 | 822 | offenders.join("\n") | |
| 801 | 823 | ); | |
| 802 | 824 | } | |
| 803 | 825 | ||
| 804 | - | fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) { | |
| 826 | + | fn walk(dir: &Path, ext: &str, f: &mut impl FnMut(&Path, &str)) { | |
| 805 | 827 | let Ok(entries) = std::fs::read_dir(dir) else { | |
| 806 | 828 | return; | |
| 807 | 829 | }; | |
| 808 | 830 | for entry in entries.flatten() { | |
| 809 | 831 | let path = entry.path(); | |
| 810 | 832 | if path.is_dir() { | |
| 811 | - | walk(&path, f); | |
| 812 | - | } else if path.extension().is_some_and(|e| e == "rs") | |
| 833 | + | walk(&path, ext, f); | |
| 834 | + | } else if path.extension().is_some_and(|e| e == ext) | |
| 813 | 835 | && let Ok(contents) = std::fs::read_to_string(&path) | |
| 814 | 836 | { | |
| 815 | 837 | f(&path, &contents); |
| @@ -345,11 +345,15 @@ | |||
| 345 | 345 | headers.insert( | |
| 346 | 346 | axum::http::header::HeaderName::from_static("content-security-policy"), | |
| 347 | 347 | HeaderValue::from_static( | |
| 348 | + | // The embedded player loads an external same-origin script | |
| 349 | + | // (/static/embed-item-player.js) and carries no inline handlers, | |
| 350 | + | // so scripts need 'self', not 'unsafe-inline' (which would | |
| 351 | + | // silently block the external file and leave the player dead). | |
| 348 | 352 | "default-src 'none'; \ | |
| 349 | 353 | img-src 'self' data: https:; \ | |
| 350 | 354 | media-src 'self'; \ | |
| 351 | 355 | style-src 'unsafe-inline'; \ | |
| 352 | - | script-src 'unsafe-inline'; \ | |
| 356 | + | script-src 'self'; \ | |
| 353 | 357 | font-src 'self'; \ | |
| 354 | 358 | base-uri 'none'; \ | |
| 355 | 359 | form-action 'none'; \ |
| @@ -78,6 +78,13 @@ | |||
| 78 | 78 | .log_statements(log::LevelFilter::Trace) | |
| 79 | 79 | .log_slow_statements(log::LevelFilter::Warn, Duration::from_millis(100)); | |
| 80 | 80 | ||
| 81 | + | // Bound every query on every pooled connection: `statement_timeout` caps | |
| 82 | + | // run time so a wedged query can't pin its connection forever (25 of those | |
| 83 | + | // would exhaust the pool with no recovery — acquire_timeout only bounds | |
| 84 | + | // getting a connection, not running one), and `lock_timeout` fails a | |
| 85 | + | // lock-contended query fast instead of blocking indefinitely. | |
| 86 | + | let statement_timeout_ms = constants::DB_STATEMENT_TIMEOUT_SECS * 1000; | |
| 87 | + | let lock_timeout_ms = constants::DB_LOCK_TIMEOUT_SECS * 1000; | |
| 81 | 88 | let db = PgPoolOptions::new() | |
| 82 | 89 | .max_connections(constants::DB_POOL_MAX_CONNECTIONS) | |
| 83 | 90 | .min_connections(constants::DB_POOL_MIN_CONNECTIONS) | |
| @@ -85,6 +92,19 @@ | |||
| 85 | 92 | .max_lifetime(Duration::from_secs(constants::DB_MAX_LIFETIME_SECS)) | |
| 86 | 93 | .idle_timeout(Duration::from_secs(constants::DB_IDLE_TIMEOUT_SECS)) | |
| 87 | 94 | .test_before_acquire(true) | |
| 95 | + | .after_connect(move |conn, _meta| { | |
| 96 | + | Box::pin(async move { | |
| 97 | + | use sqlx::Executor; | |
| 98 | + | conn.execute( | |
| 99 | + | format!( | |
| 100 | + | "SET statement_timeout = {statement_timeout_ms}; SET lock_timeout = {lock_timeout_ms}" | |
| 101 | + | ) | |
| 102 | + | .as_str(), | |
| 103 | + | ) | |
| 104 | + | .await?; | |
| 105 | + | Ok(()) | |
| 106 | + | }) | |
| 107 | + | }) | |
| 88 | 108 | .connect_with(connect_options) | |
| 89 | 109 | .await | |
| 90 | 110 | .expect("Failed to connect to database"); |
| @@ -22,6 +22,7 @@ | |||
| 22 | 22 | // License Keys | |
| 23 | 23 | crate::routes::api::license_keys::validate_key, | |
| 24 | 24 | crate::routes::api::license_keys::deactivate_key, | |
| 25 | + | crate::routes::api::license_keys::key_status_post, | |
| 25 | 26 | crate::routes::api::license_keys::key_status, | |
| 26 | 27 | crate::routes::api::license_keys::license_verify, | |
| 27 | 28 | crate::routes::api::license_keys::license_deactivate, | |
| @@ -63,6 +64,7 @@ | |||
| 63 | 64 | crate::routes::api::license_keys::ValidateKeyLicense, | |
| 64 | 65 | crate::routes::api::license_keys::DeactivateKeyRequest, | |
| 65 | 66 | crate::routes::api::license_keys::DeactivateKeyResponse, | |
| 67 | + | crate::routes::api::license_keys::KeyStatusRequest, | |
| 66 | 68 | crate::routes::api::license_keys::KeyStatusResponse, | |
| 67 | 69 | crate::routes::api::license_keys::KeyStatusLicense, | |
| 68 | 70 | crate::routes::api::license_keys::LicenseVerifyRequest, |