| 1 |
# MNW Server — Todo |
| 2 |
|
| 3 |
**Last updated:** 2026-05-31 late evening (post Run #9 — launch-eve pass). |
| 4 |
|
| 5 |
## Status |
| 6 |
|
| 7 |
All 5 axes at A- after Run #9 fixes. **0 CRITICAL open · 1 SERIOUS open (deferred) · 3 HIGH open (deferred) · 7 MED open (deferred).** Launchplan §1.5 A- bar holds. See `docs/audit_review.md` Run #9 section for full triage. |
| 8 |
|
| 9 |
## Run #9 — fixed this session (2026-05-31) |
| 10 |
|
| 11 |
- **UX-CRITICAL** Signup TOCTOU 23505 → 500 + form loss. `join_wizard.rs`: catch 23505 with constraint-name routing, surface as `return_error`. Follow-up: preserve typed form fields on error swap (Phase 4). |
| 12 |
- **Sec-SERIOUS** `delete_all_sessions_for_user` non-atomic JWT bump → wrapped in `pool.begin()` / `tx.commit()` (`db/sessions.rs:247`). |
| 13 |
- **Sec-SERIOUS** 2FA login-email IP spoofable via bare `x-forwarded-for` → swapped to `crate::helpers::extract_client_ip` (`routes/pages/public/two_factor.rs:308`). |
| 14 |
- **Pay-SERIOUS** Webhook dual-failure 503 short-circuited on Stripe retry → call `unmark_event_processed` before returning 503 (`routes/stripe/webhook/mod.rs:81`). |
| 15 |
|
| 16 |
`cargo check --tests` clean; targeted unit tests (sessions/webhook/two_factor/join_wizard) 33/33 green. Full DB-integration suite needs astra postgres. |
| 17 |
|
| 18 |
## Run #9 — deferred with rationale (Phase 4) |
| 19 |
|
| 20 |
- [ ] **Pay-SERIOUS** Subscription webhook out-of-order events resurrect `active`. Needs `created`-timestamp re-extraction from `UntypedEvent` + `WHERE last_event_at <= $created` guards across Fan+/creator-tier/synckit subscription writes. Cross-cutting; worst case is minutes-window of restored access until next webhook. |
| 21 |
- [ ] **Sto-HIGH** Migration 129 dead-letter table never written (`cleanup.rs:453`). Operational visibility, not runtime; one-INSERT fix. |
| 22 |
- [ ] **Perf-HIGH** Per-request `reqwest::Client::new()` in 5 hot paths (dashboard/main, public/landing, api/internal/cli_features, api/domains, auth.rs). Hoist to OnceLock or AppState pooled client. |
| 23 |
- [ ] **Perf-HIGH** Unbounded `tokio::spawn` in `cleanup.rs:215-220` `spawn_expired_account_cleanups`. Lift existing `CLEANUP_PARALLELISM=4` JoinSet pattern from `cleanup_sandbox_accounts` 100 lines above. |
| 24 |
- [ ] **Pay-MED** `pricing.rs::parse_dollars_to_cents` strips European decimal comma; `1,23` → 12300¢. |
| 25 |
- [ ] **Pay-MED** SyncKit app-sub checkout silently defaults `storage_limit_bytes` to 0 if metadata missing. |
| 26 |
- [ ] **Pay-MED** Guest checkout email sentinel `"unknown@guest"` collision risk. |
| 27 |
- [ ] **Sto-MED** `is_s3_key_live` 7 EXISTS subqueries on unindexed s3_key columns — sequential scans per retry. Add partial indexes WHERE NOT NULL. |
| 28 |
- [ ] **Sto-MED** `is_s3_key_live` LIKE suffix `'%' || s3_key` false-positives on neighboring keys → S3 object leaks. Anchor with `/`. |
| 29 |
- [ ] **UX-MED** `purchase.html:145` `?return_to=` dead-wired; login handler always redirects `/dashboard`. |
| 30 |
- [ ] **UX-MED** Admin user filter buttons (`admin-users.html:35-44`) use `class="primary"` instead of `btn-primary` — renders unstyled. |
| 31 |
|
| 32 |
## Run #9 — LOW/NOTE (carry forward) |
| 33 |
|
| 34 |
- [ ] **UX-LOW** Pagination links in `git/issues.html:72,76` don't URL-encode `search` param. |
| 35 |
- [ ] **UX-LOW** 5 sites use `.render().unwrap_or_default()` on Askama templates — blank UI on render failure, no log line. |
| 36 |
- [ ] **UX-LOW** `slugify` (`formatting.rs:85`) produces `"post"` for any non-ASCII title. |
| 37 |
- [ ] **Sec-MINOR** `csrf.rs:176-185` `validate_token_consuming` doesn't actually consume — rename or rotate. |
| 38 |
- [ ] **Sec-MINOR** `routes/oauth.rs:101-111` `is_localhost_redirect` allows any port regardless of registered URI. |
| 39 |
- [ ] **Sec-MINOR** `scanning/archive.rs:124` path-traversal check misses lone `..` segment (no trailing `/`). |
| 40 |
- [ ] **Perf-LOW** `db/page_views.rs` `pending` HashMap has no max-cardinality cap. |
| 41 |
- [ ] **Perf-LOW** `build_runner.rs:441` artifact tmpfile leaks if process crashes between SCP and `remove_file`. |
| 42 |
|
| 43 |
Live state: working tree has 104+ Run #8 files plus 4 Run #9 files (`join_wizard.rs`, `sessions.rs`, `two_factor.rs`, `webhook/mod.rs`, `docs/audit_review.md`, `todo.md`). |
| 44 |
|
| 45 |
## Open before launch (Monday 2026-06-01) |
| 46 |
|
| 47 |
### Platform-as-product audits (skill-driven, code-review scope; fresh context recommended) |
| 48 |
- [ ] `/creator-fuzz` — would a working creator trust this with their livelihood? |
| 49 |
- [ ] `/use-fuzz` — discoverability, learnability, first-five-minutes |
| 50 |
- [ ] `/business-fuzz` — pricing copy, fee surfacing, refund-policy wording vs actual platform behaviour |
| 51 |
|
| 52 |
### Per-project hygiene (manual, my call when ready) |
| 53 |
- [ ] README first-screen audit — what is this / who is it for / where to get it / what does it cost. No headliner paragraphs. |
| 54 |
- [ ] `Cargo.toml` version bump for the launch deploy (pick the number; I do the edit if needed) |
| 55 |
- [ ] CHANGELOG entry for the launch version |
| 56 |
|
| 57 |
### Monday browser/prod testing (saved for Monday per current direction) |
| 58 |
- [ ] §1.1 Walk every public page: footer present, OG/Twitter meta render correctly in Facebook + Twitter debuggers, error pages render via forced 404/403/500 |
| 59 |
- [ ] §1.2 First-run creator flow end-to-end in production: signup → Stripe Connect → first item upload |
| 60 |
- [ ] §1.3 Each seeded creator's `/{handle}` page renders without empty sections; sample item per medium (audio/video/text/download) reachable from `/discover` |
| 61 |
- [ ] §1.4 Production deploy of post-fuzz build + version recorded via `record_deploy`; scheduled jobs running on prod (cleanup, scan jobs retention, build reaper, broadcast fan-out); Stripe webhook reachable from dashboard ping; backup snapshot taken pre-launch + restoration path documented in `_private/docs/mnw/server-docs/`; `/health` green |
| 62 |
- [ ] §5 launch-day sequence: final deploy, smoke-test logged-out from non-dev machine, update bios/link-in-bio/handles, confirm `maxj.phd` resolves, tag launch commit (`git tag launch-2026-06-01`) |
| 63 |
|
| 64 |
## Open question for the user (action before Monday) |
| 65 |
|
| 66 |
- [ ] **Confirm all role-based email addresses route to real mailboxes**: `info@`, `security@`, `dmca@`, `privacy@`, `dpo@`, `legal@`, `billing@`, `policy@`, `reports@`, `community@`, `appeals@`, `press@`, `noreply@`. Legal pages (terms, privacy, copyright, appeals) and several role-routed flows reference them. If any are aspirational, that's a launch risk for the legal pages and an inbound-mail blackhole. Verify with Postmark/forwarding setup. |
| 67 |
|
| 68 |
## Deferred with rationale (no action; documented) |
| 69 |
|
| 70 |
- [ ] `build_runner.rs:151` serial-target loop. LOW; builds run rarely; refactor touches denominator + error aggregation + log order. Post-launch. |
| 71 |
- [ ] `scheduler/mod.rs:92-279` advisory-lock per-tier granularity. Multi-replica concern; defer until multi-replica is real. |
| 72 |
- [ ] Drop unused `completion_effects` table (migration cleanup, schema-only). |
| 73 |
- [ ] Templatize founder + standard annual prices in `tiers.md` and `pricing.md` (e.g. `$86/yr`, `$130/yr`, `$194/yr`, `$324/yr`; standard `$173/$259/$389/$648`). docengine substitutions don't support arithmetic; would require adding derived `tiers.founding.basic_annual` etc keys in `shared/docengine/src/assumptions.rs`. Not blocking. |
| 74 |
- [ ] `_head_assets.html` apple-touch-icon + manifest link wiring. `static/manifest.json` exists but the `<link rel="manifest">` was reverted; bring back if/when desired. |
| 75 |
- [ ] Migrate footer's `What's new` and `Shortcuts` `<a href="#" onclick="...">` to `data-*` attributes following the `data-copy-link` pattern. UX MED, not blocking. |
| 76 |
|
| 77 |
## What's done this session (compact summary, full details below) |
| 78 |
|
| 79 |
- **Ultra Fuzz Run #8** — all 5 axes A-. SERIOUS webhook unbounded spawn closed via new `src/background.rs` (bounded mpsc + semaphore-bounded concurrent execution). `spawn_email!` macro migrated; 17 callers + 5 manual webhook spawns + 5 same-disease per-request email spawns now route through bg queue. Run #8 5 new MEDs all closed (cart `min_price_cents`, cart-all chain-break, item-wizard `pricing_model`, inline-JS templates, cart free-claim N+1). Previously-deferred Payments H2 `claim_free_project` race closed. |
| 80 |
- **7-wave backlog sweep** — 24 of 26 carried items across auth/security/scanning/db/storage/UX/perf/payments. New schema migration `133_items_duration_seconds_nonnegative.sql`. New `commit_rescan` helper extends chronic-disease seal to admin paths. Two LOW items deferred above. |
| 81 |
- **4 cross-cutting sweeps** — `info@makenot.work` email pin (8 files), localhost/TODO/emoji/secret scans all clean. |
| 82 |
- **§1.1 public-surface code work** — OG + Twitter card meta in `base.html` (per-page overridable blocks), `static/manifest.json` created with brand colours, `error.html` drops broken back button + adds contact link, `Contact` link added to footer (mailto:info@), new `routes/pages/public/sitemap.rs` (with in-memory 10-min cache + LIKE-wildcard escape from the security review). |
| 83 |
- **Doc-fuzz** — `content-scanning.md` restructured (Malware checks + Authenticity checks sections, added URLhaus/MetaDefender/signing layers), `policy.html` See-also block linking 6 legal pages, `tiers.md` prose prices templatized via `{{ tiers.standard.* | int }}`. |
| 84 |
- **Exorcise** — 9 AI-tell removals across compare.md, content-scanning.md, appeals.md, faq.md. |
| 85 |
- **Nitpick** — 2 polish edits (dead `let _ = scan_status` removed, unused tuple-name destructure tidied). |
| 86 |
- **Security review** — 2 MEDs fixed inline: sitemap.xml in-memory cache to absorb crawler/attacker hammering; LIKE-wildcard escape on `is_s3_key_live` to prevent `_` in s3_keys from false-positive matching. |
| 87 |
|
| 88 |
--- |
| 89 |
|
| 90 |
## Ultra Fuzz 2026-05-31 (Run #8 — final re-grade) |
| 91 |
|
| 92 |
### Above-MED items to address before launch (or defer with rationale) |
| 93 |
|
| 94 |
- [x] **Perf SERIOUS — Webhook hot-path unbounded `tokio::spawn`.** Fixed 2026-05-31. New `src/background.rs` with `BackgroundTx` + bounded mpsc (1024) + semaphore-bounded concurrency (8 workers vs 25 pool conns). `spawn_email!` macro refactored to use the bg queue (covers 17 callers). 5 manual webhook spawns migrated (`checkout_helpers.rs:58, 96, 124, 290` + `checkout.rs:618`). Same-disease per-request email spawns also migrated: postmark issue replies (×2), guest-claim email, join-wizard signup (×2). `cargo test --lib` 1654 / 0. Deferred (different shapes): import pipeline (long-running), MT community create (HTTP not pool), departure/status broadcast (broadcast-class), idempotency store (trivial). |
| 95 |
|
| 96 |
### New MED-tier findings (all closed 2026-05-31) |
| 97 |
|
| 98 |
- [x] **Payments MED — Cart `min_price_cents` bypass.** Fixed. Both cart paths (`process_seller_checkout` and `create_cart_checkout`) now check `pc.min_price_cents` for non-platform Discount codes before applying. Skips the ineligible item (others may still qualify) rather than rejecting the whole cart — matches the existing scope-skip pattern. |
| 99 |
- [x] **Payments MED — Cart-all chain-break on all-free first seller.** Fixed. `process_seller_checkout` signature changed `Result<String>` → `Result<Option<String>>`; all-free path now returns `Ok(None)` instead of `Err(BadRequest)`. New `drain_to_paid` helper loops through the queued sellers until a paid one is reached (returns URL) or queue exhausted (returns `Ok(None)` → library redirect). Both callers (`create_cart_checkout_all` and `checkout_success`) updated. |
| 100 |
- [x] **UX MED — Item wizard `pricing_model` silent fallback.** Fixed. `save_pricing` now rejects missing pricing_model with `AppError::validation("Select a pricing model")` and rejects unknown values with `format!("Unknown pricing model: {other}")`. Same shape as project wizard Run #6 fix. |
| 101 |
- [x] **UX MED — Inline-JS template duplication.** Fixed. Added delegated `data-copy-link` handler to `static/mnw.js` with proper `.catch()` (falls back to `window.prompt` in non-secure contexts). 8 templates migrated from `onclick="navigator.clipboard.writeText(...).then(...)"` to `<a href="..." data-copy-link>Copy link</a>` (audio_player, blog_post, collection, item, project, text_reader, user, video_player). `href` is the real URL so middle-click / no-JS / share menus still work. Cache-bust bumped to `v=0531`. |
| 102 |
- [x] **Perf MED — Cart free-claim N+1.** Fixed. Extended `CartItem` with `enable_license_keys` + `default_max_activations` (both cart queries pull them through). Three free-claim loops (single-seller paid path, discount-zeroed promo path, chain-flow path) drop the per-item `get_item_by_id` (saves N roundtrips) and replace per-item `remove_from_cart` DELETE with a single bulk `remove_from_cart_bulk(..., ANY($2))` at the end of each loop. Per-item tx for `claim_free_item` stays (per-item claim-vs-already-purchased return value). Roundtrips per free item: was ~5-7 → now ~3-4; bulk delete = +1 roundtrip total per loop (was N). |
| 103 |
|
| 104 |
All 5 MEDs landed. `cargo test --lib` 1654 / 0. |
| 105 |
|
| 106 |
### Verified closed this run |
| 107 |
|
| 108 |
- [x] **Storage H1** — `confirm_upload` silent zero-rows + side-effects-already-fired (uploads.rs:295-337). Three-arm match, zero-rows arm rolls back storage + enqueue_s3_orphan. |
| 109 |
- [x] **Storage S1** — `media_confirm` three-write atomicity (media.rs:241-293). Single tx wraps storage credit + pending_uploads clear + media_files INSERT. |
| 110 |
- [x] DB helpers genericized to `impl PgExecutor<'e>` — all 12 callers (including `synckit/blobs.rs:157`) verified backwards-compatible. |
| 111 |
|
| 112 |
### Storage A- standing — remaining MED/LOW (Phase 4 polish or defer) |
| 113 |
Carried from Storage code-fuzz 2026-05-31 — see below. All still MED, none A- blockers. |
| 114 |
|
| 115 |
--- |
| 116 |
|
| 117 |
## Audit backlog sweep 2026-05-31 (post-Run #8, 7 waves) |
| 118 |
|
| 119 |
Sorted by file locality and difficulty. Tests: 1655 / 0 throughout. |
| 120 |
|
| 121 |
### Wave 1 — auth/security cluster (8 tiny) |
| 122 |
- [x] `synckit_auth.rs:147` `<` → `<=` closes 1-second JWT-revocation collision window. |
| 123 |
- [x] `routes/auth.rs:128, 137` malformed-email + invalid-username branches now run DUMMY_HASH equalizer — closes timing oracle. |
| 124 |
- [x] `routes/auth.rs:331-336` `validate_username` length switched from `len()` bytes to `chars().count()` — multi-byte usernames treated correctly. |
| 125 |
- [x] `git_ssh.rs:162` `parse_repo_path` rejects lone-dot segments. |
| 126 |
- [x] `routes/oauth.rs:206` `validate_token` → `validate_token_consuming` (sealed witness type). |
| 127 |
- [x] `routes/oauth.rs:213-222` OAuth `state` ≤ 1024 bytes + `code_challenge` ≤ 44 chars. |
| 128 |
- [x] `helpers.rs::ip_advisory_lock_key` `DefaultHasher` → SHA-256 (stable across Rust versions). |
| 129 |
- [x] `helpers.rs::extract_client_ip` one-shot WARN after 100 cumulative missing `cf-connecting-ip` requests. |
| 130 |
|
| 131 |
### Wave 2 — scanning (3) |
| 132 |
- [x] `scanning/clamav.rs::ping` + `ScanPipeline::assert_live` at startup; refuses to boot if scanning configured but no AV layer live. |
| 133 |
- [x] `scanning/clamav.rs` 16 KB INSTREAM truncation → `LayerVerdict::Fail` (was Error → FailOpen → Pass). |
| 134 |
- [x] `scanning/worker.rs:251` inline media UPDATE swapped to `db::scanning::update_media_file_scan_status` helper. |
| 135 |
|
| 136 |
### Wave 3 — DB layer polish (4) |
| 137 |
- [x] `db/pending_uploads.rs::remove_pending_upload` signature now requires `user_id`; 12 callers updated. |
| 138 |
- [x] `db/pending_s3_deletions.rs::is_s3_key_live` now covers `projects.cover_image_url` and `items.cover_image_url` via `LIKE %s3_key`. |
| 139 |
- [x] `db/projects.rs::update_project_image_url` returns `Result<bool>`; `images.rs::project_image_confirm` three-arm match fires rollback + orphan-queue on `Ok(false)`. |
| 140 |
- [x] `db/items/media.rs::update_item_cover` same shape; same caller treatment in `images.rs::item_image_confirm`. |
| 141 |
|
| 142 |
### Wave 4 — storage handlers + admin rescan seal + downloads (5) |
| 143 |
- [x] **Migration 133** `items_duration_seconds_nonnegative.sql` CHECK on `duration_seconds` + `video_duration_seconds`. |
| 144 |
- [x] `routes/storage/downloads.rs:120` defensive clamp: `duration.max(0) as u64 → saturating_mul(2) → clamp(3600, 86_400)`. |
| 145 |
- [x] `routes/storage/mod.rs::commit_rescan` new sibling to `commit_upload` for admin-rescan paths. |
| 146 |
- [x] `routes/admin/uploads.rs::rescan_{version,item}_inner` migrated to `commit_rescan`; chronic-disease seal now covers admin paths. |
| 147 |
- [x] `routes/pages/dashboard/wizards/item/save.rs:95` wizard `update_item_cover_image_url` call dropped (confirm authoritative, hidden-field desync risk closed). |
| 148 |
- [x] `routes/storage/mod.rs` `enqueue_s3_orphan` doc rewritten to match reality (post-credit failures only). |
| 149 |
|
| 150 |
### Wave 5 — UX polish (2) |
| 151 |
- [x] `pricing.rs::parse_dollars_to_cents` strips `$`, `,`, whitespace; new `strips_clipboard_decoration` test. |
| 152 |
- [x] `routes/admin/users.rs:37, 77` page `clamp(1, 1_000_000_000)` to prevent OFFSET overflow → sqlx 500. |
| 153 |
|
| 154 |
### Wave 6 — Performance (3 of 5; 2 deferred) |
| 155 |
- [x] `metrics::idempotency_middleware` in-memory negative cache (OnceLock<DashMap>, 60s TTL, periodic GC). Skips per-POST `get_cached_response` SELECT for keys recently confirmed not-cached. |
| 156 |
- [x] `monitor.rs` `record_storage_fill_stats` gated by 5-min TTL — 60× reduction at 10k+ creators. |
| 157 |
- [x] `scheduler/cleanup.rs::cleanup_sandbox_accounts` serial loop → JoinSet `CLEANUP_PARALLELISM=4`. |
| 158 |
- [ ] **DEFERRED** `build_runner.rs:151` serial-target loop. LOW; refactor touches denominator + error agg + log order. Post-launch. |
| 159 |
- [ ] **DEFERRED** `scheduler/mod.rs:92-279` advisory-lock granularity. Multi-replica concern; defer until multi-replica is real. |
| 160 |
|
| 161 |
### Wave 7 — Payments LOW (2) |
| 162 |
- [x] `routes/stripe/webhook/mod.rs:73-87` `insert_failed_event` failure → 503 (Stripe redelivers) instead of dropping event with 200. |
| 163 |
- [x] `routes/stripe/checkout/cart.rs:73-82, 535-543` `remove_from_cart` already-owned cleanup now logs WARN on Err. |
| 164 |
|
| 165 |
--- |
| 166 |
|
| 167 |
## Storage code-fuzz 2026-05-31 (post-Run #7) |
| 168 |
|
| 169 |
Targeted Storage-axis fuzz to verify A- before triggering full Run #8. |
| 170 |
|
| 171 |
### Above-MED fixes that landed |
| 172 |
- [x] **Storage HIGH — `confirm_upload` silent zero-rows + side-effects-already-fired.** `routes/storage/uploads.rs:295-336`. Three-arm match on UPDATE result; zero-rows case rolls storage back, routes new S3 key through `enqueue_s3_orphan`, returns BadRequest. Same shape as Run #7 HIGH-2, one step further along the same handler family. |
| 173 |
- [x] **Storage SERIOUS — `media_confirm` three-write atomicity (Run #5 plan #12 reopened).** `routes/storage/media.rs:235-294`. Three writes (storage credit + pending_uploads clear + media_files INSERT) now in a single tx; tx drop rolls all three back on interruption. Only S3 object needs explicit cleanup. 23505 duplicate-filename detection moved outside the tx — same SQLSTATE check, runs after rollback. |
| 174 |
- [x] DB-layer support: `creator_tiers::try_increment_storage_on(&mut PgConnection)` new tx-friendly variant; `pending_uploads::remove_pending_upload` and `media_files::create` signatures genericized to `impl PgExecutor<'e>` (backwards compatible — all existing `&PgPool` call sites still compile). |
| 175 |
|
| 176 |
### Remaining MED/LOW (below A- bar; defer or Phase 4 polish) |
| 177 |
- [ ] Storage MED — `update_project_image_url` / `update_item_cover` ignore `rows_affected()`. Same shape as H1 but only follow-on side-effect is `bump_cache_generation`, so blast radius is small. |
| 178 |
- [ ] Storage MED — `downloads.rs:120` `((duration as u64) * 2).max(3600)` with no DB CHECK on `duration_seconds`. Add `CHECK (duration_seconds >= 0)` migration + cap in code (`duration.max(0).saturating_mul(2).clamp(3600, 86400)`). |
| 179 |
- [ ] Storage MED — Admin rescan (`routes/admin/uploads.rs:347, 390`) bypasses `commit_upload` seal via direct `db::scan_jobs::enqueue`. Demote to `pub(crate)` and expose `commit_rescan(target, ...)`. |
| 180 |
- [ ] Storage MED — `enqueue_s3_orphan` single-policy doc overstates discipline; either tighten doc or migrate remaining direct `delete_object` cleanup sites. |
| 181 |
- [ ] Storage MED — `is_s3_key_live` doesn't enumerate project image URLs (no current bug; surface fragile). |
| 182 |
- [ ] Storage LOW — `scanning/worker.rs:251` inline UPDATE bypasses `db::scanning::update_media_file_scan_status` helper. |
| 183 |
- [ ] Storage LOW — wizard `save.rs:95` updates only `cover_image_url` (not s3_key/size). |
| 184 |
- [ ] Storage LOW — `pending_uploads::remove_pending_upload` deletes by s3_key alone (signature broader than needed). |
| 185 |
|
| 186 |
--- |
| 187 |
|
| 188 |
## Ultra Fuzz 2026-05-31 (Runs #6, #7 + S1) |
| 189 |
|
| 190 |
### Structural / chronic-disease fixes that landed |
| 191 |
- [x] `routes/storage/mod.rs::commit_upload(target, ...)` + `CommitTarget` enum; `enqueue_scan_for` demoted to module-private. All 7 confirm handlers (uploads, versions, project_image, item_image, media, internal/uploads, content_insertions) converted. |
| 192 |
- [x] `crate::pricing::parse_dollars_to_cents` + `validate_dollars_f64` shared helpers; 5 callsites converted (item save, project wizard ×2, bulk price, projects API). |
| 193 |
- [x] Dead `completion_effects` outbox deleted (`db/completion_effects.rs`, `scheduler/completion_effects.rs`, `routes/stripe/webhook/effects.rs` were orphaned files, no module declarations). Migrations 124/125 left in place — empty table, harmless. Drop-table migration is a future cleanup. |
| 194 |
|
| 195 |
### Bug-level fixes that landed |
| 196 |
- [x] Storage CRIT Run #6 — `enqueue_s3_orphan` wired at `uploads.rs:325` post-commit old-key delete. |
| 197 |
- [x] Storage HIGH Run #6 — `images.rs::project_image_confirm` idempotency check added. |
| 198 |
- [x] Storage HIGH Run #6 — `images.rs::item_image_confirm` scan-ordering fixed via commit_upload. |
| 199 |
- [x] Storage HIGH Run #7 — 4 idempotent-early-return sites now call `remove_pending_upload` before returning (uploads.rs, versions.rs, images.rs project + item). |
| 200 |
- [x] Storage HIGH Run #7 — `update_project_image_url` + `update_item_cover` failures now refund storage + queue new key for orphan deletion. |
| 201 |
- [x] Storage MED Run #6 — `media_delete` enqueue moved after `tx.commit()`. |
| 202 |
- [x] UX HIGH Run #6 — `routes/api/projects.rs` float-parse via `validate_dollars_f64`. |
| 203 |
- [x] UX HIGH Run #6 — project wizard tier-row loop bubbles errors instead of silently `continue`-ing. |
| 204 |
- [x] UX HIGH Run #7 — `pricing_model` silent fallback to Free fixed; missing/malformed now rejects. |
| 205 |
- [x] Payments S Run #6 — promo `try_increment_use_count` moved after Stripe-readiness checks in item.rs, cart.rs::create_cart_checkout, and cart.rs::process_seller_checkout. |
| 206 |
- [x] Payments S Run #6 — `process_seller_checkout` uses bulk `purchased_subset`. |
| 207 |
- [x] Payments H Run #6 — `project_members::add_project_member` + `update_member_split` reject split_percent outside [0,100]; upsert subtracts existing row before cap check. |
| 208 |
- [x] Payments H Run #6 — `CodePurpose::Discount` with NULL discount_type/value rejected at validation (item.rs, cart.rs:184, cart.rs::process_seller_checkout — third copy fixed in Run #7). |
| 209 |
- [x] Payments H Run #6 — free PWYW project checkout clears contact revocation when share_contact=true. |
| 210 |
- [x] Payments SERIOUS Run #7 — cart 23505 swallow → buyer charged for unfulfilled items. New `db::transactions::pending_subset` bulk pre-check; both cart paths pre-check before Stripe session; remaining 23505 catch is now hard-error (release promo + abort). |
| 211 |
|
| 212 |
### Deferred (with rationale) |
| 213 |
- [x] **Payments H2 (Run #7) — `claim_free_project` race.** Fixed 2026-05-31. `db::transactions::claim_free_project` now returns `Result<bool>` (mirrors `claim_free_item`). Caller in `routes/stripe/checkout/project.rs` gates `clear_contact_revocation` on the `claimed` winner — the loser of a concurrent-claim race no longer fires the side-effect. Same shape ready for any future side-effects added below the claim (sale-notification email, split recording, etc). |
| 214 |
- [ ] Drop unused `completion_effects` table — schema-only cleanup; harmless empty table. |
| 215 |
|
| 216 |
### Notes on remaining MED/LOW (per Run #7 axis reports) |
| 217 |
- Storage MED — admin rescan handlers (`routes/admin/uploads.rs:347, 390`) still call `enqueue_scan_for` indirectly via lower-level primitives; functional today but bypasses the chronic-disease seal. |
| 218 |
- Storage MED — `update_item_cover` / `update_project_image_url` don't check `rows_affected()`; an ownership-filter mismatch returns Ok(0 rows) silently. |
| 219 |
- Storage MED — worker inline media UPDATE at `scanning/worker.rs:251` should use the new `db::scanning::update_media_file_scan_status` helper. |
| 220 |
- Storage LOW — internal CLI confirm drops returned `FileScanStatus` (no `pending_review` surfacing). |
| 221 |
- Storage LOW — `main.rs:334` comment references now-private `enqueue_scan_for`. |
| 222 |
- UX MED — `parse_dollars_to_cents` rejects `"$5"` and `"1,000"` literally; could strip `$`/`,` for clipboard-paste UX. |
| 223 |
- UX MED — project wizard skips `validate_tier_price` ($1–$10k); API path enforces it. |
| 224 |
- UX LOW — `BundleItemIds.filter_map` silently drops malformed UUIDs. |
| 225 |
- Payments M1 — `compute_splits` should `.max(0)` per-member for defense vs legacy negative `split_percent` rows. |
| 226 |
- Payments NIT — extract `require_stripe_ready` helper; six near-identical 5-line blocks across checkout files. |
| 227 |
|
| 228 |
--- |
| 229 |
|
| 230 |
## Ultra Fuzz 2026-05-30 (Run #5) |
| 231 |
|
| 232 |
## Ultra Fuzz 2026-05-30 (Run #5) |
| 233 |
|
| 234 |
Full report: `docs/audit_review.md`. 3 CRITICAL, 14 HIGH/SERIOUS. Two-axis regressions (Payments B, Storage B-) are coverage expansion into previously-unaudited paths plus one chronic recurrence; Security improved to A-; all 27 Run #4 plan items verified closed. |
| 235 |
|
| 236 |
### Phase 1 — CRITICAL (fix today) |
| 237 |
|
| 238 |
- [ ] **Storage CRIT — `uploads.rs` file-type gate ordering** — `routes/storage/uploads.rs:204-237`. Move the match-arm rejection of `Download`/`Insertion`/`MediaImage`/`MediaVideo` BEFORE `enqueue_scan_for` and `update_item_scan_status`. Then make `enqueue_scan_for` + `update_*_scan_status` `pub(crate)` and expose a `commit_upload(file_type, item_id, s3_key)` higher-level op used by all three handlers (uploads / versions / images). Closes Phase 5 chronic invariant-in-prose finding. |
| 239 |
- [ ] **UX CRIT — Field-aware validation reaches the UI** — `error.rs:216-264` + `templates/error.html`. Either add `fields: Vec<(String, String)>` to `ErrorTemplate` + per-input markup in templates, OR delete the `validation_fields*` API and migrate callers to `validation(summary)`. Audit `validation_fields` callsites and pick a path. |
| 240 |
- [ ] **Perf CRIT — `build_runner.rs` partial-failure denominator** — `build_runner.rs:175-180`. Track `failed_count`; report `succeeded/(succeeded+failed)`. Add a test with 3 targets / 2 failures asserting "1/3". |
| 241 |
|
| 242 |
### Phase 2 — SERIOUS / HIGH (fix this weekend) |
| 243 |
|
| 244 |
- [ ] **Payments SERIOUS — NULL `item_id` refund decode bomb** — `db/transactions.rs:699-716`. Return `Vec<(TransactionId, Option<ItemId>)>`; skip `decrement_sales_count`/`revoke_keys_by_transaction` when None. Fixture test against a project-level transaction. |
| 245 |
- [ ] **Payments SERIOUS — `compute_splits` over-credit on members > 100%** — `routes/stripe/webhook/checkout_helpers.rs:240-269`. Reject `total_split_pct > 100` at the project_members write site (DB CHECK + validation). Defensively scale or clamp each split. Add test at 60%+60%. |
| 246 |
- [ ] **Payments SERIOUS — Tip `project_id` not validated vs recipient** — `routes/stripe/checkout/tips.rs:104-106`. After form accept, assert `project.user_id == recipient_id`; 400 otherwise. |
| 247 |
- [ ] **Payments SERIOUS — Cart bypasses item `listed` gate** — `db/cart.rs:94-123` + `get_cart_items` + `get_cart_items_for_seller`. Add `AND i.listed = true` to all three. Add per-seller checkout path check. Regression test: toggle unlisted item into cart → rejection. |
| 248 |
- [ ] **Payments SERIOUS — Unknown subscription status retry storm** — `routes/stripe/webhook/subscriptions.rs:117-121`. Replace `?` with a match: known statuses dispatch; unknown statuses `tracing::warn!` and return 200 OK so Stripe stops retrying. |
| 249 |
- [ ] **Payments SERIOUS — `is_full_refund` zero-amount** — `payments/webhooks.rs:294-308`. Predicate becomes `amount > 0 && amount_refunded >= amount`. Invert the test at line 517-525. |
| 250 |
- [ ] **Storage HIGH — `versions.rs` enqueue-before-idempotency** — `routes/storage/versions.rs:159-174`. Move `version.s3_key == req.s3_key` idempotency check before `enqueue_scan_for`. Apply Phase 1 `commit_upload` helper. |
| 251 |
- [ ] **Storage HIGH — `project_image_confirm` probe-failure + no rollback** — `routes/storage/images.rs:179-208`. On `Err`/`Ok(None)` from `s3.object_size`, fall back to recorded size. Move `enqueue_deletions` AFTER `update_project_image_url` success, or wrap in a tx. |
| 252 |
- [ ] **Storage HIGH — `media_confirm` non-atomic three-write** — `routes/storage/media.rs:236-293`. Wrap `try_increment_storage` → `remove_pending_upload` → `media_files::create` in a transaction. Refund storage credit on any failure. |
| 253 |
- [ ] **UX HIGH — Negative/zero prices via `PriceCents::from_db`** — `routes/pages/dashboard/wizards/item/save.rs:183-185, 214-227`. Use `PriceCents::new(price_cents)?` unconditionally; drop `> 0` guard. Add `min <= suggested` check on PWYW. |
| 254 |
- [ ] **UX HIGH — f64 price parsing accepts NaN/saturates** — same file + `routes/api/items/bulk.rs:136-139` + `routes/pages/dashboard/wizards/project.rs:264-298`. Parse as decimal cents (`rust_decimal::Decimal::from_str_exact`); reject NaN/Inf/out-of-range before cast. |
| 255 |
- [ ] **UX HIGH — Username live-check fails open on DB error** — `routes/auth.rs:356-361`. Propagate error or treat as "unavailable, try again". |
| 256 |
- [ ] **Perf HIGH — Cart checkout 80 sequential roundtrips** — `routes/stripe/checkout/cart.rs:68-248`. Bulk-load `has_purchased_item` with `WHERE item_id = ANY($1)`. Batch `get_item_by_id`. Claim free items in one tx with batched inserts. Target ≤ 5 roundtrips for any cart size. |
| 257 |
- [ ] **Perf HIGH — `record_view` unbounded spawn per request** — `db/page_views.rs:18-32`. Replace per-request spawn with `mpsc` channel + single background drainer flushing every 250ms via bulk UPSERT. |
| 258 |
- [ ] **Perf HIGH — `check_sales_count_drift` full-table aggregate** — `scheduler/integrity.rs:53-73`. Add `WHERE i.sales_count > 0 OR EXISTS(SELECT 1 FROM transactions WHERE item_id = i.id LIMIT 1)` short-term; long-term trigger-maintained counts. |
| 259 |
|
| 260 |
### Phase 3 — MED (fix before Run #6 if cheap) |
| 261 |
|
| 262 |
- [ ] Storage: advisory-lock leak in `check_sandbox_cap` (`db/mod.rs:92-128`) → `pg_advisory_xact_lock` or RAII guard. |
| 263 |
- [ ] Storage: `is_s3_key_live` missing tables (`db/pending_s3_deletions.rs:67-82`). |
| 264 |
- [ ] Storage: `delete_version` owner SELECT outside tx + post-commit S3 enqueue (`db/versions.rs:267-315`). |
| 265 |
- [ ] Security: ClamAV `FailOpen` startup assertion (`scanning/clamav.rs:19` + `scanning/mod.rs:151-164`) — refuse boot if scan configured but no AV layer live. |
| 266 |
- [ ] Security: `helpers.rs:44-50` `DefaultHasher` → stable hasher (sha2 first 8 bytes or `xxh3` constant seed). |
| 267 |
- [ ] Security: OAuth `state` size cap (`routes/oauth.rs:379-386`) — reject `> 1024`; cap `code_challenge` at 44 chars. |
| 268 |
- [ ] Security: `extract_client_ip` non-Cloudflare fallback warning (`helpers.rs:33-40`). |
| 269 |
- [ ] UX: pagination offset overflow (`routes/pages/public/discover.rs:85-87`, `routes/admin/users.rs:37-39`). |
| 270 |
- [ ] UX: forms silently render without `_csrf` when handler forgets to populate token — make `csrf_token` non-optional in form-bearing templates. |
| 271 |
- [ ] UX: `validate_username` byte-length vs `chars().count()` (`routes/auth.rs:322`). |
| 272 |
- [ ] Perf: scheduler advisory-lock connection pinned across S3 (`scheduler/mod.rs:92-279`) → dedicated `max_connections(1)` pool. |
| 273 |
- [ ] Perf: cleanup S3 deletes serialized inside scheduler tick (`scheduler/cleanup.rs:77-100`) → `for_each_concurrent(8, ...)`. |
| 274 |
|
| 275 |
### Phase 4 — Polish (after Run #6 confirms ≥ A-) |
| 276 |
|
| 277 |
- [ ] Payments: `has_active_subscription_to_item` period-end clause mirroring (`db/subscriptions.rs:464-470`). |
| 278 |
- [ ] Payments: `get_active_creator_tier` + `sync_user_creator_tier` period-end defense (`db/creator_tiers.rs:91-103, 181-194`). |
| 279 |
- [ ] Payments: `release_use_count` race messaging (`db/promo_codes.rs:184-200`). |
| 280 |
- [ ] Payments: License key `activation_count` recount on revoke (`db/license_keys.rs:343-382`). |
| 281 |
- [ ] Payments: Subscription minimum-charge check (`payments/checkout.rs:283-317`). |
| 282 |
- [ ] Payments: Webhook v1/v2 unmark-on-failure parity (`routes/stripe/webhook/mod.rs:48-86`). |
| 283 |
- [ ] Storage: `media_files.list_folders` scan filter (`db/media_files.rs:73-82`). |
| 284 |
- [ ] Storage: `pending_uploads.record_pending_upload` silent user-mismatch (`db/pending_uploads.rs:23-33`). |
| 285 |
- [ ] Storage: `append_log_bounded` non-atomic size cap (`build_runner.rs:516-534`). |
| 286 |
- [ ] Storage: `downloads.rs:119-122` presigned-URL expiry — cap `duration_seconds` + DB CHECK ≥ 0. |
| 287 |
- [ ] Security: `validate_token_consuming` for OAuth POST (`routes/oauth.rs:206`). |
| 288 |
- [ ] Security: `parse_repo_path` rejects lone-dot entries (`git_ssh.rs:162`). |
| 289 |
- [ ] Security: ClamAV INSTREAM 16K cap → fail-closed on truncation (`scanning/clamav.rs:101-108`). |
| 290 |
- [ ] UX: validation error messages stop reflecting user input (`wizards/item/mod.rs:176-179`). |
| 291 |
- [ ] UX: CSRF body extraction stops using `from_utf8_lossy` (`csrf.rs:528-543`). |
| 292 |
- [ ] Perf: scan-pipeline 400 MiB worst-case capacity note (`constants.rs:156-157`). |
| 293 |
- [ ] Perf: announcement fan-out persistence + resume (`scheduler/announcements.rs:59-89, 147-177`). |
| 294 |
- [ ] Perf: build log per-line DB roundtrip (`build_runner.rs:516-534`). |
| 295 |
|
| 296 |
### Phase 5 — Chronic |
| 297 |
|
| 298 |
- [ ] **Invariant-in-prose, FOURTH consecutive run.** Phase 1 #1 (constructive `commit_upload` helper sealing the lower-level scan/credit/status ops) is the only acceptable resolution. After it lands, audit `compute_splits` (Payments) and `ErrorTemplate` (UX) for the same shape and apply the same treatment. |
| 299 |
|
| 300 |
--- |
| 301 |
|
| 302 |
## Ultra Fuzz 2026-05-26 (Run #4) |
| 303 |
|
| 304 |
Full report: `docs/audit_review.md`. Plan target: lift every axis back to A- or higher (Payments A · Storage A · UX A- · Security A- · Performance A-). |
| 305 |
|
| 306 |
### Phase 1 — clear HIGH/CRITICAL caps (must do before launch) |
| 307 |
|
| 308 |
- [x] **Creator-tier forms missing CSRF token (UX CRITICAL)** — `templates/partials/tabs/user_creator.html:80,85`. Add `{% if let Some(token) = csrf_token %}<input type="hidden" name="_csrf" value="{{ token }}">{% endif %}` to both forms. Verify the page handler populates `csrf_token` in the template context. Path `/stripe/creator-tier` is not in any exempt prefix, so without the token every authenticated click returns 403. |
| 309 |
- [x] **`git_ssh.rs` repo-name validation gap (Security HIGH)** — `git_ssh.rs:90-102` + `db/git_repos.rs:21-35`. Call `validate_git_repo_name(repo_name).map_err(|_| anyhow!("repository not found"))?` immediately after `parse_repo_path` succeeds in the dispatch path; add the same in `db::git_repos::create_repo`. Without this, the raw name flows into `format!("{op} '/{owner}/{repo_name}.git'")` fed to `git-shell -c`. `cmd_ssh_repo_delete` already validates at line 354 — backport. |
| 310 |
- [x] **Login lockout `just_locked` per-attempt email flood (Security HIGH)** — `db/auth.rs:30-54`. Change the `RETURNING` clause from `(failed_login_attempts >= $2) AS just_locked` to `(failed_login_attempts = $2) AS just_locked` (exact equality, fires only on the crossing attempt). Follow-up: idempotency key on the lockout-email outbox so a regression cannot inbox-flood a known email. |
| 311 |
- [x] **`cancel_pending_item_checkout` CSRF gap (UX HIGH)** — `routes/stripe/checkout/item.rs:390-407`. Either narrow the `/stripe/checkout` CSRF exempt prefix so this path isn't in it, or add explicit `csrf::validate_token` like `create_tip_checkout` does in `stripe/checkout/tips.rs:49-50`. Document the rule at the exempt-prefix definition site so future "I'll just add a quick mutation handler" landings fail review. |
| 312 |
- [x] **Promo `use_count` over-release on cart cleanup (Payments SERIOUS)** — `scheduler/cleanup.rs:223` + `db/transactions.rs:1011-1029`. Cart checkouts produce N pending-tx rows carrying the same `promo_code_id`; the loop calls `release_use_count` N times for a single reservation. Either dedupe with `HashSet<PromoCodeId>` before the release loop, or have `cleanup_stale_pending` return `DISTINCT promo_code_id` from the DELETE. |
| 313 |
- [x] **Scanner streams instead of `Vec<u8>` (Storage SERIOUS)** — `storage.rs:179,404,629` + `scanning/worker.rs:175`. Add `download_stream`/`get_object_stream` to the S3 trait returning a `ByteStream`; pipe through ClamAV instead of buffering. A 20 GB media scan currently OOMs the worker; concurrent scans amplify. (plan: `../../_private/docs/mnw/server-docs/plans/scanner-streaming.md`) |
| 314 |
- [x] **`scan_jobs` retention (Perf CRITICAL)** — `db/scan_jobs.rs`. Add a scheduler tier (hourly) that deletes rows older than 30 days where status ∈ {Clean, Quarantined, Failed}. Keep Pending/Running. Default retention constant in `constants.rs`. (plan: `../../_private/docs/mnw/server-docs/plans/scan-jobs-retention.md`) |
| 315 |
- [x] **Scanner DB-pool permit across S3 download (Perf CRITICAL)** — `scanning/worker.rs`. Drop the pool permit before `download_object` (or `download_stream` after the SERIOUS fix above); reacquire after bytes are local. Under any scan backlog the pool starves request traffic today. (plan: `../../_private/docs/mnw/server-docs/plans/scanner-pool-permit.md`) |
| 316 |
- [x] **`broadcast.rs` unbounded sequential loop (Perf HIGH)** — `routes/api/users/broadcast.rs`. Wrap recipient fan-out in `tokio::spawn` with a bounded `JoinSet` (cap 16) over chunks; preserve the 100ms per-recipient SMTP shape. (plan: `../../_private/docs/mnw/server-docs/plans/broadcast-bounded-fanout.md`) |
| 317 |
|
| 318 |
### Phase 2 — close axis-dragging SERIOUS items |
| 319 |
|
| 320 |
- [x] **Cart template price math (UX MED)** — `templates/pages/cart.html:71-76,95,102`. Move `effective_price_cents() / 100` etc. out of templates; pass pre-formatted strings from the handler, or expose `format_price` as an Askama filter. Unify with `format_revenue` so thousands separators match across dashboards (`format_revenue(1_000_000)` returns `"$10000.00"`). |
| 321 |
- [x] **`media.rs` delete-then-reupload race (Storage MED)** — `routes/storage/media.rs:418-456`. Worker must re-check `SELECT 1 FROM media_files/items/versions WHERE s3_key = $1` before each S3 delete, OR queue inserts carry the entity-ID and worker confirms absence. Today a delete-then-reupload within worker latency deletes the fresh file. |
| 322 |
- [x] **`pending_uploads` reaper-bump owner pinning (Storage MED)** — `db/pending_uploads.rs:26-34`. ON CONFLICT only refresh `created_at` when `user_id` matches; otherwise treat as a fresh row. Closes the slow-leak where re-presigning every minute keeps an orphan S3 object alive indefinitely. |
| 323 |
- [x] **TOTP step-replay across re-enable (Security MED)** — `db/totp.rs:60-81`. `disable_totp` adds `totp_last_used_step = NULL`. `set_totp_secret` resets to 0 / NULL. Closes the false-reject DoS when a user disables and re-enables TOTP. |
| 324 |
- [x] **`delete_other_sessions` cache eviction (Security MED)** — `db/sessions.rs:178-192`. Change return to `RETURNING id`; callers iterate and call `state.session_cache.remove(&id)`. Today "log out other sessions" leaves cached `AuthUser` extractor entries valid up to `SESSION_TOUCH_CACHE_SECS`. |
| 325 |
- [x] **CSRF on `/login` (Security MED)** — `csrf.rs:166-181`. Move `/login` out of `exempt_prefixes`; have `login_handler` call `csrf::validate_token` like `authorize_post` does. The login template already renders the token; only middleware bypass is keeping it from doing work. Defense-in-depth for SameSite-Lax-only login-CSRF. |
| 326 |
- [x] **`is_registered_redirect_uri` `fetch_one` brittleness (Security MED)** — `db/oauth.rs:84-97`. Switch to `fetch_optional`, return `Ok(false)` on `None`. Document trailing-slash matching policy explicitly to close the developer-onboarding foot-gun. |
| 327 |
|
| 328 |
### Phase 3 — resilience & infra hardening |
| 329 |
|
| 330 |
- [x] **Multi-replica `claim_pending_build` race (Storage MED)** — `db/builds.rs:262-281`. Add `CREATE UNIQUE INDEX … ON ota_builds(status) WHERE status='running'` partial unique index, OR wrap with `pg_advisory_xact_lock`. Today's single-replica prod is safe; the bug fires the moment a second builder joins. |
| 331 |
- [x] **Build status reaper race (Storage MED)** — `db/builds.rs:216-252,287`. Add `WHERE status='running'` to the success UPDATE and check `rows_affected`. Avoids the case where the stale-build reaper marks a successful build failed at the exact second it completes. |
| 332 |
- [x] ~~**`KNOWN_SYNC_APPS` deletion path (Perf surprise)**~~ — registry removed; no `KNOWN_SYNC_APPS` in `rate_limit.rs` (only JWT extractors). Item moot. — `rate_limit.rs:65-95`. Add `unregister_known_sync_app(app_id)` called from sync-app delete. Long-term: replace `OnceLock<DashSet>` with a DB-backed cache + TTL so multi-replica deploys don't diverge on app inventory. |
| 333 |
- [x] **`extract_s3_key_from_url` host pinning (Storage SERIOUS)** — `storage.rs:582-593`. Require the `https://` host portion to be the configured S3 endpoint host or a known CDN domain before extracting the key. Today path-style branch returns `Some("foo")` for `https://attacker.example/my-bucket/foo`. |
| 334 |
- [x] **TOTP `pending_2fa_*` tracking row (Security surprise)** — `routes/auth.rs:196-202`. Insert a temporary tracking row scoped via `kind='pending_2fa'` (or separate table) at the moment 2FA-pending begins, so `delete_all_sessions_for_user` can sweep a phisher mid-2FA-prompt. Today that intermediate authenticated state is invisible to "log out everywhere". |
| 335 |
|
| 336 |
### Phase 4 — polish |
| 337 |
|
| 338 |
- [x] **`MaybeUserUnverified` rename or short-circuit (Security LOW)** — `auth.rs:229-249`. Either rename to make the danger boundary impossible to forget (e.g. `SessionUserStaleAllowed`), or short-circuit to `None` when the session lacks `SESSION_TRACKING_KEY` so legacy sessions don't quietly survive `/logout-everywhere`. |
| 339 |
- [x] **`sum_file_sizes_for_item` clamp direction (Storage LOW)** — `db/versions.rs:320-332`. Replace `COALESCE(LEAST(SUM, i64::MAX)::BIGINT, 0)` with `COALESCE(GREATEST(0, LEAST(SUM, i64::MAX))::BIGINT, 0)`. Today the protection is one-sided; negative values flow through. |
| 340 |
- [x] **License-key 23505 collision retry (Payments LOW)** — `crypto.rs:30-40` + `db/transactions.rs:411-422`. Retry once on UNIQUE violation in `create_license_key` and the promo-claim arm so an unlucky generator collision doesn't surface as a 500. |
| 341 |
- [x] **Stripe v1 multi-signature rotation (Payments LOW)** — `payments/webhooks.rs:350-389`. Collect all `v1=` values from the header; accept on any match against any configured secret. Today only the last `v1=` value is tried, which breaks Stripe's documented secret-rotation procedure. |
| 342 |
- [x] **`has_active_subscription_to_project` period-end check (Payments LOW)** — `db/subscriptions.rs:394-408`. Add `AND (current_period_end IS NULL OR current_period_end > NOW())`. Defense-in-depth for a missed/delayed `customer.subscription.deleted` webhook. |
| 343 |
- [x] **Download HTML sniff strengthening (Security LOW)** — `scanning/content_type.rs:119-146`. For Download, add a lightweight string sniff for `<!--`, `<script`, `<svg`, `<?xml`, BOM-stripped `<html` after `infer` returns None. Pair with `Content-Disposition: attachment` on all served downloads so the browser never renders inline regardless of scan verdict. |
| 344 |
- [x] **Profile-link `rel="ugc nofollow"` (UX LOW)** — `templates/pages/user.html:84`. Add `rel="ugc nofollow"` to user-supplied profile links. |
| 345 |
- [x] **`format_revenue` thousands separator (UX LOW)** — `formatting.rs:43-51`. Make `format_revenue` use thousands separators consistent with `format_price`. Dashboards mixing both currently render "$1,234" and "$10000.00" side by side. |
| 346 |
- [x] **Third-party credits / attributions page (UX, brand)** — Build a public page that credits every library, vendor, and dependency the platform leans on (Rust crates from server + multithreaded + pom + mnw-cli + shared/, JS libs, fonts, SDKs, payment / object-storage / email vendors). Pull crate names + licenses out of `cargo metadata`, group by tier (runtime infra, scanning, payments, etc.), and write a short human-readable paragraph for the big ones (tokio, axum, sqlx, aws-sdk-s3, async-stripe, yara-x, fs2, etc.). Lives somewhere discoverable from the footer or the `/about` tree. Worth real effort — this is one of the visible ways MNW shows respect for the OSS ecosystem it stands on. |
| 347 |
|
| 348 |
### Phase 5 — chronic |
| 349 |
|
| 350 |
- [~] **Invariant-in-prose / policy-not-in-types — third consecutive run (CHRONIC)** — scan_status-ordering half closed 2026-05-26 (see Phase 1 entry for `images.rs::item_image_confirm`). The constructive-impossibility shape from the chronic-remediation rubric: `commit_*_upload` is the only handler-reachable path that writes both row + scan_status; the lower-level scan_status writes were renamed `set_*_scan_status_standalone` and documented as worker- and admin-override-only. Compiler-driven migration found one additional handler with the same bug (CLI internal upload) — that's the test the rubric wants: structural change exposes drift, not human review. Remaining: `/stripe/*` CSRF policy patchwork — same disease, different organ. Track as Landing 2 below. |
| 351 |
|
| 352 |
- [x] **Per-route CSRF posture (CHRONIC, second half) — Landing 2** — landed 2026-05-26. `csrf.rs::exempt_prefixes` allowlist replaced with per-route helpers + a `CsrfRouter<S>` newtype that only accepts `PostureMethodRouter<S>` values produced by the `{post,put,patch,delete}_csrf*` helpers. A bare `Router::route(path, post(handler))` no longer compiles inside any of the 14 mutation-bearing route files — the structural guarantee replaces the originally-planned runtime trip wire, which was impossible because per-route layers run *inside* global ones (the global writer would have run after the global reader). Compiler-driven migration surfaced one real regression (Manual-posture tip handler was form-only; old middleware was header-then-form) — fixed by routing the Manual handler through `extract_token_from_request` to match the standard precedence. |
| 353 |
|
| 354 |
Sub-steps: |
| 355 |
- [x] L2.1 Helpers + sealed marker. `CsrfPosture::{Auto, Manual(&'static str), Skip(&'static str)}`, `CsrfManuallyValidated` ZST with sealed constructor, `validate_token_consuming` as the only producer. |
| 356 |
- [x] L2.2 Per-route layers replace global middleware. The Auto helper attaches a per-route `from_fn` that runs `validate_auto`; Skip/Manual attach nothing at runtime (the posture lives in the helper signature for source-level grep, not in request extensions). |
| 357 |
- [x] L2.3 Inventory: 290 mutation routes across 14 files (208 POST · 36 PUT · 2 PATCH · 45 DELETE). |
| 358 |
- [x] L2.4–L2.6 Migrate all routes. Skip for webhooks / pre-auth / HMAC-internal / bearer-sync / guest-checkout; Manual for the tip handler; Auto for the rest. Reason strings are at the call site (`STRIPE_SESSION_SKIP`, `SYNCKIT_API_KEY_SKIP`, etc.). |
| 359 |
- [x] L2.7 Allowlist + `csrf_middleware` deleted from `csrf.rs` and `lib.rs`. |
| 360 |
- [x] L2.8 Structural enforcement via `CsrfRouter<S>` + `PostureMethodRouter<S>`. `route_get` for read-only methods; `merge`, `nest`, `layer`, `route_layer` mirror axum's `Router`. `finalize()` is the single escape hatch, called once in `build_app`. |
| 361 |
|
| 362 |
Follow-ups: |
| 363 |
- [ ] **Manual-posture runtime assertion (dev builds).** Today `*_csrf_manual` requires no compile-time proof that the handler called `validate_token_consuming`. Only the tip handler is Manual, and `_validated` is bound only as documentation. In dev/test builds, set a flag in `validate_token_consuming` and debug-assert it after the handler runs; mismatched routes panic loudly in CI without affecting prod. Not blocking — only matters if Manual grows beyond one route. |
| 364 |
- [ ] **Phase 1 entries still open:** `cancel_pending_item_checkout` Skip reason is `"Phase 1 todo: tighten to post_csrf"` (grep "Phase 1 todo" to find). `/login` and `creator-tier` template tightening tracked separately above. |
| 365 |
|
| 366 |
- [x] **Integration test drift — 42 pre-existing failures (snapshot 2026-05-26 post-Landing 2).** Cleared 2026-05-27. Three landings (harness helpers, KeyCode validator + fixtures, password_reset body asserts) + per-test cleanup. Real code fixes that fell out: `validate_key_code` accepts 5 or 6 words (generator was emitting 6 but validator pinned at 5); `update_build_status` source-status gate now `IN ('pending','running')` (Phase 3 gate inadvertently blocked cancel of pending builds); `validate_auto` short-circuits safe methods (GET on multi-method `with_csrf` routes was 403); `media_confirm` matches `AppError::Database(sqlx::Error::Database)` for 23505 instead of substring on the wrapper Display. All 803 integration tests now pass. Net delta from the CSRF migration is 0 (tip-handler regression was found and fixed; the remaining 42 match the pre-migration baseline modulo flakes). These are not CSRF-related and need triage in their own landings. Re-snapshot before each push. |
| 367 |
- [ ] workflows::admin::admin_approve_item_upload |
| 368 |
- [ ] workflows::admin::admin_approve_version_upload |
| 369 |
- [ ] workflows::admin::admin_reject_item_upload |
| 370 |
- [ ] workflows::admin::admin_reject_version_upload |
| 371 |
- [ ] workflows::adversarial_auth::suspended_user_login_ok_writes_blocked |
| 372 |
- [ ] workflows::adversarial_business::exhausted_promo_code_rejected |
| 373 |
- [ ] workflows::auth::lockout_after_failed_attempts |
| 374 |
- [ ] workflows::auth::nonexistent_user_rejected |
| 375 |
- [ ] workflows::auth::password_change_flow |
| 376 |
- [ ] workflows::auth::wrong_password_rejected |
| 377 |
- [ ] workflows::creator_media::scan_real_flac_passes |
| 378 |
- [ ] workflows::creator_media::scan_real_mp3_passes |
| 379 |
- [ ] workflows::creator_media::scan_real_wav_passes |
| 380 |
- [ ] workflows::fingerprinting::license_deactivate_frees_slot |
| 381 |
- [ ] workflows::fingerprinting::license_verify_lifecycle |
| 382 |
- [ ] workflows::fingerprinting::license_verify_requires_verification_enabled |
| 383 |
- [ ] workflows::fingerprinting::license_verify_revoked_key |
| 384 |
- [ ] workflows::license_keys::license_key_lifecycle |
| 385 |
- [ ] workflows::license_keys::max_activations_enforced |
| 386 |
- [ ] workflows::license_keys::revoke_key_then_validate_fails |
| 387 |
- [ ] workflows::license_keys::v1_license_endpoints |
| 388 |
- [ ] workflows::lifecycle::sandbox_lifecycle_create_use_expire_cleanup |
| 389 |
- [ ] workflows::media_library::filename_collision_rejected |
| 390 |
- [ ] workflows::password_reset::password_reset_expired_link |
| 391 |
- [ ] workflows::password_reset::password_reset_passwords_must_match |
| 392 |
- [ ] workflows::password_reset::password_reset_tampered_signature |
| 393 |
- [ ] workflows::promo_codes_free_access::free_access_code_claim_already_owned |
| 394 |
- [ ] workflows::promo_codes_free_access::free_access_code_claim_by_buyer |
| 395 |
- [ ] workflows::promo_codes_free_access::free_access_code_generate_list_delete |
| 396 |
- [ ] workflows::sandbox::create_sandbox_account |
| 397 |
- [ ] workflows::sandbox::sandbox_blocks_restricted_endpoints |
| 398 |
- [ ] workflows::sandbox::sandbox_blog_no_email |
| 399 |
- [ ] workflows::sandbox::sandbox_content_not_visible_on_item_page |
| 400 |
- [ ] workflows::sandbox::sandbox_rss_returns_404 |
| 401 |
- [ ] workflows::scanning::admin_approve_held_upload |
| 402 |
- [ ] workflows::scanning::confirm_upload_bad_magic_quarantined |
| 403 |
- [ ] workflows::scanning::confirm_upload_clean_file_passes |
| 404 |
- [ ] workflows::scanning::trusted_creator_upload_auto_publishes |
| 405 |
- [ ] workflows::scanning::untrusted_creator_upload_held_for_review |
| 406 |
- [ ] workflows::subscriptions::sandbox_tier_uses_fake_stripe_ids |
| 407 |
- [ ] workflows::suspension::suspended_user_claim_promo_code_blocked |
| 408 |
- [ ] workflows::wizards::wizard_back_navigation |
| 409 |
|
| 410 |
### Notes & non-actions |
| 411 |
|
| 412 |
- Status-notification fan-out cooldown across overlapping tasks (`monitor.rs:213-237`) — single-replica today; harmless. Reconsider when adding a second instance. |
| 413 |
- `record_storage_fill_stats` JOIN (`metrics.rs:181-218`) — 5min cadence is acceptable at 100k users; revisit at 1M. |
| 414 |
- `metadefender` could run concurrently with MalwareBazaar in suspicion path (`scanning/mod.rs:377-398`) — micro-optimization, deferred. |
| 415 |
- `populate_known_sync_apps` startup-only (`rate_limit.rs:65-85`) — paired with the deletion-path item above; together they're a single fix. |
| 416 |
|
| 417 |
--- |
| 418 |
|
| 419 |
## Ultra Fuzz 2026-05-26 (Run #3) |
| 420 |
|
| 421 |
Full report: `docs/audit_review.md`. Plan target: lift every axis to A- or higher (Payments A · Storage A- · UX A · Security A- · Performance A-). |
| 422 |
|
| 423 |
### Notes & non-actions |
| 424 |
|
| 425 |
- Backup-code fast-path malformed-hash trap (`db/totp.rs:155-189`) — log + alert + fall through to legacy path; small, file as polish. |
| 426 |
- `session_cache` TTL window vs admin revoke (`auth.rs:154-191`) — documented as intentional; consider exposing a broadcast invalidate op if operator demand emerges. |
| 427 |
- `monitor.rs` `pg_stat_activity` cadence already covered by Phase 2 split. |
| 428 |
|
| 429 |
--- |
| 430 |
|
| 431 |
## Ultra Fuzz 2026-05-25 (Run #2) |
| 432 |
|
| 433 |
Full report: `docs/audit_review.md`. |
| 434 |
|
| 435 |
### Outbox follow-ups — convert remaining webhook handlers |
| 436 |
|
| 437 |
All five remaining handlers converted to outbox 2026-05-25; migration 125 added `fan_plus_subscription_id` and `creator_subscription_id` parents so each subscription type has its own idempotency anchor. |
| 438 |
|
| 439 |
### Current phase — serious / high |
| 440 |
|
| 441 |
- [ ] **Login template field-aware errors** — deferred 2026-05-26. Re-scoped: error-construction infra (`AppError::validation_fields`) is in place in `join_wizard::step_account_create`, but neither signup nor login renders per-field highlights yet. Real work is a new HTMX partial with OOB swaps per input + per-field error containers on both templates. Login itself has only one safe per-field message by design (creds are intentionally generic to avoid enumeration); the value is mostly on the signup side. |
| 442 |
- [~] **Scanning peak memory** — `scanning/mod.rs:174` already uses `std::sync::Arc::<[u8]>::from(data)` which dispatches through `Vec::into_boxed_slice` and reuses the allocation; SHA-256 streams via `Sha256::update` over the same Arc-shared buffer. No change needed. |
| 443 |
- [~] **`check_sales_count_drift` full GROUP BY** — the SQL already filters via `HAVING i.sales_count != COUNT(t.id)` (the real bound). The trailing `LIMIT 50` is a per-tick cap on how many drifts to surface, not a cosmetic post-group filter. No action. |
| 444 |
|
| 445 |
### Current phase — medium / minor |
| 446 |
|
| 447 |
- [~] **`pg_stat_activity` baseline load** — `monitor.rs:290-294` doc explicitly justifies the 30 s cadence for operator-dashboard refresh; no change. |
| 448 |
|
| 449 |
### Deferred — architectural |
| 450 |
|
| 451 |
- [ ] **Cloudflare-only origin: migrate custom domains to CF for SaaS, then firewall 80/443.** Re-scoped 2026-05-26. The original sketch (firewall the origin to CF IP ranges) conflicts with the shipped custom-domain feature (`api/domains.rs` + Caddy `on_demand_tls`), which expects creators' A-records to hit the origin directly. The two threats the firewall was meant to close are already mitigated at layer 7 — `CloudflareIpKeyExtractor` peer-IP fallback (landed 2026-05-26) closes the CF-Connecting-IP spoofing surface; Caddy `client_auth require_and_verify` closes the WAF-bypass surface for `makenot.work`. The proper sequencing is now (1) upgrade to CF Business for CF-for-SaaS, (2) reconfigure CF dashboard with a fallback origin, (3) update `api/domains.rs` onboarding to CNAME instead of A-record, (4) migrate the 1 live custom-domain creator, (5) drop `on_demand_tls` from `Caddyfile`, (6) apply the firewall ACL. Full sequence + ACL sketch + gotchas live in `_meta/docs/incident_response.md` § "Pending Hardening: Cloudflare-only origin firewall". Blocked on the CF plan upgrade + 1 customer email, neither of which happens in-session. |
| 452 |
|
| 453 |
--- |
| 454 |
|
| 455 |
## Ultra Fuzz 2026-05-24 (Run #1) |
| 456 |
|
| 457 |
Full report: `docs/audit_review.md`. |
| 458 |
|
| 459 |
### Current phase — medium |
| 460 |
|
| 461 |
- [~] **Status notification parallel fan-out** — kept sequential with 100 ms shaper; that pacing is intentional (SMTP rate-limit shape). No change. |
| 462 |
|
| 463 |
### Deferred — architectural |
| 464 |
|
| 465 |
All four Run #1 deferred items closed by Run #2 sweeps; pointers below. |
| 466 |
|
| 467 |
--- |
| 468 |
|
| 469 |
## PoM contract guard (landed 2026-05-25) |
| 470 |
|
| 471 |
Schema-drift guard test wired against `shared/pom-contract/`: `src/routes/pages/public/health/mod.rs::tests::pom_hetzner_health_expectations_resolve`. `health_json` body builder extracted as pure `health_json_body(overall, db_ok)` for the test. Catches the v0.5.16-class drift where a field is removed from `/api/health` without updating PoM's expectations. See `MNW/CLAUDE.md` § PoM Health Contract. |
| 472 |
|