Skip to main content

max / makenotwork

Remediate audit Run 21 findings across server Fix all 10 highest-blast-radius findings plus a large fix-soon/cleanup tail from the Run 21 audit; full suite stays green (2,998 tests). Fix-first: - SyncKit: create the sync_app_usage_current row transactionally on app create + backfill migration 165; post-117 apps no longer 500 on billing. - Blog: auto-save no longer unpublishes live posts (publish state is a no-change Option the client omits). - Payments: reverse settled Fan+ platform credits on refund (migration 166 + TransferReversal + scheduler sweep); founder checkout never crosses billing cadence; mixed-cart promo reserves before granting free lines; tip orphan escalation; webhook retry worker routes through the per-event lock + dedup. - Content exposure: purchase_page visibility gate; followed feeds apply the full discover gate; re-subscribe restores only suspension-hidden items (migration 164), not genuine drafts. - SyncKit push: per-batch advisory lock restores the idempotency backstop migration 086 dropped. - Embed player CSP script-src 'self'. - DB statement/lock timeouts + scheduler tick watchdog. - Import pipeline: batched tag resolution + bounded bg dispatch. - Uploads: cap browser single-PUT at 5 GiB (large files go via CLI/desktop). Fix-soon/cleanup: license_text visibility gate, CSS expression() strip, pricing-seal widening, mnw-admin UTF-8/CSV hardening, admin audit-log logging, misattached tracing span, item-wizard GET draft reuse, tier parse-swallow, register_device TOCTOU, admin active-state CSS, global- script cleanup, tag-count visibility, collections owner-scope, and more. Surfaced items: email-keyed mailing-list unsubscribe (imported subscribers now reachable + CAN-SPAM compliant); POST /api/v1/keys/status (key out of URLs, GET deprecated); Postmark patch self-MessageID short-circuit + corrected external_ref comment (MT dedups via unique indexes). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 16:08 UTC
Commit: 050368af60f22a358b852b8f7381ff67069df1e7
Parent: 0df7c95
76 files changed, +1382 insertions, -309 deletions
@@ -142,7 +142,7 @@ async-stripe = { version = "1.0.0-rc.6", features = ["default-tls"] }
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"] }
@@ -0,0 +1,13 @@
1 + -- Distinguish suspension-hidden items from genuine drafts (Run 21, content
2 + -- exposure trio).
3 + --
4 + -- The post-grace integrity sweep (`hide_all_items_for_users`) flips every
5 + -- currently-public item to `is_public = false`. On re-subscribe,
6 + -- `unhide_all_items_for_user` flipped ALL `is_public = false` items back to
7 + -- true — including items the creator never published (genuine drafts). A
8 + -- lapsed-then-renewed creator's private drafts were silently made public.
9 + --
10 + -- This marker records WHICH items the sweep hid, so the unhide restores only
11 + -- those and leaves drafts alone. NULL = not hidden by the sweep (a draft or a
12 + -- live item); non-NULL = hidden by the sweep and eligible for restore.
13 + ALTER TABLE items ADD COLUMN IF NOT EXISTS hidden_by_suspension_at TIMESTAMPTZ;
@@ -0,0 +1,13 @@
1 + -- Backfill missing `sync_app_usage_current` rows (Run 21, HIGH availability).
2 + --
3 + -- Migration 117 seeded one usage row per app that existed at the time, but
4 + -- nothing inserted a row for apps created afterward — `create_sync_app` only
5 + -- wrote to `sync_apps`. Every billing/blob path locks the usage row with
6 + -- `SELECT ... FOR UPDATE` and errors when it's absent, so every app created
7 + -- between 117 and this migration 500s on its storage/billing surface.
8 + --
9 + -- `create_sync_app` now inserts the usage row transactionally on create; this
10 + -- repairs the apps that slipped through in between. Idempotent.
11 + INSERT INTO sync_app_usage_current (app_id)
12 + SELECT id FROM sync_apps
13 + ON CONFLICT (app_id) DO NOTHING;
@@ -0,0 +1,23 @@
1 + -- Reverse settled platform-funded credits when their sale is refunded (Run 21,
2 + -- money-loss finding).
3 + --
4 + -- When a Fan+ platform credit discounts a paid sale, the scheduler transfers
5 + -- the discounted amount MNW -> creator so the creator nets full price
6 + -- (migration 158). If that sale is later refunded, the buyer's charge is
7 + -- refunded but nothing reversed the MNW -> creator transfer: the creator kept
8 + -- the reimbursement for a returned item and MNW absorbed the loss silently.
9 + --
10 + -- To reverse the transfer we need its Stripe id, which the settle path now
11 + -- captures (`platform_credit_transfer_id`). `platform_credit_reversed_at` marks
12 + -- a completed reversal so the reversal sweep runs exactly once per transaction.
13 + ALTER TABLE transactions
14 + ADD COLUMN IF NOT EXISTS platform_credit_transfer_id TEXT,
15 + ADD COLUMN IF NOT EXISTS platform_credit_reversed_at TIMESTAMPTZ;
16 +
17 + -- Drives the reversal sweep: settled credits on refunded transactions that
18 + -- haven't been reversed yet.
19 + CREATE INDEX IF NOT EXISTS idx_transactions_platform_credit_reversible
20 + ON transactions (completed_at)
21 + WHERE platform_credit_cents > 0
22 + AND platform_credit_settled_at IS NOT NULL
23 + AND platform_credit_reversed_at IS NULL;
@@ -34,6 +34,18 @@ use sqlx::PgPool;
34 34
35 35 use makenotwork::db::{self, AppealDecision, SelectionMethod, TransactionStatus, Username, WaitlistStatus};
36 36
37 + /// Truncate `s` to at most `max` bytes on a char boundary (for display only).
38 + fn truncate_display(s: &str, max: usize) -> &str {
39 + if s.len() <= max {
40 + return s;
41 + }
42 + let mut end = max;
43 + while end > 0 && !s.is_char_boundary(end) {
44 + end -= 1;
45 + }
46 + &s[..end]
47 + }
48 +
37 49 #[derive(Parser)]
38 50 #[command(name = "mnw-admin", about = "MNW admin CLI")]
39 51 struct Cli {
@@ -172,7 +184,7 @@ async fn cmd_waitlist(pool: &PgPool) -> anyhow::Result<()> {
172 184 for entry in &entries {
173 185 let pitch = entry.pitch.as_deref().unwrap_or("(invited)");
174 186 let pitch_short = if pitch.len() > 40 {
175 - format!("{}...", &pitch[..40])
187 + format!("{}...", truncate_display(pitch, 40))
176 188 } else {
177 189 pitch.to_string()
178 190 };
@@ -401,7 +413,7 @@ async fn cmd_appeals(pool: &PgPool) -> anyhow::Result<()> {
401 413 .unwrap_or_else(|| "-".to_string());
402 414 let appeal = user.appeal_text.as_deref().unwrap_or("");
403 415 let appeal_short = if appeal.len() > 50 {
404 - format!("{}...", &appeal[..50])
416 + format!("{}...", truncate_display(appeal, 50))
405 417 } else {
406 418 appeal.to_string()
407 419 };
@@ -485,11 +497,11 @@ async fn cmd_transactions(pool: &PgPool, username_str: &str) -> anyhow::Result<(
485 497 let date = tx.created_at.format("%Y-%m-%d");
486 498 let title = tx.item_title.as_deref().unwrap_or("(deleted)");
487 499 let title_short = if title.len() > 28 {
488 - format!("{}...", &title[..25])
500 + format!("{}...", truncate_display(title, 25))
489 501 } else {
490 502 title.to_string()
491 503 };
492 - let amount = format!("${:.2}", tx.amount_cents.as_f64() / 100.0);
504 + let amount = makenotwork::formatting::format_revenue(tx.amount_cents.as_i64());
493 505 println!(
494 506 "{:<12} {:<30} {:>10} {:<10}",
495 507 date, title_short, amount, tx.status
@@ -528,8 +540,8 @@ async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
528 540 .item_id
529 541 .map(|id| id.to_string())
530 542 .unwrap_or_default();
531 - let title = csv_escape(row.item_title.as_deref().unwrap_or(""));
532 - let email = csv_escape(row.buyer_email.as_deref().unwrap_or(""));
543 + let title = makenotwork::formatting::sanitize_csv_cell(row.item_title.as_deref().unwrap_or(""));
544 + let email = makenotwork::formatting::sanitize_csv_cell(row.buyer_email.as_deref().unwrap_or(""));
533 545 println!(
534 546 "{},{},{},{},{},{}",
535 547 date, item_id, title, row.amount_cents, row.status, email
@@ -539,15 +551,6 @@ async fn cmd_export(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
539 551 Ok(())
540 552 }
541 553
542 - /// Escape a value for CSV: wrap in quotes if it contains comma, quote, or newline.
543 - fn csv_escape(s: &str) -> String {
544 - if s.contains(',') || s.contains('"') || s.contains('\n') {
545 - format!("\"{}\"", s.replace('"', "\"\""))
546 - } else {
547 - s.to_string()
548 - }
549 - }
550 -
551 554 // ── Storage audit command ──
552 555
553 556 async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
@@ -594,7 +597,7 @@ async fn cmd_storage(pool: &PgPool, username_str: &str) -> anyhow::Result<()> {
594 597 if let Some(key) = &row.s3_key {
595 598 let label = format!("{} v{}", row.item_title, row.version_number);
596 599 let label_short = if label.len() > 23 {
597 - format!("{}...", &label[..20])
600 + format!("{}...", truncate_display(&label, 20))
598 601 } else {
599 602 label
600 603 };
@@ -224,9 +224,15 @@ impl Config {
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 @@ impl ScanConfig {
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 @@ pub const DB_ACQUIRE_TIMEOUT_SECS: u64 = 3;
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.
@@ -330,8 +330,11 @@ impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> {
330 330 }
331 331
332 332 fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> {
333 - // expression() is dead in every browser we support; record it so the
334 - // creator sees why it's gone, and recurse for any url() inside it.
333 + // expression() is dead in every browser we support, but the sanitizer's
334 + // contract is "recorded as blocked => actually removed", so neutralize it
335 + // rather than passing the token through: clear its arguments (dropping any
336 + // url()/payload inside) and rename it so the output can't contain a
337 + // working `expression(...)` (Run 21 security).
335 338 if function.name.as_ref().eq_ignore_ascii_case("expression") {
336 339 self.rejections.push(Rejection {
337 340 kind: RejectionKind::BlockedFunction,
@@ -339,6 +342,9 @@ impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> {
339 342 original_value: "expression()".into(),
340 343 reason: "the expression() function is not allowed".into(),
341 344 });
345 + function.arguments.0.clear();
346 + function.name = lightningcss::values::ident::Ident("mnw-blocked".into());
347 + return Ok(());
342 348 }
343 349 function.visit_children(self)
344 350 }
@@ -717,8 +723,13 @@ mod tests {
717 723
718 724 #[test]
719 725 fn expression_function_recorded() {
720 - let (_out, rej) = san(".x { width: expression(alert(1)) }");
726 + let (out, rej) = san(".x { width: expression(alert(1)) }");
721 727 assert!(rej.iter().any(|r| r.kind == RejectionKind::BlockedFunction));
728 + // Behavior must match the "blocked" contract: the output must not contain
729 + // a working expression() call or its payload.
730 + let lower = out.to_ascii_lowercase();
731 + assert!(!lower.contains("expression("), "expression() must be neutralized in output: {out}");
732 + assert!(!lower.contains("alert(1)"), "expression() payload must be stripped: {out}");
722 733 }
723 734
724 735 #[test]
@@ -168,6 +168,10 @@ pub async fn get_landing_changelog_post(
168 168 /// (the scheduler will set it when the time comes).
169 169 ///
170 170 /// `web_only` uses `Option<bool>`: `None` = no change, `Some(v)` = update.
171 + ///
172 + /// `publish` uses `Option<bool>`: `None` = leave `published_at` untouched (a
173 + /// background auto-save passes `None` so it can never silently unpublish a live
174 + /// post), `Some(true)` = publish, `Some(false)` = unpublish.
171 175 #[allow(clippy::too_many_arguments)]
172 176 #[tracing::instrument(skip_all)]
173 177 pub async fn update_blog_post(
@@ -177,19 +181,22 @@ pub async fn update_blog_post(
177 181 slug: &Slug,
178 182 body_markdown: &str,
179 183 body_html: &str,
180 - publish: bool,
184 + publish: Option<bool>,
181 185 publish_at: Option<Option<chrono::DateTime<chrono::Utc>>>,
182 186 web_only: Option<bool>,
183 187 show_on_landing: Option<bool>,
184 188 ) -> Result<DbBlogPost> {
185 189 let update_publish_at = publish_at.is_some();
186 190 let publish_at_value = publish_at.flatten();
191 + let change_publish = publish.is_some();
192 + let publish_value = publish.unwrap_or(false);
187 193
188 - // Four-way CASE for published_at:
194 + // Five-way CASE for published_at:
189 195 // 1. Scheduling (publish_at is being set) → keep NULL (scheduler handles it)
190 - // 2. First publish (publish=true, published_at IS NULL) → set to NOW()
191 - // 3. Unpublish (publish=false) → clear to NULL
192 - // 4. Re-save while published → preserve existing timestamp
196 + // 2. Explicit first publish (publish=Some(true), published_at IS NULL) → set to NOW()
197 + // 3. Explicit unpublish (publish=Some(false)) → clear to NULL
198 + // 4. No publish-state change (publish=None, e.g. auto-save) → preserve
199 + // 5. Re-save while published (publish=Some(true), already published) → preserve
193 200 let post = sqlx::query_as::<_, DbBlogPost>(
194 201 r#"
195 202 UPDATE blog_posts
@@ -199,8 +206,8 @@ pub async fn update_blog_post(
199 206 body_html = $5,
200 207 published_at = CASE
201 208 WHEN $7 = true AND $8 IS NOT NULL THEN NULL
202 - WHEN $6 = true AND published_at IS NULL THEN NOW()
203 - WHEN $6 = false THEN NULL
209 + WHEN $11 = true AND $6 = true AND published_at IS NULL THEN NOW()
210 + WHEN $11 = true AND $6 = false THEN NULL
204 211 ELSE published_at
205 212 END,
206 213 publish_at = CASE WHEN $7 THEN $8 ELSE publish_at END,
@@ -216,11 +223,12 @@ pub async fn update_blog_post(
216 223 .bind(slug)
217 224 .bind(body_markdown)
218 225 .bind(body_html)
219 - .bind(publish)
226 + .bind(publish_value)
220 227 .bind(update_publish_at)
221 228 .bind(publish_at_value)
222 229 .bind(web_only)
223 230 .bind(show_on_landing)
231 + .bind(change_publish)
224 232 .fetch_one(pool)
225 233 .await?;
226 234
@@ -77,11 +77,20 @@ pub async fn update_collection(
77 77 Ok(collection)
78 78 }
79 79
80 - /// Delete a collection by ID. Returns true if a row was deleted.
80 + /// Delete a collection owned by `owner_id`. Returns true if a row was deleted.
81 + ///
82 + /// Ownership is scoped IN the SQL (defense in depth, mirroring
83 + /// `blog_posts::delete_blog_post` / `media_files::delete`) so this can't remove
84 + /// another user's collection even if a caller skips the upstream ownership check.
81 85 #[tracing::instrument(skip_all)]
82 - pub async fn delete_collection(pool: &PgPool, collection_id: CollectionId) -> Result<bool> {
83 - let result = sqlx::query("DELETE FROM collections WHERE id = $1")
86 + pub async fn delete_collection(
87 + pool: &PgPool,
88 + collection_id: CollectionId,
89 + owner_id: UserId,
90 + ) -> Result<bool> {
91 + let result = sqlx::query("DELETE FROM collections WHERE id = $1 AND user_id = $2")
84 92 .bind(collection_id)
93 + .bind(owner_id)
85 94 .execute(pool)
86 95 .await?;
87 96
@@ -99,7 +99,26 @@ pub async fn get_active_creator_tier(
99 99 .fetch_optional(pool)
100 100 .await?;
101 101
102 - Ok(tier.and_then(|t| t.parse().ok()))
102 + match tier {
103 + None => Ok(None),
104 + // A present-but-unparseable tier means the DB holds a tier string the
105 + // enum doesn't know (enum drift). Silently mapping that to `None` would
106 + // strip a paying creator's entitlements with no signal — surface it
107 + // loudly instead of swallowing it (Run 21). The `enum_drift` test and the
108 + // DB CHECK constraint make this unreachable in practice.
109 + Some(t) => match t.parse::<CreatorTier>() {
110 + Ok(parsed) => Ok(Some(parsed)),
111 + Err(_) => {
112 + tracing::error!(
113 + user_id = %user_id, tier = %t,
114 + "active creator subscription has an unrecognized tier string (enum drift)"
115 + );
116 + Err(crate::error::AppError::Internal(anyhow::anyhow!(
117 + "unrecognized creator tier '{t}' for user {user_id}"
118 + )))
119 + }
120 + },
121 + }
103 122 }
104 123
105 124 // Apply a Stripe-driven status and/or period update in one guarded statement.
@@ -86,19 +86,22 @@ pub async fn get_followed_items(
86 86 WITH followed_item_ids AS (
87 87 SELECT i.id FROM items i
88 88 JOIN projects p ON i.project_id = p.id
89 + JOIN users u ON u.id = p.user_id
89 90 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
90 - WHERE i.is_public = true AND p.is_public = true
91 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
91 92 UNION
92 93 SELECT i.id FROM items i
93 94 JOIN projects p ON i.project_id = p.id
95 + JOIN users u ON u.id = p.user_id
94 96 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
95 - WHERE i.is_public = true AND p.is_public = true
97 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
96 98 UNION
97 99 SELECT i.id FROM items i
98 100 JOIN projects p ON i.project_id = p.id
101 + JOIN users u ON u.id = p.user_id
99 102 JOIN item_tags it ON it.item_id = i.id
100 103 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
101 - WHERE i.is_public = true AND p.is_public = true
104 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
102 105 )
103 106 SELECT i.* FROM items i
104 107 JOIN followed_item_ids fi ON fi.id = i.id
@@ -127,19 +130,22 @@ pub async fn get_followed_feed_items(
127 130 WITH followed_item_ids AS (
128 131 SELECT i.id FROM items i
129 132 JOIN projects p ON i.project_id = p.id
133 + JOIN users u ON u.id = p.user_id
130 134 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
131 - WHERE i.is_public = true AND p.is_public = true
135 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
132 136 UNION
133 137 SELECT i.id FROM items i
134 138 JOIN projects p ON i.project_id = p.id
139 + JOIN users u ON u.id = p.user_id
135 140 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
136 - WHERE i.is_public = true AND p.is_public = true
141 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
137 142 UNION
138 143 SELECT i.id FROM items i
139 144 JOIN projects p ON i.project_id = p.id
145 + JOIN users u ON u.id = p.user_id
140 146 JOIN item_tags it ON it.item_id = i.id
141 147 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
142 - WHERE i.is_public = true AND p.is_public = true
148 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
143 149 )
144 150 SELECT
145 151 i.id,
@@ -186,19 +192,22 @@ pub async fn count_followed_feed_items(
186 192 WITH followed_item_ids AS (
187 193 SELECT i.id FROM items i
188 194 JOIN projects p ON i.project_id = p.id
195 + JOIN users u ON u.id = p.user_id
189 196 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'user' AND f.target_id = p.user_id
190 - WHERE i.is_public = true AND p.is_public = true
197 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
191 198 UNION
192 199 SELECT i.id FROM items i
193 200 JOIN projects p ON i.project_id = p.id
201 + JOIN users u ON u.id = p.user_id
194 202 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'project' AND f.target_id = p.id
195 - WHERE i.is_public = true AND p.is_public = true
203 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
196 204 UNION
197 205 SELECT i.id FROM items i
198 206 JOIN projects p ON i.project_id = p.id
207 + JOIN users u ON u.id = p.user_id
199 208 JOIN item_tags it ON it.item_id = i.id
200 209 JOIN follows f ON f.follower_id = $1 AND f.target_type = 'tag' AND f.target_id = it.tag_id
201 - WHERE i.is_public = true AND p.is_public = true
210 + WHERE i.is_public = true AND i.listed = true AND p.is_public = true AND i.scan_status = 'clean' AND u.is_sandbox = FALSE AND i.deleted_at IS NULL
202 211 )
203 212 SELECT COUNT(*) FROM followed_item_ids
204 213 "#,
@@ -62,6 +62,35 @@ pub async fn get_viewer_item_flags(
62 62 /// an existing item in the same project, appends a counter suffix.
63 63 #[allow(clippy::too_many_arguments)]
64 64 #[tracing::instrument(skip_all)]
65 + /// Find an existing untitled, unpublished wizard draft for a project + type.
66 + ///
67 + /// The single-type-card item wizard used to `create_item` a fresh "Untitled"
68 + /// row on every GET, so a prefetch or a re-visit piled up orphan drafts. The
69 + /// wizard now reuses the most recent such draft (newest first) instead of
70 + /// minting another (Run 21). A draft the creator actually names/publishes no
71 + /// longer matches, so real items are never recycled.
72 + #[tracing::instrument(skip_all)]
73 + pub async fn find_untitled_wizard_draft(
74 + pool: &PgPool,
75 + project_id: ProjectId,
76 + item_type: ItemType,
77 + ) -> Result<Option<DbItem>> {
78 + let item = sqlx::query_as::<_, DbItem>(
79 + r#"
80 + SELECT * FROM items
81 + WHERE project_id = $1 AND item_type = $2
82 + AND title = 'Untitled' AND is_public = false AND deleted_at IS NULL
83 + ORDER BY created_at DESC
84 + LIMIT 1
85 + "#,
86 + )
87 + .bind(project_id)
88 + .bind(item_type)
89 + .fetch_optional(pool)
90 + .await?;
91 + Ok(item)
92 + }
93 +
65 94 pub async fn create_item(
66 95 pool: &PgPool,
67 96 project_id: ProjectId,
@@ -1037,6 +1066,9 @@ pub async fn get_item_by_project_and_slug(
1037 1066 /// Hide every public item for ALL given creators in one statement, for the
1038 1067 /// post-grace scheduler sweep — replaces N per-user UPDATEs on the lock-held tick
1039 1068 /// (Perf-S4, Run 9). Returns the total rows affected. No-op on an empty slice.
1069 + ///
1070 + /// Stamps `hidden_by_suspension_at` so the matching `unhide_all_items_for_user`
1071 + /// restores ONLY what this sweep hid, never a creator's genuine drafts.
1040 1072 #[tracing::instrument(skip_all)]
1041 1073 pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Result<u64> {
1042 1074 if user_ids.is_empty() {
@@ -1044,7 +1076,7 @@ pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Res
1044 1076 }
1045 1077 let result = sqlx::query(
1046 1078 r#"
1047 - UPDATE items SET is_public = false
1079 + UPDATE items SET is_public = false, hidden_by_suspension_at = NOW()
1048 1080 WHERE project_id IN (SELECT id FROM projects WHERE user_id = ANY($1))
1049 1081 AND is_public = true
1050 1082 "#,
@@ -1056,15 +1088,20 @@ pub async fn hide_all_items_for_users(pool: &PgPool, user_ids: &[UserId]) -> Res
1056 1088 Ok(result.rows_affected())
1057 1089 }
1058 1090
1059 - /// Unhide all items for a user (set is_public = true). Used when a creator re-subscribes
1060 - /// after post-grace hiding. Returns the number of items unhidden.
1091 + /// Unhide items for a user that the post-grace sweep hid. Used when a creator
1092 + /// re-subscribes. Returns the number of items unhidden.
1093 + ///
1094 + /// Restores ONLY items stamped by `hide_all_items_for_users`
1095 + /// (`hidden_by_suspension_at IS NOT NULL`) and clears the stamp — a draft the
1096 + /// creator never published (`is_public = false` with a NULL stamp) is left
1097 + /// private, so a lapsed-then-renewed subscription can't leak unpublished work.
1061 1098 #[tracing::instrument(skip_all)]
1062 1099 pub async fn unhide_all_items_for_user(pool: &PgPool, user_id: UserId) -> Result<u64> {
1063 1100 let result = sqlx::query(
1064 1101 r#"
1065 - UPDATE items SET is_public = true
1102 + UPDATE items SET is_public = true, hidden_by_suspension_at = NULL
1066 1103 WHERE project_id IN (SELECT id FROM projects WHERE user_id = $1)
1067 - AND is_public = false
1104 + AND hidden_by_suspension_at IS NOT NULL
1068 1105 AND removed_at IS NULL
1069 1106 "#,
1070 1107 )
@@ -3,7 +3,6 @@
3 3 use sqlx::PgPool;
4 4
5 5 use super::enums::MailingListType;
6 - use super::follows::FollowerEmailRow;
7 6 use super::id_types::{MailingListId, ProjectId, UserId};
8 7 use super::models::DbMailingList;
9 8 use crate::error::Result;
@@ -115,22 +114,44 @@ pub async fn unsubscribe_from_project(
115 114 Ok(result.rows_affected())
116 115 }
117 116
118 - /// Get email addresses of all subscribers on a list.
119 - /// Only verified, non-suspended, non-suppressed users. Capped at 10,000.
117 + /// A mailing-list recipient: either an MNW user (has `user_id`) or an
118 + /// email-only subscriber imported from another platform (`user_id` is `None`).
119 + /// The two need different unsubscribe links — user-keyed vs email-keyed.
120 + #[derive(Debug, Clone, sqlx::FromRow)]
121 + pub struct MailingSubscriber {
122 + pub user_id: Option<UserId>,
123 + pub email: String,
124 + pub display_name: Option<String>,
125 + }
126 +
127 + /// Get all deliverable subscribers on a list — both MNW users AND email-only
128 + /// imported subscribers. Capped at 10,000.
129 + ///
130 + /// User rows require a verified, non-suspended account; email-only rows
131 + /// (`user_id IS NULL`) come straight from the import. Both branches exclude
132 + /// suppressed addresses. Previously this INNER JOINed `users`, so imported
133 + /// email-only subscribers were silently never emailed (Run 21 data).
120 134 #[tracing::instrument(skip_all)]
121 - pub async fn get_subscriber_emails(
135 + pub async fn get_subscribers(
122 136 pool: &PgPool,
123 137 list_id: MailingListId,
124 - ) -> Result<Vec<FollowerEmailRow>> {
125 - let rows = sqlx::query_as::<_, FollowerEmailRow>(
138 + ) -> Result<Vec<MailingSubscriber>> {
139 + let rows = sqlx::query_as::<_, MailingSubscriber>(
126 140 r#"
127 - SELECT u.id, u.email, u.display_name
141 + SELECT u.id AS user_id, u.email, u.display_name
128 142 FROM mailing_list_subscribers s
129 143 JOIN users u ON u.id = s.user_id
130 144 WHERE s.list_id = $1
131 145 AND u.email_verified = true
132 146 AND u.suspended_at IS NULL
133 147 AND LOWER(u.email) NOT IN (SELECT LOWER(email) FROM email_suppressions)
148 + UNION ALL
149 + SELECT NULL::uuid AS user_id, s.email, NULL AS display_name
150 + FROM mailing_list_subscribers s
151 + WHERE s.list_id = $1
152 + AND s.user_id IS NULL
153 + AND s.email IS NOT NULL
154 + AND LOWER(s.email) NOT IN (SELECT LOWER(email) FROM email_suppressions)
134 155 LIMIT 10000
135 156 "#,
136 157 )
@@ -141,6 +162,26 @@ pub async fn get_subscriber_emails(
141 162 Ok(rows)
142 163 }
143 164
165 + /// Unsubscribe an email-only subscriber (no MNW account) from a list. Removes
166 + /// the `(list_id, email)` row. Idempotent. Backs the email-keyed unsubscribe
167 + /// link carried in emails to imported subscribers.
168 + #[tracing::instrument(skip_all)]
169 + pub async fn unsubscribe_by_email(
170 + pool: &PgPool,
171 + list_id: MailingListId,
172 + email: &str,
173 + ) -> Result<bool> {
174 + let result = sqlx::query(
175 + "DELETE FROM mailing_list_subscribers WHERE list_id = $1 AND LOWER(email) = LOWER($2) AND user_id IS NULL",
176 + )
177 + .bind(list_id)
178 + .bind(email)
179 + .execute(pool)
180 + .await?;
181 +
182 + Ok(result.rows_affected() > 0)
183 + }
184 +
144 185 /// Auto-create the default content + devlog lists for a new project.
145 186 #[tracing::instrument(skip_all)]
146 187 pub async fn create_default_lists(
@@ -55,7 +55,7 @@ pub mod collections; // pub so the integration test crate can exercise the layer
55 55 pub(crate) mod ota;
56 56 pub(crate) mod builds;
57 57 pub mod creator_tiers;
58 - pub(crate) mod mailing_lists;
58 + pub mod mailing_lists;
59 59 pub mod custom_domains;
60 60 pub mod patches;
61 61 pub mod bundles;
@@ -118,8 +118,9 @@ pub struct DbSyncBlob {
118 118 /// Sync app + billing columns + live usage counters (joined view).
119 119 ///
120 120 /// Mirrors columns added in migration 117 (`117_synckit_v2_billing.sql`).
121 - /// Built by joining `sync_apps` against `sync_app_usage_current` (LEFT JOIN; /// the usage row is inserted on app create, but guards against the row going
122 - /// missing).
121 + /// Built by joining `sync_apps` against `sync_app_usage_current` (LEFT JOIN).
122 + /// The usage row is created with the app (`create_sync_app`) and backfilled by
123 + /// migration 165; the LEFT JOIN is defense-in-depth against a missing row.
123 124 #[derive(Debug, Clone, FromRow, Serialize)]
124 125 pub struct DbSyncAppBilling {
125 126 // sync_apps base
@@ -59,17 +59,71 @@ pub async fn claim_unsettled_credit(pool: &PgPool) -> Result<Option<PlatformCred
59 59 Ok(row)
60 60 }
61 61
62 - /// Record that a claimed credit's transfer succeeded. Idempotent.
63 - pub async fn mark_settled(pool: &PgPool, transaction_id: TransactionId) -> Result<()> {
64 - sqlx::query!(
65 - "UPDATE transactions SET platform_credit_settled_at = NOW() WHERE id = $1",
66 - transaction_id as TransactionId,
62 + /// Record that a claimed credit's transfer succeeded, storing the Stripe
63 + /// transfer id so the credit can be reversed if the sale is later refunded.
64 + /// Idempotent. Runtime-checked query (the `platform_credit_transfer_id` column
65 + /// is newer than the offline sqlx cache), per [`get_stale_credits`].
66 + pub async fn mark_settled(
67 + pool: &PgPool,
68 + transaction_id: TransactionId,
69 + transfer_id: &str,
70 + ) -> Result<()> {
71 + sqlx::query(
72 + "UPDATE transactions SET platform_credit_settled_at = NOW(), platform_credit_transfer_id = $2 WHERE id = $1",
67 73 )
74 + .bind(transaction_id)
75 + .bind(transfer_id)
68 76 .execute(pool)
69 77 .await?;
70 78 Ok(())
71 79 }
72 80
81 + /// A settled platform credit whose sale was refunded and whose MNW -> creator
82 + /// transfer must be reversed to claw the reimbursement back.
83 + #[derive(Debug, sqlx::FromRow)]
84 + pub struct ReversibleCredit {
85 + pub transaction_id: TransactionId,
86 + pub amount_cents: Cents,
87 + pub transfer_id: String,
88 + }
89 +
90 + /// Up to `limit` settled platform credits sitting on refunded transactions that
91 + /// haven't been reversed yet, oldest first. The reversal uses a deterministic
92 + /// idempotency key, so the single-instance scheduler can process these
93 + /// sequentially and safely. Runtime-checked query (new columns).
94 + pub async fn get_reversible_credits(pool: &PgPool, limit: i64) -> Result<Vec<ReversibleCredit>> {
95 + let rows = sqlx::query_as::<_, ReversibleCredit>(
96 + r#"
97 + SELECT id AS transaction_id,
98 + platform_credit_cents AS amount_cents,
99 + platform_credit_transfer_id AS transfer_id
100 + FROM transactions
101 + WHERE status = 'refunded'
102 + AND platform_credit_cents > 0
103 + AND platform_credit_settled_at IS NOT NULL
104 + AND platform_credit_transfer_id IS NOT NULL
105 + AND platform_credit_reversed_at IS NULL
106 + ORDER BY completed_at
107 + LIMIT $1
108 + "#,
109 + )
110 + .bind(limit)
111 + .fetch_all(pool)
112 + .await?;
113 +
114 + Ok(rows)
115 + }
116 +
117 + /// Mark a platform credit reversed (funds clawed back). Idempotent.
118 + /// Runtime-checked query (new column).
119 + pub async fn mark_reversed(pool: &PgPool, transaction_id: TransactionId) -> Result<()> {
120 + sqlx::query("UPDATE transactions SET platform_credit_reversed_at = NOW() WHERE id = $1")
121 + .bind(transaction_id)
122 + .execute(pool)
123 + .await?;
124 + Ok(())
125 + }
126 +
73 127 /// Release a claimed-but-unsettled credit back to the queue after a *graceful*
74 128 /// transfer failure (transient error where nothing external happened). A
75 129 /// non-graceful failure (process killed) cannot reach here; that row stays
@@ -99,18 +99,24 @@ pub async fn delete(pool: &PgPool, section_id: ProjectSectionId) -> Result<()> {
99 99 }
100 100
101 101 /// Reorder sections by setting sort_order from an ordered list of IDs.
102 + ///
103 + /// A single `UNNEST ... WITH ORDINALITY` update: atomic (no partial ordering on
104 + /// failure) and one round-trip instead of one query per section.
102 105 #[tracing::instrument(skip_all)]
103 106 pub async fn reorder(pool: &PgPool, project_id: ProjectId, section_ids: &[ProjectSectionId]) -> Result<()> {
104 - for (i, id) in section_ids.iter().enumerate() {
105 - sqlx::query(
106 - "UPDATE project_sections SET sort_order = $1, updated_at = now() WHERE id = $2 AND project_id = $3",
107 - )
108 - .bind(i as i32)
109 - .bind(id)
110 - .bind(project_id)
111 - .execute(pool)
112 - .await?;
113 - }
107 + let ids: Vec<uuid::Uuid> = section_ids.iter().map(|id| *id.as_uuid()).collect();
108 + sqlx::query(
109 + r#"
110 + UPDATE project_sections AS s
111 + SET sort_order = ord.pos::int - 1, updated_at = now()
112 + FROM UNNEST($1::uuid[]) WITH ORDINALITY AS ord(id, pos)
113 + WHERE s.id = ord.id AND s.project_id = $2
114 + "#,
115 + )
116 + .bind(&ids)
117 + .bind(project_id)
118 + .execute(pool)
119 + .await?;
114 120
115 121 Ok(())
116 122 }
@@ -40,8 +40,6 @@ pub struct HeldVersionRow {
40 40 pub scan_layers: Option<serde_json::Value>,
41 41 }
42 42
43 - /// Insert a scan result record for audit trail.
44 - #[tracing::instrument(skip_all)]
45 43 /// Remove every CDN-served image reference to `s3_key`, across the image
46 44 /// surfaces that have no per-row scan gate: the gallery tables
47 45 /// (`item_images.s3_key`, `project_images.s3_key`, `content_insertions.storage_key`)
@@ -158,6 +156,8 @@ pub async fn set_cdn_image_scan_status_by_key(
158 156 Ok(affected)
159 157 }
160 158
159 + /// Insert a scan result record for audit trail.
160 + #[tracing::instrument(skip_all)]
161 161 pub async fn insert_scan_result(
162 162 db: &PgPool,
163 163 s3_key: &str,
@@ -17,6 +17,12 @@ pub fn hash_api_key(api_key: &str) -> String {
17 17 }
18 18
19 19 /// Create a new sync app. Stores the hashed API key and prefix.
20 + ///
21 + /// Creates the app row AND its `sync_app_usage_current` row in one transaction.
22 + /// Every billing/blob path (`confirm_developer_blob`, key claim/release, egress)
23 + /// does `SELECT ... FOR UPDATE` on that usage row and errors if it's missing, so
24 + /// an app without one is unusable. Migration 117 seeded the row for apps that
25 + /// existed then; this keeps every app created since in the same state.
20 26 #[tracing::instrument(skip_all)]
21 27 pub async fn create_sync_app(
22 28 pool: &PgPool,
@@ -28,6 +34,8 @@ pub async fn create_sync_app(
28 34 ) -> Result<DbSyncApp> {
29 35 let key_hash = hash_api_key(api_key);
30 36 let key_prefix = &api_key[..8.min(api_key.len())];
37 +
38 + let mut tx = pool.begin().await?;
31 39 let app = sqlx::query_as::<_, DbSyncApp>(
32 40 r#"
33 41 INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, project_id, item_id)
@@ -41,9 +49,17 @@ pub async fn create_sync_app(
41 49 .bind(key_prefix)
42 50 .bind(project_id)
43 51 .bind(item_id)
44 - .fetch_one(pool)
52 + .fetch_one(&mut *tx)
53 + .await?;
54 +
55 + sqlx::query(
56 + "INSERT INTO sync_app_usage_current (app_id) VALUES ($1) ON CONFLICT (app_id) DO NOTHING",
57 + )
58 + .bind(app.id)
59 + .execute(&mut *tx)
45 60 .await?;
46 61
62 + tx.commit().await?;
47 63 Ok(app)
48 64 }
49 65