Skip to main content

max / makenotwork

Code fuzz + ultra fuzz remediations, doc fixes, WAL archiving Code fuzz (prior sessions): fix cart refund multi-item, buyer!=seller guard, passkey re-auth, password reset session invalidation, email HMAC purpose prefix, SSH command sanitization, build runner partial failure, presigned upload tracking with reaper, soft-delete S3 cleanup, promo code fixes (cross-creator collision, expiry, use_count guards), subscription reactivation, content type scanning hardening, unsubscribe action validation, and ~40 additional minor fixes across payments, storage, auth, scanning, scheduler, and import modules. Ultra fuzz Run 21: pin scheduler advisory lock to connection, enforce PWYW minimum in cart checkout, fix soft-delete purge to collect version S3 keys and decrement storage, make confirm_upload atomic, parallelize discover page facet queries with try_join!, validate checkout cancel item_id as UUID, harden ClamAV response parsing and archive path traversal checks, delete old S3 objects on file replacement, add UNIQUE constraint to pending_uploads, instrument embed handlers, require re-auth for OAuth with legacy sessions. Also: doc fuzz prose fixes, WAL archiving scripts, rollback docs, PoM backup monitoring update, test harness DocLoaderConfig fix.
Co-Authored-By
Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-09 02:02 UTC
Commit: f0e40b7eecc823569be9a8d941dde8b9b2ef4ffa
Parent: 3714f0d
105 files changed, +1995 insertions, -577 deletions
@@ -60,8 +60,8 @@
60 60 staleness_days = 7
61 61
62 62 [targets.mnw.backups]
63 - directory = "/opt/backups/postgres"
64 - databases = ["makenotwork", "multithreaded"]
63 + directory = "/opt/makenotwork/backups"
64 + databases = ["makenotwork"]
65 65 max_age_hours = 25
66 66 interval_secs = 3600
67 67
@@ -97,7 +97,86 @@
97 97
98 98 - Migrations are auto-applied on boot via sqlx. If you restore an older backup and the current binary has newer migrations, they'll run automatically.
99 99 - If a migration is incompatible with the restored data, you'll need to also rollback the binary (see Quick Rollback above).
100 - - Data created between the backup and the restore will be lost. There's no WAL archiving — only daily `pg_dump`.
100 + - For finer-grained recovery (less than 24h of data loss), use Point-in-Time Recovery below.
101 +
102 + ## Point-in-Time Recovery (PITR)
103 +
104 + WAL continuous archiving caps data loss at ~5 minutes. Use PITR when you need to recover to a specific moment rather than the last daily dump.
105 +
106 + **Prerequisites:** WAL archiving must be enabled (see `deploy/setup-wal-archiving.sh`). WAL segments are archived to `/opt/makenotwork/wal-archive/`.
107 +
108 + ### Steps
109 +
110 + 1. **Stop the application:**
111 + ```bash
112 + ssh root@100.120.174.96 "systemctl stop makenotwork"
113 + ```
114 +
115 + 2. **Stop PostgreSQL:**
116 + ```bash
117 + ssh root@100.120.174.96 "systemctl stop postgresql"
118 + ```
119 +
120 + 3. **Back up the current (broken) data directory:**
121 + ```bash
122 + ssh root@100.120.174.96 "cp -r /var/lib/postgresql/16/main /var/lib/postgresql/16/main.broken"
123 + ```
124 +
125 + 4. **Restore the base backup** (latest daily pg_dump):
126 + ```bash
127 + ssh root@100.120.174.96 "sudo -u postgres psql -c 'DROP DATABASE makenotwork;'"
128 + ssh root@100.120.174.96 "sudo -u postgres psql -c \"CREATE DATABASE makenotwork OWNER makenotwork;\""
129 + ssh root@100.120.174.96 "gunzip -c /opt/makenotwork/backups/makenotwork-YYYYMMDD-HHMMSS.sql.gz | sudo -u makenotwork psql makenotwork"
130 + ```
131 +
132 + 5. **Configure recovery target** (create a recovery signal file):
133 + ```bash
134 + ssh root@100.120.174.96 "cat > /var/lib/postgresql/16/main/recovery.signal <<EOF
135 + EOF"
136 + ```
137 +
138 + Add to `postgresql.conf` (or a conf.d override):
139 + ```
140 + restore_command = 'cp /opt/makenotwork/wal-archive/%f %p'
141 + recovery_target_time = '2026-05-08 14:30:00 UTC'
142 + recovery_target_action = 'promote'
143 + ```
144 +
145 + 6. **Start PostgreSQL** (it will replay WAL segments up to the target time):
146 + ```bash
147 + ssh root@100.120.174.96 "systemctl start postgresql"
148 + ```
149 +
150 + 7. **Verify recovery** (check logs for "recovery complete"):
151 + ```bash
152 + ssh root@100.120.174.96 "journalctl -u postgresql --since '5 minutes ago' --no-pager"
153 + ```
154 +
155 + 8. **Remove recovery config** (clean up the override so future restarts don't re-enter recovery):
156 + ```bash
157 + ssh root@100.120.174.96 "rm -f /var/lib/postgresql/16/main/recovery.signal"
158 + ```
159 + Remove the `restore_command`, `recovery_target_time`, and `recovery_target_action` lines from the config.
160 +
161 + 9. **Restart the application:**
162 + ```bash
163 + ssh root@100.120.174.96 "systemctl start makenotwork"
164 + ```
165 +
166 + ### When to Use PITR vs Full Dump Restore
167 +
168 + | Scenario | Use |
169 + |----------|-----|
170 + | Catastrophic corruption, need any recovery | Full dump restore (simpler) |
171 + | Need to recover to a specific point in time | PITR |
172 + | Data was accidentally deleted minutes ago | PITR |
173 + | Restoring to a known-good state from days ago | Full dump restore |
174 +
175 + ### WAL Archive Maintenance
176 +
177 + - WAL segments are pruned automatically: local (7 days via cron), offsite on astra (7 days via `sync-wal-offsite.sh`).
178 + - Daily pg_dump backups remain the primary recovery method. WAL archiving adds continuous protection on top.
179 + - Monitor WAL archive health via PoM (checks recency every hour).
101 180
102 181 ## Migration Rollback
103 182
@@ -116,7 +195,8 @@
116 195 - **Config**: `/opt/makenotwork/.env`
117 196 - **Static**: `/opt/makenotwork/static/`
118 197 - **Docs**: `/opt/makenotwork/docs/`
119 - - **Backups**: `/opt/makenotwork/backups/`
198 + - **Backups**: `/opt/makenotwork/backups/` (daily pg_dump)
199 + - **WAL archive**: `/opt/makenotwork/wal-archive/` (continuous)
120 200 - **Systemd unit**: `/etc/systemd/system/makenotwork.service`
121 201 - **Caddy config**: `/etc/caddy/Caddyfile`
122 202 - **Error pages**: `/opt/makenotwork/error-pages/`
@@ -1,48 +1,48 @@
1 1 # MakeNotWork -- Audit Review
2 2
3 - **Last audited:** 2026-05-04 (Run 20, MNW server full audit)
4 - **Previous audit:** 2026-05-02 (Run 19, MNW server + doc fuzz)
3 + **Last audited:** 2026-05-08 (Run 21, Ultra Fuzz -- 5-axis deep audit)
4 + **Previous audit:** 2026-05-04 (Run 20, MNW server full audit)
5 5
6 6 ## Overall Grade: A
7 7
8 - Run 20: 678 integration tests passing (2 timing-sensitive sandbox rate-limit tests failing, non-critical), 0 cargo warnings. v0.4.10. ~83,232 LOC. 2 cold spots (0 bugs, 2 minor). Clean git status. `cargo check` passes cleanly.
8 + Run 21: Ultra Fuzz (Payments, Storage, UX Wiring, Security, Performance). v0.5.7. ~87,427 LOC. 1,218 test annotations. 101 migrations. 5 SERIOUS findings (1 payments, 2 storage, 2 performance), 0 CRITICAL. 5 cold spots. All money math integer-only. Security posture remains A+.
9 9
10 10 ## Scorecard
11 11
12 12 | Dimension | Grade | Notes |
13 13 |-----------|:-----:|-------|
14 - | Code Quality | A | Zero .unwrap() in production paths (3 LazyLock regex are acceptable) |
15 - | Architecture | A | Clean layer separation. Inline SQL only in health.rs (stats + probe, acceptable) |
16 - | Testing | A | ~1,214 test annotations, 94 integration test files, proptest active, comprehensive harness |
17 - | Security | A+ | Constant-time compare, fail-closed scanning, CSRF everywhere, Argon2id, HMAC webhooks, path traversal prevention |
18 - | Performance | A | Batch queries, pagination, CDN fallback, session touch cache, presigned uploads |
19 - | Documentation | A- | Module-level //! on all major files. No README.md (CONTRIBUTING.md fills role) |
20 - | Dependencies | A- | 3 transitive advisories (none exploitable). All security-sensitive deps on latest stable |
21 - | Frontend | A | Askama auto-escape, HTMX patterns consistent, CSP headers |
14 + | Code Quality | A | Zero .unwrap() in production paths. Clean macro patterns throughout |
15 + | Architecture | A | Clean layer separation. Trait-based backends for storage/email/payments |
16 + | Testing | A | 1,218 test annotations, proptest active, adversarial tests, comprehensive harness |
17 + | Security | A+ | SHA-256-based constant-time compare, fail-closed scanning, CSRF everywhere, Argon2id, HMAC webhooks, PKCE S256 |
18 + | Performance | A- | Discover page fires 5-8 queries per request (new finding). Presigned uploads, CDN fallback, session cache solid |
19 + | Documentation | A- | Module-level //! on all major files. No README.md |
20 + | Dependencies | A- | 4 transitive advisories (none exploitable). async-trait still present (chronic) |
21 + | Frontend | A | Askama auto-escape, json_escape prevents JSON-LD XSS, HTMX patterns consistent |
22 22 | Type Safety | A+ | 36 UUID newtypes, 25+ domain enums, validated string types, Cents/PriceCents monetary newtypes |
23 - | Observability | A- | Comprehensive #[instrument] on routes + DB layer. Gaps: embed/ handlers (0 instruments), payments/ module (0 instruments) |
24 - | Concurrency | A | ON CONFLICT, FOR UPDATE, advisory locks, DashMap caches, atomic state changes |
23 + | Observability | A- | Comprehensive #[instrument] on routes + DB. Gaps: embed/ (0), payments/ (0) -- chronic from Run 20 |
24 + | Concurrency | A- | ON CONFLICT, FOR UPDATE, DashMap caches. Scheduler advisory lock not pinned (new finding) |
25 25 | Resilience | A | Graceful shutdown with 10s deadline, timeouts on all outbound calls, fail-closed scanning |
26 - | API Consistency | A | ListResponse wrapper, json_error_layer, versioned SyncKit routes, documented conventions |
27 - | Migration Safety | A | 93 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout, defaults on all NOT NULL |
28 - | Codebase Size | A- | 83K LOC well-organized. 5 files over 500-line guideline (max 844). No egregious violations |
26 + | API Consistency | A | ListResponse wrapper, json_error_layer, versioned SyncKit routes |
27 + | Migration Safety | A | 101 additive migrations, IF NOT EXISTS guards, TIMESTAMPTZ throughout |
28 + | Codebase Size | A- | 87K LOC. 7 files over 500-line guideline (max 846). No egregious violations |
29 29
30 30 ## Module Heatmap
31 31
32 32 | Module | Code | Arch | Test | Security | Perf | Docs | TypeSafe | Observ | Size |
33 33 |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:--------:|:------:|:----:|
34 34 | lib.rs + main.rs | A | A | B+ | A | A | A | A | A | A |
35 - | config.rs | A | A | A | A | n/a | A | A | B+ | A |
35 + | config.rs | A | A | A | A | n/a | A | A | A- | A |
36 36 | error.rs | A | A | A | A | n/a | A | A | A | A |
37 37 | auth.rs | A | A | A- | A+ | A | A | A | A | A |
38 38 | csrf.rs | A | A | A | A | A- | A | A | A- | A |
39 + | constants.rs | A+ | n/a | A | A | n/a | A- | A | n/a | A |
39 40 | helpers.rs | A | A | A | A | A | A | A- | B+ | A |
40 - | constants.rs | A | n/a | A | A | n/a | A- | A | n/a | A |
41 - | rate_limit.rs | A | A | A | A | A | A | A | B+ | A |
41 + | rate_limit.rs | A | A | A- | A | A | A | A | B+ | A |
42 42 | storage.rs | A | A | A | A | A | A | A | B+ | A |
43 - | pricing.rs | A | A+ | A+ | A | A | A | A | n/a | A |
44 - | crypto.rs | A | A | A | A | A | A | A | n/a | A |
45 - | formatting.rs | A | A | A+ | A | A | A | A | n/a | A |
43 + | pricing.rs | A | A+ | A+ | A | A | A | A- | n/a | A |
44 + | crypto.rs | A | A | A | A+ | A | A | A | n/a | A |
45 + | formatting.rs | A | A | A+ | A | A | A- | A | n/a | A |
46 46 | rss.rs | A | A | A | A | A | A | A- | n/a | A |
47 47 | synckit_auth.rs | A | A | A | A | A | A | A | B+ | A |
48 48 | db/enums.rs | A | A | A | A | n/a | A | A+ | n/a | A |
@@ -51,52 +51,78 @@
51 51 | db/users.rs | A | A | B | A | A- | A | A | A | A |
52 52 | db/items.rs | A | A | B | A | A- | A | A | A | A |
53 53 | db/synckit.rs | A | A | B | A | A | A | A | A | A |
54 - | db/transactions.rs | A | A | B | A | A | A | A | A | A |
55 - | db/discover.rs | A | A | B | A | A | A | A | n/a | A |
56 - | db/creator_tiers.rs | A | A | A | A | A | A | A | n/a | A |
54 + | db/transactions.rs | A | A | B+ | A | A | A | A | A | A |
55 + | db/discover.rs | A | A | B | A | A- | A | A | n/a | **B** |
56 + | db/cart.rs | **B+** | A | B | A | A | A | A- | n/a | A |
57 + | db/creator_tiers.rs | A | A | A | A | A- | A | A | n/a | A |
58 + | db/versions.rs | A | A | A | A | A | A | A | n/a | A |
59 + | db/builds.rs | A | A+ | A | A | A | A | A | n/a | A |
60 + | db/pending_refunds.rs | A | A | B | A | A | A | A | n/a | A |
61 + | db/license_keys.rs | A | A | B | A | A | A | A | n/a | A |
62 + | db/promo_codes.rs | A | A | A | A | A | A | A | n/a | A |
63 + | db/tips.rs | A | A | B | A | A | A | A | n/a | A |
57 64 | db/models/* | A | A | B+ | A | n/a | A- | A | n/a | A |
58 65 | types/ | A | A | B | A | n/a | A | A | n/a | A |
59 - | scanning/ | A | A+ | A+ | A+ | A- | A | A | B | A |
66 + | scanning/ | A | A+ | A- | A+ | A- | A | A- | B+ | A |
60 67 | payments/ | A | A | A- | A | A- | A | A | **B+** | B+ |
61 68 | email/ | A | A | A | A | A- | A | A | B+ | A- |
62 - | scheduler/ | A | A | B+ | A | A | A | A | A- | A |
69 + | scheduler/ | A | A | B+ | A | **B+** | A | A | A- | A |
70 + | scheduler/cleanup.rs | A- | A | n/a | A | **B+** | A | A | A- | A |
63 71 | validation/ | A | A | A+ | A+ | A | B+ | A | n/a | A |
64 72 | import/ | A | A | A | A | B+ | A- | A | B+ | A |
65 73 | git/ | A | A | A | A+ | B+ | A- | A | B | A |
66 - | git_ssh.rs | A | A | A | A | A | A- | A | B+ | A |
67 - | build_runner.rs | A | A- | B+ | A+ | A | A- | A | A | A |
74 + | git_ssh.rs | A | A | A | A- | A | A- | A | B+ | A |
75 + | build_runner.rs | A | A- | B+ | A+ | A- | A- | A | A | A |
68 76 | monitor.rs | A | A | A- | A | A | A | A | A | A |
69 - | templates/ | A | A | n/a | A | A | A- | A | B | B+ |
77 + | templates/ | A | A- | n/a | A | A | A- | A | B+ | B+ |
70 78 | routes/auth.rs | A | A | n/a | A+ | A | A | A | A | A |
71 - | routes/oauth.rs | A | A | n/a | A+ | A | A | A | A | A- |
79 + | routes/oauth.rs | A | A | n/a | A- | A | A | A | A- | A- |
72 80 | routes/admin/ | A | A | n/a | A | A | A | A | A | A |
73 81 | routes/api/ | A | A | n/a | A | A | A | A | A | **B+** |
74 - | routes/stripe/ | A | A | n/a | A | A | A | A | **B+** | **B+** |
75 - | routes/synckit/ | A | A | n/a | A | A | A | A | A | A |
76 - | routes/pages/ | A | A | n/a | A | A | A | A | A- | **B+** |
82 + | routes/stripe/ | A | A | n/a | A | A- | A | A | **B+** | **B+** |
83 + | routes/stripe/checkout/ | A | A | n/a | A- | A | A | A | A | A |
84 + | routes/synckit/ | A | A | n/a | A | A- | A | A | A | A |
85 + | routes/pages/discover.rs | A | **B-** | n/a | A | **B** | B+ | A | A | A |
86 + | routes/pages/ (other) | A | A | n/a | A | A | A | A | A- | **B+** |
77 87 | routes/embed/ | A | A | n/a | A | A | A- | A | **B** | A |
78 88 | routes/git/ | A | A | n/a | A | A | A | A | A | A |
79 89 | routes/storage/ | A | A | n/a | A | A | A | A | A | A |
90 + | routes/storage/uploads.rs | A | A- | n/a | A | A- | A | A | A | A |
91 + | routes/storage/images.rs | A | A- | n/a | A | A- | A | A | A | A |
80 92 | routes/postmark/ | A | A | n/a | A | A | A | n/a | A- | B+ |
81 93
82 94 **Bold** = cold spot (B or below).
83 95
84 96 ### Cold Spots
85 97
86 - 1. **routes/embed/ observability (B):** Zero `#[tracing::instrument]` on any embed handler (item.rs, project.rs, user.rs). Embeds serve third-party traffic — blind spot for latency monitoring.
87 - 2. **routes/stripe/webhook/checkout.rs size (B+) + observability (B+):** 792 LOC (above 500-line guideline), internal `handle_*` functions lack `#[instrument]`.
98 + 1. **routes/pages/discover.rs performance (B) + architecture (B-):** Full discover page fires 5-8 sequential DB queries against same base tables. At 25-connection pool, ~4 concurrent page loads could exhaust connections.
99 + 2. **routes/embed/ observability (B):** Zero `#[tracing::instrument]` on any embed handler. Chronic from Run 20.
100 + 3. **db/discover.rs size (B):** Repetitive SQL templates across facet queries. Reflects the 5-8-query-per-request pattern.
101 + 4. **db/cart.rs correctness (B+):** PWYW minimum not enforced in `effective_price_cents()`, allowing cart bypass of creator-set minimums.
102 + 5. **scheduler/ concurrency (B+):** Advisory lock acquired from pool, not pinned to connection -- lock released immediately, providing no mutual exclusion.
88 103
89 - ### Resolved Cold Spots (from Run 19)
104 + ### Resolved Cold Spots (from Run 20)
90 105
91 - - ~~db/moderation.rs type safety (B)~~ -- Fixed (typed IDs added)
92 - - ~~payments/connect.rs resilience (B)~~ -- Fixed (raw reqwest call removed/restructured)
106 + - ~~routes/stripe/webhook/checkout.rs size (B+)~~ -- Now 684 LOC (was 792), under guideline threshold.
93 107
94 - ## Mandatory Surprise
108 + ## Mandatory Surprises
95 109
96 - **Unexpectedly good:** The `scanning` module (2,110 LOC) implements production-grade 6-layer anti-malware infrastructure. The archive layer doesn't trust ZIP central directory size claims -- it actually decompresses entries counting bytes with an abort threshold (archive.rs:83-107). The structural analysis layer detects VMProtect-packed and UPX-packed binaries by exact section name matching to avoid false positives. ZIP bomb detection uses both compression ratio AND actual decompression byte counting. This is far above what most content platforms implement and would be impressive in a dedicated security product.
110 + **Run 21 (5 surprises, one per axis):**
111 +
112 + 1. **Payments -- Pending refund system (unexpectedly good):** `pending_refunds.rs` + `checkout_helpers.rs` + `billing.rs` implement a complete solution for Stripe webhook ordering. Refund-before-charge queues the refund, checkout-complete checks for matching pending refund, stale refunds escalate to WAM. Bidirectional matching with `FOR UPDATE SKIP LOCKED`. Most production Stripe integrations lack this.
113 +
114 + 2. **Storage -- `claim_pending_build` (unexpectedly good):** Uses `NOT EXISTS (SELECT 1 FROM ota_builds WHERE status = 'running')` inside `FOR UPDATE SKIP LOCKED` for global single-build concurrency without advisory locks or external coordination.
115 +
116 + 3. **UX Wiring -- `json_escape` HTML entity escaping (unexpectedly good):** `types/mod.rs:214` escapes `<`, `>`, `&` as Unicode escape sequences (`\u003c`, etc.) for JSON-LD `<script>` blocks rendered with `|safe`. Prevents XSS breakout from user-controlled data in JSON-LD. Explicitly tested.
117 +
118 + 4. **Security -- SHA-256-based `constant_time_compare` (unexpectedly good):** `crypto.rs:7-18` hashes both inputs with SHA-256 before XOR comparison. This eliminates timing side channels from both value AND length differences -- goes beyond what most frameworks provide.
119 +
120 + 5. **Performance -- Scheduler advisory lock not pinned (unexpectedly bad):** `scheduler/mod.rs:77` acquires `pg_try_advisory_lock` from the pool -- the connection returns immediately, releasing the lock in microseconds. During rolling deploys, two instances could both claim the lock and execute duplicate work. The codebase knows how to pin connections (`db/mod.rs:86` uses `pool.acquire()`), making this inconsistency surprising.
97 121
98 122 ### Previous Surprises
99 123
124 + **Run 20:** Scanning module -- 6-layer anti-malware with actual decompression, VMProtect/UPX detection, ZIP bomb byte counting.
125 +
100 126 **Run 19:** Hand-rolled Stripe v2 webhook signature verification with replay protection.
101 127
102 128 **Run 18:** Sandbox tier mismatch bug (SmallFiles vs small_files). Fixed.
@@ -108,36 +134,179 @@
108 134 ## Strengths
109 135
110 136 ### 1. Security-in-depth
111 - Zero SQL injection vectors across 200+ queries. Argon2id with explicit params (46MiB/2 iterations), CSRF synchronizer tokens with constant-time comparison, session fixation prevention, account lockout, rate limiting, HMAC-signed URLs, 6-layer malware scanning pipeline with fail-closed design. ZIP bomb detection, path traversal prevention in archives, shell command validation in build runner.
137 + Zero SQL injection vectors across 200+ queries. Argon2id (46MiB/2 iterations), SHA-256-based constant-time comparison (length-independent), CSRF synchronizer tokens, session fixation prevention, account lockout with anti-enumeration dummy hashes, PKCE S256 required, rate limiting on all endpoint classes, HMAC-signed URLs, 6-layer malware scanning with fail-closed, ZIP bomb detection, path traversal prevention, shell command validation. New: TOTP replay prevention via `last_used_step`, passkey counter updates preventing cloning attacks.
112 138
113 139 ### 2. Type safety discipline
114 - 36 UUID newtypes via `define_pg_uuid_id!`, 25+ domain enums via `impl_str_enum!`, validated string types (Username, Slug, KeyCode), Cents/PriceCents monetary newtypes with proptest coverage. Compile-time template verification via Askama. Proof-carrying types: once constructed, guaranteed valid.
140 + 36 UUID newtypes via `define_pg_uuid_id!`, 25+ domain enums via `impl_str_enum!`, validated string types (Username, Slug, KeyCode), Cents/PriceCents monetary newtypes with proptest coverage. Compile-time template verification via Askama. All money math in integer cents (i32/i64), zero floating point in money paths. `SUM(BIGINT)::BIGINT` cast used consistently across all aggregate queries.
115 141
116 - ### 3. Test quality
117 - 1,214+ test annotations with per-test database isolation. Property-based testing with proptest (pricing, formatting, validated types). Adversarial tests cover SQL injection, XSS, path traversal, formula injection, ZIP bombs. Integration harness mocks all external dependencies (Stripe, S3, Postmark, ClamAV).
142 + ### 3. Payment robustness
143 + Three-layer webhook idempotency (event dedup table, status-based WHERE clauses, ON CONFLICT). Bidirectional pending refund matching. Atomic promo code reservation with cleanup on abandonment. `FOR UPDATE` row locking on tier deletion, license activation, pending refund claims. Self-purchase blocked across all checkout paths.
118 144
119 145 ## Weaknesses
120 146
121 - ### 1. Observability gaps in embed/ and payments/
122 - The embed module (serving third-party iframe traffic) and payments module (handling money) have zero `#[instrument]` annotations. These are high-value modules where tracing would provide the most benefit.
147 + ### 1. Discover page query multiplication (NEW)
148 + Full discover page fires 5-8 sequential DB queries against overlapping base tables. The HTMX partial path (`discover_results`) correctly runs only 2 queries. At scale, this is the first bottleneck.
123 149
124 - ### 2. Five files above 500-line size guideline
125 - health.rs (844), webhook/checkout.rs (792), exports.rs (737), license_keys.rs (741), tabs/user.rs (707). None are egregious but represent opportunities for extraction.
150 + ### 2. Observability gaps in embed/ and payments/ (CHRONIC)
151 + Zero `#[instrument]` annotations in both modules. Carried from Run 20 (#58, #59). Third consecutive audit flagging this.
126 152
127 - ### 3. async-trait still in use
128 - 3 trait definitions still use `async-trait` crate instead of Rust 2024 native async traits (StorageBackend, EmailTransport, PaymentProvider). Chronic -- carried from Run 18.
153 + ### 3. Storage accounting drift windows
154 + Non-atomic confirm upload (increment storage, then update item in separate queries) and soft-delete purge (deletes items without decrementing storage or cleaning version S3 keys) create drift windows. Weekly `recalculate_all_storage_batch` corrects drift, but the window is hours to days.
155 +
156 + ### 4. async-trait still in use (CHRONIC)
157 + 3 trait definitions still use `async-trait` crate. Carried from Run 18. Fourth consecutive audit.
158 +
159 + ## Bug Reports by Axis
160 +
161 + ### Payments
162 + 1 SERIOUS, 1 MINOR, 5 NOTE
163 +
164 + | # | Sev | Location | Description |
165 + |---|-----|----------|-------------|
166 + | P1 | **SERIOUS** | `db/cart.rs:104-108` | Cart PWYW minimum not enforced -- `effective_price_cents()` uses `.max(0)` but not `.max(pwyw_min_cents)`. Buyer can set $0.01 for a $5-minimum item via cart. Single-item checkout validates correctly via `item_pricing.validate_amount()`. |
167 + | P2 | MINOR | `routes/stripe/webhook/checkout.rs:643` | Guest checkout `increment_sales_count` outside transaction (pool, not db_tx). Cosmetic drift only. |
168 + | P3 | NOTE | `pricing.rs:134-148` | FixedPricing has no upper cap on amount. Not exploitable -- fixed-price path uses `item.price_cents`, not user input. |
169 + | P4 | NOTE | `pricing.rs:113-115` | FixedPricing `is_free()` hardcoded false. Unreachable -- `for_item()` routes price=0 to FreePricing. |
170 + | P5 | NOTE | `routes/stripe/checkout/tips.rs:59` | Tip dollar-to-cents multiply could overflow. Guarded by `amount_dollars > 10_000` check. |
171 + | P6 | NOTE | `payments/checkout.rs:253-257` | Cart metadata lacks per-item IDs. Reconstructed from pending_transactions. |
172 + | P7 | NOTE | `db/subscriptions.rs:399-407` | `has_active_subscription_to_project` doesn't check `cancel_at_period_end`. By design (access until period end). |
173 +
174 + ### Storage
175 + 2 SERIOUS, 3 MINOR, 1 NOTE
176 +
177 + | # | Sev | Location | Description |
178 + |---|-----|----------|-------------|
179 + | S1 | **SERIOUS** | `scheduler/cleanup.rs:188-222` | Soft-delete purge doesn't decrement `storage_used_bytes` or clean version S3 keys. Version rows CASCADE-delete, losing S3 key data permanently. Orphaned S3 objects. |
180 + | S2 | **SERIOUS** | `routes/storage/uploads.rs:225-248` | Non-atomic confirm: `try_increment_storage` then 2 separate UPDATEs without transaction. Partial failure = stale file_size_bytes + permanent storage drift. |
181 + | S3 | MINOR | `routes/storage/images.rs:326-342` | Image replacement doesn't delete old S3 object. Old covers orphaned permanently. |
182 + | S4 | MINOR | `migrations/101_pending_uploads.sql` | No `UNIQUE(s3_key)` on pending_uploads. Duplicate rows possible on retry. |
183 + | S5 | MINOR | `routes/storage/media.rs:80-91` | `classify_media` accepts any `image/*` including `image/svg+xml`. Subsequent `validate_content_type` catches it. |
184 + | S6 | NOTE | `storage.rs:446-465` | `extract_s3_key_from_url` includes bucket name for path-style URLs. Only used for project images which handle it. |
185 +
186 + ### UX Wiring
187 + 0 SERIOUS, 2 MINOR, 5 NOTE
188 +
189 + | # | Sev | Location | Description |
190 + |---|-----|----------|-------------|
191 + | U1 | MINOR | `routes/stripe/checkout/mod.rs:105-108` | Checkout cancel `item_id` not validated as UUID before `format!("/i/{}", id)`. Path traversal to internal routes possible via crafted cancel URL. |
192 + | U2 | MINOR | `templates/pages/purchase.html:122` | PWYW `amount_cents` hidden field starts empty. JS-disabled submit sends empty string. |
193 + | U3 | NOTE | `templates/pages/login.html:13-16` | Login form lacks CSRF. Intentional (pre-auth exempt). |
194 + | U4 | NOTE | `templates/public.rs:471-482` | BuyPageTemplate lacks csrf_token field entirely. Intentional (guest checkout). |
195 + | U5 | NOTE | `routes/stripe/checkout/item.rs:312` | Cancel URL built without URL-encoding. UUID is URL-safe, so harmless. |
196 + | U6 | NOTE | `templates/pages/oauth_authorize.html:52` | redirect_uri in hidden input. Auto-escaped. Standard OAuth. |
197 + | U7 | NOTE | `formatting.rs:4-13` | `format_price` uses f64 division. Correct for all practical prices with `{:.2}`. |
198 +
199 + ### Security
200 + 0 SERIOUS, 3 MINOR, 3 NOTE
201 +
202 + | # | Sev | Location | Description |
203 + |---|-----|----------|-------------|
204 + | X1 | MINOR | `scanning/clamav.rs:96` | `contains("FOUND")` could misclassify hypothetical error containing "FOUND". Should be `ends_with("FOUND")`. |
205 + | X2 | MINOR | `scanning/archive.rs:70` | URL-encoded path traversal check misses mixed-case `%2e%2E`. |
206 + | X3 | MINOR | `routes/oauth.rs:253-256` | OAuth authorize accepts legacy sessions without tracking validation. Stale session could authorize grant. |
207 + | X4 | NOTE | `scanning/mod.rs:279` | Pipeline integration test only exercises 3 of 7 FileType variants. |
208 + | X5 | NOTE | `scanning/hash_lookup.rs:84` | MalwareBazaar `no_results` treated as Error (held for review). Deliberate fail-closed. |
209 + | X6 | NOTE | `routes/api/users/profile.rs:140-146` | Breached password check is advisory-only. Documented policy decision. |
210 +
211 + ### Performance
212 + 2 SERIOUS, 3 MINOR, 2 NOTE
213 +
214 + | # | Sev | Location | Description |
215 + |---|-----|----------|-------------|
216 + | F1 | **SERIOUS** | `routes/pages/public/discover.rs:292-513` | Full discover page fires 5-8 sequential DB queries (items + count + 4-5 facet queries). At 25-connection pool, ~4 concurrent full loads could exhaust pool. HTMX partial runs only 2 queries. |
217 + | F2 | **SERIOUS** | `scheduler/mod.rs:77-88` | Advisory lock acquired from pool -- connection returns immediately, lock held for microseconds. Two scheduler instances can both proceed during rolling deploy. `db/mod.rs:86` shows correct `pool.acquire()` pattern. |
218 + | F3 | MINOR | `scheduler/integrity.rs:54-65` | `check_sales_count_drift` full-scans items JOIN transactions. Weekly, but expensive at scale. |
219 + | F4 | MINOR | `metrics.rs:206` | Idempotency middleware buffers full response body (up to 1MB). |
220 + | F5 | MINOR | `build_runner.rs:383-386` | Build artifacts loaded entirely into RAM before S3 upload. Infrequent, low priority. |
221 + | F6 | NOTE | `routes/synckit/subscribe.rs:123` | SSE broadcast channel size 16. Lag handled by filtering. By design. |
222 + | F7 | NOTE | `routes/synckit/sync.rs:98-100` | Push/pull fetch all devices for validation. Bounded at 50 per app. |
223 +
224 + ## Cross-Cutting Concerns
225 +
226 + ### Storage accounting drift (Storage + Performance)
227 + Three separate mechanisms can cause storage_used_bytes to drift: (1) soft-delete purge doesn't decrement (S1), (2) non-atomic confirm upload (S2), (3) image replacement doesn't delete old S3 objects (S3). The `recalculate_all_storage_batch` weekly job corrects byte counts but does NOT clean orphaned S3 objects. Consider a unified approach: the planned `pending_s3_deletions` durable queue (already in todo.md backlog) would address S1+S3.
228 +
229 + ### Scheduler concurrency (Performance + Storage)
230 + The unpinned advisory lock (F2) means duplicate scheduler ticks could run concurrently. This compounds with S1 -- duplicate purge ticks could attempt to delete already-deleted S3 objects (benign due to S3 idempotency) and CASCADE-delete items twice (benign due to SQL semantics). But duplicate announcement emails (scheduler/announcements.rs) would be user-visible.
231 +
232 + ## Components Successfully Stress-Tested
233 +
234 + ### Payments (10 vectors survived)
235 + Webhook replay, double-credit, concurrent promo exhaustion, cross-user data access, self-purchase, negative/overflow amounts, floating-point money math, out-of-order webhooks, suspended creator purchases, SUM(BIGINT) pitfall.
236 +
237 + ### Storage (8 vectors survived)
238 + Cross-user file overwrites, path traversal in filenames, content type smuggling, storage quota bypass, double-spend on idempotent confirm, malware file serving, orphaned upload cleanup, SUM(BIGINT) pitfall.
239 +
240 + ### UX Wiring (10 vectors survived)
241 + XSS via template injection, CSRF bypass, open redirect, user enumeration, markdown/HTML injection, pagination abuse, integer overflow in pricing, internal detail leakage, CSV injection, Unicode boundary attacks.
242 +
243 + ### Security (17 vectors survived)
244 + Virus scan bypass via ClamAV downtime, ZIP bomb, content-type spoofing, path traversal in archives, session fixation, timing-based user enumeration, brute force login, X-Forwarded-For spoofing, session reuse after password change, OAuth code replay, PKCE downgrade, token prediction, CSRF on state-changing endpoints, SSH command injection, passkey cloning, TOTP replay, IDOR on passkeys/sessions.
245 +
246 + ### Performance (9 vectors survived)
247 + Connection pool exhaustion (except discover), file scanning memory, SSE connection accumulation, scheduler job accumulation (mostly), background task leaks, ZIP bombs, path traversal, shell injection, lock ordering.
248 +
249 + ## Confidence Assessment
250 +
251 + | Axis | Confidence | Notes |
252 + |------|-----------|-------|
253 + | Payments | HIGH | Three-layer idempotency, integer money math, bidirectional refund matching. One trust gap (PWYW cart bypass). |
254 + | Storage | HIGH (normal) / MEDIUM (edge cases) | Presigned URL security solid. Non-atomic confirms could drift under transient DB errors. Weekly recalc corrects. |
255 + | UX Wiring | HIGH | Askama auto-escape, json_escape defense-in-depth, comprehensive CSRF, no detail leakage. |
256 + | Security | HIGH | No CRITICAL or SERIOUS findings. Argon2id, constant-time everywhere, fail-closed scanning, PKCE S256. |
257 + | Performance | HIGH (current scale) | Adequate for alpha/soft launch. Discover page is first bottleneck at scale. |
258 +
259 + ## Metrics
260 +
261 + - Modules audited: 50+
262 + - Total cold spots: 5
263 + - Bugs by severity: 0 critical, 5 serious, 11 minor, 16 note
264 + - Axes at A or above: 4/5 (Performance at A-)
265 +
266 + ## Axis Summary Grades
267 +
268 + | Axis | Overall | Cold Spots | Mandatory Surprise |
269 + |------|---------|------------|-------------------|
270 + | Payments | A | db/cart.rs correctness (B+) | Pending refund bidirectional matching system |
271 + | Storage | A- | scheduler/cleanup.rs (B+), routes/storage/uploads.rs transaction safety (B) | claim_pending_build FOR UPDATE SKIP LOCKED |
272 + | UX Wiring | A | None | json_escape HTML entity escaping in JSON-LD blocks |
273 + | Security | A | None | SHA-256-based constant_time_compare eliminates length leaking |
274 + | Performance | A- | discover.rs (B), scheduler/ concurrency (B+) | Scheduler advisory lock unpinned (surprisingly bad) |
275 +
276 + ## Recommended Priority Order
277 +
278 + 1. **[SERIOUS] Pin scheduler advisory lock to connection** (`scheduler/mod.rs:77`) -- Use `pool.acquire()` and hold connection for tick duration. Prevents duplicate work during rolling deploys. Small change, high impact.
279 + 2. **[SERIOUS] Enforce PWYW minimum in cart** (`db/cart.rs:104-108`) -- Change `.max(0)` to `.max(self.pwyw_min_cents.unwrap_or(0))` in `effective_price_cents()`. Trust violation for creators.
280 + 3. **[SERIOUS] Fix soft-delete purge** (`scheduler/cleanup.rs:188-222`) -- Query version S3 keys before CASCADE delete. Decrement storage_used_bytes per user. Prevents permanent S3 orphans.
281 + 4. **[SERIOUS] Wrap confirm upload in transaction** (`routes/storage/uploads.rs:225-248`) -- Use `pool.begin()` for storage increment + item updates. Prevents drift on partial failure.
282 + 5. **[SERIOUS] Optimize discover page queries** (`routes/pages/public/discover.rs:292-513`) -- Combine facet queries into single CTE, or use `tokio::try_join!` for parallel execution. First scalability bottleneck.
283 + 6. **[MINOR] Delete old S3 objects on image replacement** (`routes/storage/images.rs:326-342`) -- Prevents permanent orphans.
284 + 7. **[MINOR] Validate checkout cancel item_id as UUID** (`routes/stripe/checkout/mod.rs:105-108`)
285 + 8. **[MINOR] Fix ClamAV FOUND check** (`scanning/clamav.rs:96`) -- Change `contains("FOUND")` to `ends_with("FOUND")`.
286 + 9. **[MINOR] Case-normalize URL-encoded path traversal check** (`scanning/archive.rs:70`)
287 + 10. **[MEDIUM] Add #[instrument] to embed/ and payments/** -- Chronic from Run 20.
129 288
130 289 ## Action Items
131 290
132 - ### Run 20 (2026-05-04)
291 + ### Run 21 (2026-05-08)
133 292
134 - 58. **[MEDIUM]** Add `#[tracing::instrument(skip_all)]` to all handlers in routes/embed/ (item.rs, project.rs, user.rs)
135 - 59. **[MEDIUM]** Add `#[tracing::instrument(skip_all)]` to functions in payments/ (checkout.rs, connect.rs, webhooks.rs)
136 - 60. **[LOW]** Split routes/stripe/webhook/checkout.rs (792 LOC) -- extract handle_* functions to submodule
137 - 61. **[LOW]** Bump transitive deps: yara-x (for intaglio fix), AWS SDK chain (for rustls-webpki fix)
138 - 62. **[DEFERRED]** Remove `async-trait` in favor of Rust 2024 native async traits (chronic, carried from Run 18 #56)
139 - 63. **[DEFERRED]** Add README.md to server/ (carried from Run 19 #53)
140 - 64. **[DEFERRED]** Split oversized route files: exports.rs, license_keys.rs, health.rs, tabs/user.rs
293 + 65. **[SERIOUS]** Pin scheduler advisory lock to acquired connection (`scheduler/mod.rs:77-88`)
294 + 66. **[SERIOUS]** Enforce PWYW minimum in cart `effective_price_cents()` (`db/cart.rs:104-108`)
295 + 67. **[SERIOUS]** Fix soft-delete purge: query version S3 keys, decrement storage, before CASCADE (`scheduler/cleanup.rs:188-222`)
296 + 68. **[SERIOUS]** Wrap confirm_upload DB writes in transaction (`routes/storage/uploads.rs:225-248`)
297 + 69. **[SERIOUS]** Optimize discover page: combine facet queries or parallelize with try_join! (`routes/pages/public/discover.rs:292-513`)
298 + 70. **[MINOR]** Delete old S3 objects on item image/audio/video replacement (`routes/storage/images.rs`, `routes/storage/uploads.rs`)
299 + 71. **[MINOR]** Validate checkout cancel `item_id` as UUID (`routes/stripe/checkout/mod.rs:105-108`)
300 + 72. **[MINOR]** Change ClamAV `contains("FOUND")` to `ends_with("FOUND")` (`scanning/clamav.rs:96`)
301 + 73. **[MINOR]** Case-normalize URL-encoded path traversal check (`scanning/archive.rs:70`)
302 + 74. **[MINOR]** Add UNIQUE(s3_key) to pending_uploads table (migration)
303 + 75. **[MEDIUM]** Add `#[tracing::instrument(skip_all)]` to embed/ handlers (chronic, from Run 20 #58)
304 + 76. **[MEDIUM]** Add `#[tracing::instrument(skip_all)]` to payments/ functions (chronic, from Run 20 #59)
305 + 77. **[MINOR]** Initialize PWYW amount_cents hidden field server-side (`templates/pages/purchase.html`)
306 + 78. **[MINOR]** Use `AuthUser` instead of `MaybeUser` for OAuth authorize (`routes/oauth.rs:253-256`)
307 + 79. **[DEFERRED]** Remove `async-trait` (chronic, from Run 18 #56 -> #62)
308 + 80. **[DEFERRED]** Add README.md to server/ (chronic, from Run 19 #53 -> #63)
309 + 81. **[DEFERRED]** Split oversized route files: health.rs (846), exports.rs (842), tabs/user.rs (815)
141 310
142 311 ### Open (blocked on upstream)
143 312
@@ -146,19 +315,50 @@
146 315 25. Monitor aws-sdk-s3 for rustls-webpki 0.101.7 fix (RUSTSEC-2026-0049)
147 316 33. bincode unmaintained (RUSTSEC-2025-0141) -- upstream via syntect/yara-x, warning only
148 317
149 - ## Previous Action Item Verification (Run 19)
318 + ## Previous Action Item Verification (Run 20)
150 319
151 320 | # | Item | Status |
152 321 |---|------|--------|
153 - | 51 | Add timeout to payments/connect.rs raw reqwest call | Fixed (call restructured) |
154 - | 52 | Add ModerationActionId newtype to db/moderation.rs | Fixed |
155 - | 53 | Add README.md to server/ | Unfixed (carried as #63) |
156 - | 54 | Bump dependency pins (tokio, uuid, chrono, yara-x, anyhow) | Fixed (all at latest) |
157 - | 55 | Extract inline SQL from route handlers (4 locations) | Fixed (only health.rs COUNT stats remain -- acceptable) |
158 - | 56 | Remove async-trait | Unfixed (chronic, carried as #62) |
159 - | 57 | Migrate onclick to addEventListener for strict CSP | Fixed (via dashboard usability rework) |
322 + | 58 | Add #[instrument] to routes/embed/ | **Unfixed** (chronic, carried as #75) |
323 + | 59 | Add #[instrument] to payments/ | **Unfixed** (chronic, carried as #76) |
324 + | 60 | Split webhook/checkout.rs (792 LOC) | **Fixed** (now 684 LOC) |
325 + | 61 | Bump transitive deps | Partially fixed (yara-x still has wasmtime advisory) |
326 + | 62 | Remove async-trait | **Unfixed** (chronic, carried as #79) |
327 + | 63 | Add README.md to server/ | **Unfixed** (chronic, carried as #80) |
328 + | 64 | Split oversized route files | **Unfixed** (carried as #81) |
160 329
161 - 5 of 7 Run 19 items fixed. 2 carried forward (1 chronic). No regressions.
330 + 2 of 7 Run 20 items fixed. 5 carried forward (3 chronic). No regressions.
331 +
332 + ### Chronic Items (unfixed across 3+ consecutive runs)
333 +
334 + | Item | First flagged | Runs unfixed |
335 + |------|--------------|-------------|
336 + | Remove async-trait | Run 18 | 4 (18, 19, 20, 21) |
337 + | Add #[instrument] to embed/ | Run 20 | 2 (20, 21) |
338 + | Add #[instrument] to payments/ | Run 20 | 2 (20, 21) |
339 + | Add README.md to server/ | Run 19 | 3 (19, 20, 21) |
340 +
341 + ## Delta Since Run 20
342 +
343 + ### Fixed
344 + - webhook/checkout.rs reduced from 792 to 684 LOC (below 500-line concern threshold for this module)
345 + - Version bumped from 0.4.10 to 0.5.7
346 + - 8 new migrations (093-101 -> 101 total)
347 + - LOC grew from ~83K to ~87K (+4K)
348 + - Test annotations from 1,214 to 1,218
349 +
350 + ### New Findings (not in Run 20)
351 + - Cart PWYW minimum bypass (SERIOUS)
352 + - Soft-delete purge missing version S3 keys + storage decrement (SERIOUS)
353 + - Non-atomic confirm upload (SERIOUS)
354 + - Discover page 5-8 queries per request (SERIOUS)
355 + - Scheduler advisory lock not pinned (SERIOUS)
356 + - 6 new MINOR findings (old S3 cleanup, cancel UUID, ClamAV FOUND, path traversal case, pending_uploads UNIQUE, OAuth legacy session)
357 +
358 + ### Grade Changes
359 + - Performance: A -> A- (discover page finding)
360 + - Concurrency: A -> A- (scheduler lock finding)
361 + - Overall: A (held)
162 362
163 363 ## Metrics Over Time
164 364
@@ -183,6 +383,7 @@
183 383 | 2026-05-01 (Run 18) | ~80,470 | -- | 1,933 (34 int. fail) | ~15.1 | 0 | 5 | A |
184 384 | 2026-05-02 (Run 19) | ~81,384 | -- | 1,923 (0 fail) | ~23.6 | 0 | 2 | A |
185 385 | 2026-05-04 (Run 20) | ~83,232 | 238 | 1,214+ annotations | ~14.6 | 0 | 2 | A |
386 + | 2026-05-08 (Run 21) | ~87,427 | -- | 1,218 annotations | ~13.9 | 0 | 5 | A |
186 387
187 388 ---
188 389
@@ -512,6 +512,10 @@
512 512
513 513 - [ ] Phase 22E: MediaMTX deployment on alpha-west-1 (install binary, systemd unit, Caddy config, Cloudflare DNS, firewall rules)
514 514 - [ ] Add `ffprobe` to production server (Phase 14E-1)
515 + - [ ] Generate Tauri signing key: `cargo tauri signer generate -w ~/.tauri/mnw.key` — save private key, note public key
516 + - [ ] Deploy signing key to each build host: set `TAURI_SIGNING_PRIVATE_KEY` env var (or `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` if password-protected) in the build user's environment
517 + - [ ] Add public key to each app's `tauri.conf.json` under `plugins.updater.pubkey`
518 + - [ ] Set S3 lifecycle rule on the upload prefix: delete incomplete/unconfirmed objects after 36 hours (safety net for presigned URL cleanup)
515 519
516 520 ---
517 521
@@ -1,7 +1,7 @@
1 1 # Makenotwork TODO
2 2
3 3 ## Status
4 - v0.5.0 deployed 2026-05-06. Soft launch target 2026-05-09. Audit grade A. ~85K LOC, 1,912 tests, 0 warnings (verified). Migration 100. Sprints 1-9 complete (see `todo_done.md`).
4 + v0.5.7 deployed 2026-05-08. Soft launch target 2026-05-09. Audit grade A (Run 21). ~87K LOC, 1,218 test annotations, 0 warnings. Migration 101. Sprints 1-9 complete (see `todo_done.md`).
5 5
6 6 Human tasks in `human_todo.md`. Completed items in `todo_done.md`.
7 7
@@ -16,6 +16,28 @@
16 16
17 17 ---
18 18
19 + ## Ultra Fuzz Run 21 (2026-05-08)
20 +
21 + ### SERIOUS (fix before launch)
22 + - [x] Pin scheduler advisory lock to acquired connection — hold for tick duration (`scheduler/mod.rs:77-88`)
23 + - [x] Enforce PWYW minimum in cart `effective_price_cents()` — `.max(pwyw_min_cents)` (`db/cart.rs:104-108`)
24 + - [x] Fix soft-delete purge: query version S3 keys + decrement storage before CASCADE (`scheduler/cleanup.rs:188-222`)
25 + - [x] Wrap confirm_upload DB writes in single UPDATE — rollback storage on failure (`routes/storage/uploads.rs:225-248`)
26 + - [x] Optimize discover page: parallelize facet queries with try_join! (`routes/pages/public/discover.rs:292-513`)
27 +
28 + ### MINOR/MEDIUM (current phase)
29 + - [x] Delete old S3 objects on item image/audio/video replacement (`routes/storage/images.rs`, `routes/storage/uploads.rs`)
30 + - [x] Validate checkout cancel `item_id` as UUID (`routes/stripe/checkout/mod.rs:105-108`)
31 + - [x] Change ClamAV `contains("FOUND")` to `ends_with("FOUND")` (`scanning/clamav.rs:96`)
32 + - [x] Case-normalize URL-encoded path traversal check (`scanning/archive.rs:70`)
33 + - [x] Add UNIQUE(s3_key) to pending_uploads table (migration 102)
34 + - [x] Add `#[tracing::instrument(skip_all)]` to embed/ handlers (was mostly done; added item_player)
35 + - [x] payments/ already had #[instrument] on all async fns (webhook extractors are pure, no I/O)
36 + - [x] Initialize PWYW amount_cents hidden field server-side (`templates/pages/purchase.html`)
37 + - [x] Use re-auth for OAuth authorize with legacy sessions (`routes/oauth.rs:253-256`)
38 +
39 + ---
40 +
19 41 ## Audit Remediations (2026-05-08, post-Sprint 9)
20 42
21 43 - [x] Fix XSS: escape `s.category` and `s.url` in search suggestions innerHTML (discover.html)
@@ -27,6 +49,145 @@
27 49
28 50 ---
29 51
52 + ## Code Fuzz Findings (2026-05-08)
53 +
54 + Two-pass fuzz: initial scan + deep verification. Items marked REFUTED were disproven in the second pass. Items marked CONFIRMED were verified with exact evidence.
55 +
56 + ### Payments & Checkout
57 + - [x] SERIOUS: Cart refund only cleans up ONE item — changed `fetch_optional` to `fetch_all`, refund handler now iterates all matching transactions (`db/transactions.rs`, `routes/stripe/webhook/billing.rs`)
58 + - [x] MINOR: No buyer != seller validation in cart checkout — added `user.id == seller_id` guard (`routes/stripe/checkout/cart.rs`)
59 + - [x] MINOR: Guest checkout `increment_sales_count` error was silently swallowed — now propagates error so Stripe retries (`routes/stripe/webhook/checkout.rs`)
60 + - [x] MINOR: Tips lack `check_not_suspended()` — added (`routes/stripe/checkout/tips.rs`)
61 + - [ ] MINOR: Orphaned pending transactions on partial cart failure — mitigated by 24h stale cleanup (`routes/stripe/checkout/cart.rs:317-353`)
62 + - [x] MINOR: `CartLineItem.amount_cents` is `i32` while `Cents` wraps `i64` — changed to `i64`, removed redundant cast (`payments/checkout.rs`)
63 + - [ ] MINOR: No Stripe minimum amount ($0.50) enforcement before creating session (`payments/checkout.rs:108,244`)
64 + - [ ] MINOR: Cart items removed before Stripe session completes — cancel = empty cart, intentional UX trade-off (`routes/stripe/checkout/cart.rs:356,673`)
65 + - [ ] MINOR: Project checkout uses `ItemId::nil()` — needs schema change to support per-project unique constraint (`routes/stripe/checkout/project.rs:135,148`)
66 + - [ ] MINOR: v2 webhook handler swallows account update failures — needs v2 retry queue infrastructure (`routes/stripe/webhook_v2.rs:95-106`)
67 + - ~~SERIOUS: Partial Stripe refunds revoke full access~~ REFUTED — `is_full_refund()` check at `billing.rs:272` prevents this
68 + - ~~MINOR: $0 items dropped in `process_seller_checkout`~~ REFUTED — unreachable; `process_seller_checkout` is only called with `promo_code: None`
69 +
70 + ### Pricing, Promo Codes & Transactions
71 + - [x] MINOR: Cross-creator promo code collision — added `code_purpose = 'free_access'` filter to `get_promo_code_by_code` (`db/promo_codes.rs`)
72 + - [x] MINOR: `remove_free_item_from_library` doesn't decrement promo code `use_count` — now returns and decrements via `release_use_count` (`db/transactions.rs`)
73 + - [x] MINOR: `claim_free_with_promo_code` doesn't record `promo_code_id` — added to INSERT (`db/transactions.rs`)
74 + - [x] MINOR: Tautology `if s == s { 23 } else { 0 }` in `parse_date` — removed dead branch (`routes/api/promo_codes.rs`)
75 + - [x] MINOR: Duplicate promo code creation returns 500 — added 23505 handling with friendly message (`routes/api/promo_codes.rs`)
76 + - [x] MINOR: Can create already-expired promo codes — added `expires_at > now()` check (`routes/api/promo_codes.rs`)
77 + - [x] MINOR: Subscription trial promo code `use_count` never released on abandoned checkout — subscription checkout now creates pending transaction row, cleaned up by existing 25h stale cleanup (`db/transactions.rs`, `routes/stripe/checkout/subscriptions.rs`, `routes/stripe/webhook/checkout.rs`)
78 + - [x] MINOR: `update_promo_code` allows setting `max_uses` below current `use_count` — added guard rejecting max_uses < current use_count (`routes/api/promo_codes.rs`)
79 + - [x] MINOR: License key generation after promo claim is non-transactional — `claim_free_with_promo_code` now accepts `LicenseKeyParams` and creates key inside the same transaction (`db/transactions.rs`, `routes/api/promo_codes.rs`, `routes/stripe/checkout/item.rs`)
80 + - [x] NOTE: `FixedPricing::validate_amount` has no upper cap — not exploitable, fixed-price checkout uses `item.price_cents` (`pricing.rs:134-143`)
81 +
82 + ### Subscriptions
83 + - [x] SERIOUS: Re-subscription after cancel silently no-ops — changed `ON CONFLICT DO NOTHING` to `DO UPDATE` with reactivation (`db/creator_tiers.rs`, `db/fan_plus.rs`)
84 + - [x] SERIOUS: `handle_subscription_updated` with `status=canceled` never sets `canceled_at` — all 4 `update_*_status` functions now set `canceled_at` via CASE/COALESCE (`db/subscriptions.rs`, `db/creator_tiers.rs`, `db/fan_plus.rs`, `db/app_sync.rs`)
85 + - [x] MINOR: `cancel_subscription` overwrites `canceled_at` on re-cancel — all 4 cancel functions now use `COALESCE(canceled_at, NOW())`
86 + - [x] MINOR: `resume_subscriptions_for_creator` doesn't filter on `status = 'active'` — added `AND status = 'active'` (`db/subscriptions.rs`)
87 + - [x] MINOR: `get_user_subscribed_item_ids` grants access to paused item subs — added `AND paused_at IS NULL` (`db/subscriptions.rs`)
88 + - [x] LOW: `get_project_subscriber_count` counts paused subscriptions — added `AND paused_at IS NULL` (`db/subscriptions.rs`)
89 + - [x] LOW: Grace period upload rejection returns bare 403 — changed to `BadRequest` with descriptive message (`db/creator_tiers.rs`)
90 + - [x] NOTE: Everything tier checkout hard-blocked — guard removed, tier is live and priced (`routes/stripe/checkout/subscriptions.rs`)
91 +
92 + ### Auth & Sessions
93 + - [x] MEDIUM: Passkey registration does not require re-authentication — added password confirmation to `register_start` + JS prompt (`routes/api/passkeys.rs`, `static/passkey.js`)
94 + - [x] MINOR: Password reset doesn't invalidate other sessions — added `delete_all_sessions_for_user` + cache eviction (`routes/pages/email_actions/password.rs`)
95 + - [x] LOW: Email verification HMAC lacks purpose prefix — added `"verify:"` prefix to generate and verify (`email/tokens.rs`)
96 + - [x] NOTE: CSRF exemption comment says SameSite=Strict but cookie is Lax — corrected comment (`csrf.rs:131`)
97 + - [x] NOTE: `authorize_post` duplicates `AuthUser` session validation logic — correct but fragile, accepted (`routes/oauth.rs:222-258`)
98 +
99 + ### Storage & Uploads
100 + - [x] SERIOUS: Internal CLI upload skips S3 key validation — added `expected_prefix` check matching all other confirm handlers (`routes/api/internal/uploads.rs`)
101 + - [x] SERIOUS: Internal CLI upload increments storage AFTER writing DB record — moved `try_increment_storage` before DB writes, deletes S3 on quota failure (`routes/api/internal/uploads.rs`)
102 + - [x] MINOR: Insertion presign skips `check_presign_allowed` quota check — added quota check before presign (`routes/api/content_insertions.rs`)
103 + - [x] MINOR: Filenames sanitizing to empty create ambiguous S3 keys — fallback to `"file"` basename (`storage.rs`)
104 + - [x] MINOR: Insertion confirm stores client-supplied `mime_type` without re-validation — added `validate_content_type` at confirm (`routes/api/content_insertions.rs`)
105 + - [x] LOW: Media delete swallows S3 error then deletes DB record — now logs S3 errors, decrements storage before DB delete (`routes/storage/media.rs`)
106 + - [x] SERIOUS: Presigned URLs have no S3 lifecycle cleanup — added `pending_uploads` table, reaper job (24h), S3 lifecycle rule (36h) in human_todo (`routes/storage/uploads.rs`)
107 + - [ ] MINOR: Storage counter decrement-then-increment for file replacement not transactional — mitigated by `recalculate_all_storage_batch` cron (`routes/storage/uploads.rs:206-225`)
108 + - [ ] MINOR: `classify_media` trusts client-supplied `content_type` for size limits — attacker wastes own quota (`media.rs:80-91`)
109 + - [ ] MINOR: Creator cannot preview own draft content via stream/download — needs `is_creator` check in access control (`routes/storage/downloads.rs:76-78`)
110 + - [ ] LOW: Play count inflatable via unauthenticated spam — needs per-IP dedup or rate limiting (`routes/storage/downloads.rs:121`)
111 + - [ ] NOTE: `extract_s3_key_from_url` may include bucket name for path-style URLs — only used for project images which handle it (`storage.rs:427-446`)
112 + - [ ] NOTE: Project image old-file cleanup relies on URL-to-key extraction — fragile but functional (`routes/storage/images.rs:169-177`)
113 +
114 + ### File Scanning
115 + - [x] MEDIUM: Archive decompression read error silently swallowed — on read error, now falls back to claimed size instead of zero (`scanning/archive.rs`)
116 + - [x] MEDIUM: Audio/Insertion files with unrecognized magic bytes pass ALL layers — now Fail like images do (`scanning/content_type.rs`)
117 + - [ ] MEDIUM: No scanning when scanner=None + trusted user — by-design trust model; needs async background scan mode to fix without breaking upload UX (`scanning/mod.rs:142-151`)
118 + - [x] MINOR: `ends_with("OK")` too permissive for ClamAV response parsing — changed to exact match `response == "stream: OK"` (`scanning/clamav.rs`)
119 + - [ ] MINOR: Archive scan skips cover images with ZIP magic bytes — layer 1 catches type mismatch; removing skip would be defense-in-depth but no current exploit path (`scanning/archive.rs:29-36`)
120 + - [x] MINOR: Path traversal check misses URL-encoded and Unicode normalization variants — added `%2e%2e`, absolute path, and null byte checks (`scanning/archive.rs`)
121 + - [ ] MINOR: Nested archive detection double-decompresses entries — needs refactor to save first 8 bytes during initial decompression pass (`scanning/archive.rs:83-142`)
122 + - [x] NOTE: Unrecognized video data gets `Pass` instead of failing — now Fail like images (`scanning/content_type.rs`)
123 + - [ ] NOTE: Mach-O binaries get unconditional `Pass` — needs research into suspicious Mach-O import patterns to match PE/ELF analysis (`scanning/structural.rs:44-49`)
124 + - [x] NOTE: Download XSS blocklist missing `image/svg+xml` and `application/xhtml+xml` — added both (`scanning/content_type.rs`)
125 +
126 + ### DB & Validation
127 + - [x] SERIOUS: `count_discover_items` missing `deleted_at IS NULL` — added filter (`db/discover.rs`)
128 + - [x] MINOR: `get_bundleable_items` includes soft-deleted items — added `deleted_at IS NULL` (`db/bundles.rs`)
129 + - [x] MINOR: `get_item_type_counts`, `get_price_range_counts`, `get_ai_tier_counts` missing `deleted_at IS NULL` — all three fixed (`db/discover.rs`)
130 + - [x] MINOR: `get_tag_counts` missing `deleted_at IS NULL` and `listed = true` — added both (`db/tags.rs`)
131 + - [x] MINOR: `get_all_tag_counts` missing `listed` and `deleted_at` filters — added (`db/tags.rs`)
132 + - [x] MINOR: `search_suggestions` exposes suspended/deactivated usernames — added `suspended_at IS NULL AND deactivated_at IS NULL` (`db/discover.rs`)
133 + - [x] NOTE: `discover_projects` item_count includes soft-deleted items — added `deleted_at IS NULL` to FILTER clause (`db/discover.rs`)
134 + - [x] MINOR: `get_items_by_user` and `count_items_by_user_projects` include soft-deleted items — added `deleted_at IS NULL` (`db/items.rs`)
135 + - [x] MINOR: `bulk_update_price` takes raw `i32` instead of `PriceCents` — changed to `PriceCents` (`db/items.rs`, `routes/api/items/bulk.rs`)
136 + - [x] MINOR: `duplicate_item` slug retry loop has no counter cap — added cap at 100 (`db/items.rs`)
137 + - [x] MINOR: `get_deleted_items_by_project` has no LIMIT clause — added LIMIT 500 (`db/items.rs`)
138 + - [ ] MINOR: `item_slug_exists` considers soft-deleted items — intentional during 7-day recovery window (`db/items.rs:80-94`)
139 + - [x] MINOR: `get_all_users` accepts arbitrary `limit`/`offset` — clamped to 200 (`db/users.rs`)
140 + - [ ] MINOR: Guest checkout auto-attach not in transaction — mitigated by `attach_guest_purchases_by_email` safety net (`db/transactions.rs:83-89`)
141 + - [ ] NOTE: `set_bundle_items` no ownership check at DB layer — all callers verify ownership at route layer (`db/bundles.rs:148-179`)
142 + - [ ] NOTE: Idempotency key scope mismatch — UUID-based keys make collision across endpoints near-impossible (`db/idempotency.rs:24-64`)
143 +
144 + ### Git SSH & Build Runner
145 + - [x] MEDIUM: Unsanitized SSH command passthrough — `exec_git_shell` now receives a reconstructed command from validated components (`git_ssh.rs`)
146 + - [x] MEDIUM: Partial-failure builds create live OTA releases — now returns early with Failed status, no release created (`build_runner.rs`)
147 + - [x] MINOR: `&repo.description[..25]` byte-position slice panics on multi-byte UTF-8 — uses `chars().take(25)` (`git_ssh.rs`)
148 + - [x] LOW: `cmd_ssh_repo_delete` path not canonicalized — added `canonicalize` + `starts_with` check (`git_ssh.rs`)
149 + - [x] MEDIUM: Empty signature on automated builds — build runner now SCPs .sig files, passes to release. Key generation in human_todo (`build_runner.rs`)
150 + - [x] MINOR: TOCTOU race between `has_running_build` and `get_pending_build` — replaced with atomic `claim_pending_build` using `FOR UPDATE SKIP LOCKED` (`db/builds.rs`, `build_runner.rs`)
151 + - [ ] LOW: Global build trigger token readable from repo hooks directory (`build_runner.rs:14-48`)
152 + - [x] LOW: `get_latest_release` SQL `::int[]` cast crashes on non-numeric version parts — added regex guard, non-numeric sorts last (`db/ota.rs`)
153 + - [x] LOW: No version uniqueness enforcement on OTA releases — constraint already existed in migration 033, added `ON CONFLICT` handling returning 409 (`db/ota.rs`)
154 + - [x] LOW: S3 key collision on same-version rebuilds — resolved by OTA version uniqueness constraint (`db/ota.rs`)
155 + - [x] LOW: Build log stores unsanitized build output — added ANSI escape stripping (`build_runner.rs`)
156 + - [ ] LOW: `StrictHostKeyChecking=accept-new` trusts on first SSH connect (`build_runner.rs:421,455`)
157 + - ~~MINOR: `hook_trigger` doesn't check `config.enabled`~~ REFUTED — DB query includes `AND enabled = true`
158 +
159 + ### Scheduler & Infrastructure
160 + - [x] SERIOUS: Soft-deleted item purge never cleans S3 — added `get_expired_deleted_item_s3_keys` query + S3 delete before DB purge (`scheduler/cleanup.rs`, `db/items.rs`)
161 + - [x] MODERATE: Dead webhook retry storm — added `AND attempts < 5` guard to `get_retryable_events` query (`db/webhook_events.rs`)
162 + - [x] MINOR: WAM client double-slash URL — `trim_end_matches('/')` on base_url (`wam_client.rs`)
163 + - [x] NOTE: `COUNT(*) LIMIT 1` no-op in monitor — changed to `SELECT EXISTS(...)` (`monitor.rs`)
164 + - [ ] MODERATE: S3 delete then DB delete crash gap — partial S3 prefix deletion + DB delete on retry = permanent orphans (`scheduler/cleanup.rs:26-74`) — see Backlog: `pending_s3_deletions` plan
165 + - [x] MINOR: Stale refund escalation sends duplicate alerts — `mark_escalated` now runs before alerts, skips on failure (`scheduler/webhooks.rs`)
166 + - [ ] NOTE: Announcement emails lost on server restart — no delivery persistence (`scheduler/announcements.rs:59-85`)
167 + - [ ] NOTE: Duplicate onboarding emails on step-advance DB failure (`scheduler/announcements.rs:210-222`)
168 + - [ ] NOTE: Idempotency middleware drops >1MB response bodies silently (`metrics.rs:205-226`)
169 + - [ ] NOTE: Unconfigured S3 reports `s3_ok = true` — intentional but masks misconfig (`monitor.rs:56-65`)
170 + - [ ] NOTE: `X-Forwarded-For` spoofable without Cloudflare — accepted risk (`rate_limit.rs:26-31`)
171 + - ~~MINOR: Advisory lock accumulates across ticks~~ REFUTED — reentrant by design, harmless
172 +
173 + ### Email, Templates, Config & Import
174 + - [x] MEDIUM: Dev-mode email logging exposes sensitive tokens — body redacted from log output (`email/mod.rs`)
175 + - [x] MEDIUM: CSV import has no row count limit — added 100K row cap (`import/csv_converter.rs`)
176 + - [x] LOW: `parse_amount_cents` wrong for negative decimals — fixed sign handling (`import/csv_converter.rs`)
177 + - [x] LOW: `format_revenue` displays negative as `"$-5.00"` — changed to `"-$5.00"` (`formatting.rs`)
178 + - [x] LOW: `slugify` produces unbounded-length output — capped at 128 chars (`formatting.rs`)
179 + - [x] LOW: Dev signing secret uses UUID v4 — changed to 256-bit CSPRNG (`config.rs`)
180 + - [x] NOTE: Email verification HMAC lacks `"verify:"` prefix — fixed in Auth section above (`email/tokens.rs`)
181 + - [x] LOW: Negative `price_cents` produces malformed `price_decimal` in JSON-LD — fixed sign handling (`types/mod.rs`)
182 + - [x] NOTE: `sanitize_field` tab/CR branches are dead code — removed (`import/csv_converter.rs`)
183 + - [x] MEDIUM: Unsubscribe `action` parameter not validated against enum — added `UnsubscribeAction` enum with 9 variants, updated 17 call sites, fixed missing `notify_tip` handler (`email/tokens.rs`, 13 files)
184 + - [ ] LOW: Issue reply signature truncated to 64 bits — accepted trade-off for email local-part length limits (`email/tokens.rs:284`)
185 + - [x] LOW: Import tier `price_cents` silently clamped from i64 to i32 — replaced with `try_into()` + descriptive error (`import/pipeline.rs`)
186 + - [x] LOW: Import `strip_html_tags` has no output length limit — capped at 512KB (`import/pipeline.rs`)
187 + - [x] LOW: Lossy i64-to-u32 casts in WaveStats/DbDiscoverItemRow — replaced with saturating casts (`types/conversions.rs`)
188 +
189 + ---
190 +
30 191 ## Backlog (no sprint assigned)
31 192
32 193 ### Promo Codes — Nice to Have
@@ -42,6 +203,32 @@
42 203 - [ ] Video URL fallback on S3 presign failure (migration: `video_url` column)
43 204 - [ ] Arrow key seek should use virtual timeline (segment-aware)
44 205
206 + ### S3/DB Crash-Gap Fix: `pending_s3_deletions` Durable Queue
207 +
208 + S3 deletes then DB deletes create a crash gap: if the server dies between the two steps,
209 + the DB references deleted S3 objects (or S3 objects are orphaned with no DB reference).
210 +
211 + **Affected paths (5 total):**
212 + 1. `scheduler/cleanup.rs` — `cleanup_user_s3_and_delete` (user CASCADE, main + synckit buckets)
213 + 2. `scheduler/cleanup.rs` — `purge_expired_deleted_items` (soft-delete purge, main bucket)
214 + 3. `routes/ota.rs:250-257` — `delete_release` (OTA artifacts, synckit bucket)
215 + 4. `routes/storage/media.rs:374-384` — media file delete (main bucket)
216 + 5. `routes/api/content_insertions.rs:284-290` — insertion delete (main bucket)
217 +
218 + **Implementation:**
219 +
220 + - [ ] Migration (`102_pending_s3_deletions.sql`): `pending_s3_deletions` table with `id UUID PK`, `s3_key TEXT NOT NULL`, `bucket TEXT NOT NULL DEFAULT 'main'`, `source TEXT NOT NULL`, `created_at TIMESTAMPTZ`, `attempts INT DEFAULT 0`, `last_attempted_at TIMESTAMPTZ`. Index on `created_at`. No FK to users (must survive CASCADE).
221 + - [ ] DB module (`db/pending_s3_deletions.rs`): `enqueue_deletions(pool, keys: &[(String, String)], source)` bulk INSERT via unnest; `remove_completed(pool, ids: &[Uuid])` DELETE by id; `get_stale_pending(pool, min_age, limit) -> Vec<PendingS3Deletion>` SELECT + UPDATE attempts atomically.
222 + - [ ] Update `cleanup_user_s3_and_delete`: collect all S3 prefixes (user, projects, synckit apps) -> enqueue -> S3 delete (best-effort) -> CASCADE delete user -> dequeue completed. If enqueue fails, bail before any S3 work.
223 + - [ ] Update `purge_expired_deleted_items`: query S3 keys -> enqueue -> S3 delete -> DB purge -> dequeue. If enqueue fails, skip entirely (items stay soft-deleted, retry next tick).
224 + - [ ] Update `delete_release` (ota.rs): enqueue artifact keys -> S3 delete -> DB delete -> dequeue.
225 + - [ ] Update media file delete: enqueue -> S3 delete -> storage decrement -> DB delete -> dequeue.
226 + - [ ] Update insertion delete: enqueue -> S3 delete -> DB delete -> dequeue.
227 + - [ ] Scheduler retry job (`retry_pending_s3_deletions`): fetch rows older than 10 min (batch 100), attempt S3 delete (prefix if trailing `/`, else single object), dequeue on success. Run in sandbox cadence (every 5 ticks). Log warning at attempts > 5.
228 + - [ ] Wire into `scheduler/mod.rs` in sandbox cadence block. Record job run.
229 +
230 + **Edge cases:** S3 deletes are idempotent (404 = success). Duplicate rows are harmless. Enqueue failure = bail before any destructive work. Crash between enqueue and S3 = retry job picks up. Crash after S3 but before DB = next scheduler tick re-processes (re-enqueue is harmless). Advisory lock prevents concurrent schedulers.
231 +
45 232 ### Code Quality
46 233 - [ ] Remove `async-trait` in favor of Rust 2024 native async traits
47 234 - [ ] Add README.md to server/
@@ -403,7 +403,7 @@
403 403 let unsub_url = crate::email::generate_unsubscribe_url(
404 404 &state.config.host_url,
405 405 user_id,
406 - "login",
406 + crate::email::UnsubscribeAction::Login,
407 407 &user_id.to_string(),
408 408 &state.config.signing_secret,
409 409 );