max / makenotwork
105 files changed,
+1994 insertions,
-576 deletions
| @@ -60,8 +60,8 @@ timeout_secs = 600 | |||
| 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 |
| @@ -35,7 +35,8 @@ pub fn check_backup( | |||
| 35 | 35 | }; | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | - | let prefix = format!("{database}_"); | |
| 38 | + | let prefix_underscore = format!("{database}_"); | |
| 39 | + | let prefix_hyphen = format!("{database}-"); | |
| 39 | 40 | let suffix = ".sql.gz"; | |
| 40 | 41 | ||
| 41 | 42 | let entries = match std::fs::read_dir(dir) { | |
| @@ -59,7 +60,9 @@ pub fn check_backup( | |||
| 59 | 60 | for entry in entries.flatten() { | |
| 60 | 61 | let name = entry.file_name(); | |
| 61 | 62 | let name_str = name.to_string_lossy(); | |
| 62 | - | if !name_str.starts_with(&prefix) || !name_str.ends_with(suffix) { | |
| 63 | + | if !(name_str.starts_with(&prefix_underscore) || name_str.starts_with(&prefix_hyphen)) | |
| 64 | + | || !name_str.ends_with(suffix) | |
| 65 | + | { | |
| 63 | 66 | continue; | |
| 64 | 67 | } | |
| 65 | 68 | ||
| @@ -157,6 +160,20 @@ mod tests { | |||
| 157 | 160 | } | |
| 158 | 161 | ||
| 159 | 162 | #[test] | |
| 163 | + | fn hyphen_separator_returns_ok() { | |
| 164 | + | let dir = std::env::temp_dir().join("pom_backup_test_hyphen"); | |
| 165 | + | let _ = std::fs::create_dir_all(&dir); | |
| 166 | + | let file = dir.join("mydb-20260508-030000.sql.gz"); | |
| 167 | + | std::fs::write(&file, b"fake backup").unwrap(); | |
| 168 | + | ||
| 169 | + | let result = check_backup("test", dir.to_str().unwrap(), "mydb", 25); | |
| 170 | + | assert_eq!(result.status, "ok"); | |
| 171 | + | assert!(result.size_bytes.unwrap() > 0); | |
| 172 | + | ||
| 173 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 160 | 177 | fn wrong_prefix_not_matched() { | |
| 161 | 178 | let dir = std::env::temp_dir().join("pom_backup_test_prefix"); | |
| 162 | 179 | let _ = std::fs::create_dir_all(&dir); |
| @@ -97,7 +97,86 @@ Backups are created daily at 03:00 UTC by cron (`/opt/makenotwork/backup-db.sh`) | |||
| 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 @@ Migrations are additive (new tables, new columns with defaults). There's no buil | |||
| 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/` |
| @@ -0,0 +1,75 @@ | |||
| 1 | + | #!/bin/bash | |
| 2 | + | # Setup WAL continuous archiving for PostgreSQL. | |
| 3 | + | # Run once on the production server, then restart PostgreSQL. | |
| 4 | + | # | |
| 5 | + | # This enables point-in-time recovery (PITR): restore to any moment, | |
| 6 | + | # not just the last daily pg_dump. Caps data loss at ~5 minutes. | |
| 7 | + | # | |
| 8 | + | # Usage: | |
| 9 | + | # scp deploy/setup-wal-archiving.sh root@100.120.174.96:/opt/makenotwork/ | |
| 10 | + | # ssh root@100.120.174.96 "bash /opt/makenotwork/setup-wal-archiving.sh" | |
| 11 | + | ||
| 12 | + | set -euo pipefail | |
| 13 | + | ||
| 14 | + | WAL_ARCHIVE_DIR="/opt/makenotwork/wal-archive" | |
| 15 | + | PG_CONF_DIR="/etc/postgresql/16/main" | |
| 16 | + | PG_CONF="${PG_CONF_DIR}/postgresql.conf" | |
| 17 | + | CONF_OVERRIDE="${PG_CONF_DIR}/conf.d/wal_archiving.conf" | |
| 18 | + | ||
| 19 | + | echo "[$(date -Iseconds)] Setting up WAL continuous archiving..." | |
| 20 | + | ||
| 21 | + | # Verify PostgreSQL config exists | |
| 22 | + | if [ ! -f "$PG_CONF" ]; then | |
| 23 | + | # Try version 15 (Debian 11) | |
| 24 | + | PG_CONF_DIR="/etc/postgresql/15/main" | |
| 25 | + | PG_CONF="${PG_CONF_DIR}/postgresql.conf" | |
| 26 | + | CONF_OVERRIDE="${PG_CONF_DIR}/conf.d/wal_archiving.conf" | |
| 27 | + | if [ ! -f "$PG_CONF" ]; then | |
| 28 | + | echo "ERROR: PostgreSQL config not found at /etc/postgresql/{15,16}/main/postgresql.conf" | |
| 29 | + | exit 1 | |
| 30 | + | fi | |
| 31 | + | fi | |
| 32 | + | ||
| 33 | + | # Ensure conf.d directory exists and is included | |
| 34 | + | mkdir -p "${PG_CONF_DIR}/conf.d" | |
| 35 | + | if ! grep -q "include_dir = 'conf.d'" "$PG_CONF" 2>/dev/null; then | |
| 36 | + | echo "include_dir = 'conf.d'" >> "$PG_CONF" | |
| 37 | + | echo "[$(date -Iseconds)] Added conf.d include directive to postgresql.conf" | |
| 38 | + | fi | |
| 39 | + | ||
| 40 | + | # Create WAL archive directory | |
| 41 | + | mkdir -p "$WAL_ARCHIVE_DIR" | |
| 42 | + | chown postgres:postgres "$WAL_ARCHIVE_DIR" | |
| 43 | + | chmod 700 "$WAL_ARCHIVE_DIR" | |
| 44 | + | echo "[$(date -Iseconds)] Created WAL archive directory: $WAL_ARCHIVE_DIR" | |
| 45 | + | ||
| 46 | + | # Write WAL archiving configuration | |
| 47 | + | cat > "$CONF_OVERRIDE" <<'PGCONF' | |
| 48 | + | # WAL continuous archiving for PITR | |
| 49 | + | # Caps data loss at ~5 minutes (archive_timeout). | |
| 50 | + | # Applied: setup-wal-archiving.sh | |
| 51 | + | ||
| 52 | + | wal_level = replica | |
| 53 | + | archive_mode = on | |
| 54 | + | archive_command = 'test ! -f /opt/makenotwork/wal-archive/%f && cp %p /opt/makenotwork/wal-archive/%f' | |
| 55 | + | archive_timeout = 300 | |
| 56 | + | PGCONF | |
| 57 | + | ||
| 58 | + | chown postgres:postgres "$CONF_OVERRIDE" | |
| 59 | + | echo "[$(date -Iseconds)] Wrote WAL config to $CONF_OVERRIDE" | |
| 60 | + | ||
| 61 | + | echo "" | |
| 62 | + | echo "WAL archiving configured. To activate:" | |
| 63 | + | echo "" | |
| 64 | + | echo " sudo systemctl restart postgresql" | |
| 65 | + | echo "" | |
| 66 | + | echo "Verify after restart:" | |
| 67 | + | echo " sudo -u postgres psql -c \"SHOW wal_level;\" -- expect: replica" | |
| 68 | + | echo " sudo -u postgres psql -c \"SHOW archive_mode;\" -- expect: on" | |
| 69 | + | echo " sudo -u postgres psql -c \"SHOW archive_timeout;\" -- expect: 300" | |
| 70 | + | echo "" | |
| 71 | + | echo "Then add cron jobs for WAL maintenance and offsite sync:" | |
| 72 | + | echo " # Prune WAL segments older than 7 days (hourly):" | |
| 73 | + | echo " 0 * * * * find /opt/makenotwork/wal-archive -name '0*' -mtime +7 -delete" | |
| 74 | + | echo " # Offsite sync (every 10 minutes):" | |
| 75 | + | echo " */10 * * * * /opt/makenotwork/sync-wal-offsite.sh >> /opt/makenotwork/wal-archive/sync.log 2>&1" |
| @@ -0,0 +1,62 @@ | |||
| 1 | + | #!/bin/bash | |
| 2 | + | # Sync WAL archive to offsite host (astra) via Tailscale. | |
| 3 | + | # Runs every 10 minutes via cron. | |
| 4 | + | # | |
| 5 | + | # Setup on astra: | |
| 6 | + | # mkdir -p /opt/backups/mnw/wal | |
| 7 | + | # | |
| 8 | + | # Setup on Hetzner (as makenotwork user): | |
| 9 | + | # Ensure SSH key-based auth to astra is configured (same as sync-backup-offsite.sh). | |
| 10 | + | # | |
| 11 | + | # Cron (as makenotwork user): | |
| 12 | + | # */10 * * * * /opt/makenotwork/sync-wal-offsite.sh >> /opt/makenotwork/wal-archive/sync.log 2>&1 | |
| 13 | + | ||
| 14 | + | set -euo pipefail | |
| 15 | + | ||
| 16 | + | OFFSITE_HOST="100.106.221.39" # astra (Tailscale IP) | |
| 17 | + | OFFSITE_USER="max" | |
| 18 | + | OFFSITE_DIR="/opt/backups/mnw/wal" | |
| 19 | + | WAL_DIR="/opt/makenotwork/wal-archive" | |
| 20 | + | OFFSITE_RETENTION_DAYS=7 | |
| 21 | + | WAM_URL="${WAM_URL:-http://127.0.0.1:7890}" | |
| 22 | + | ||
| 23 | + | wam_alert() { | |
| 24 | + | local title="$1" | |
| 25 | + | local body="${2:-}" | |
| 26 | + | curl -sf -X POST "$WAM_URL/tickets" \ | |
| 27 | + | -H "Content-Type: application/json" \ | |
| 28 | + | -d "{\"title\": \"$title\", \"body\": \"$body\", \"priority\": \"high\", \"source\": \"wal-offsite\"}" \ | |
| 29 | + | >/dev/null 2>&1 || true | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | if [ ! -d "$WAL_DIR" ]; then | |
| 33 | + | echo "[$(date -Iseconds)] WAL-OFFSITE: Archive directory $WAL_DIR does not exist" | |
| 34 | + | exit 0 | |
| 35 | + | fi | |
| 36 | + | ||
| 37 | + | # Count files to sync | |
| 38 | + | WAL_COUNT=$(find "$WAL_DIR" -maxdepth 1 -name '0*' -type f 2>/dev/null | wc -l) | |
| 39 | + | if [ "$WAL_COUNT" -eq 0 ]; then | |
| 40 | + | exit 0 | |
| 41 | + | fi | |
| 42 | + | ||
| 43 | + | echo "[$(date -Iseconds)] WAL-OFFSITE: Syncing $WAL_COUNT WAL segment(s) to ${OFFSITE_HOST}:${OFFSITE_DIR}" | |
| 44 | + | ||
| 45 | + | if rsync -e "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new" \ | |
| 46 | + | --timeout=120 \ | |
| 47 | + | "$WAL_DIR"/ \ | |
| 48 | + | "${OFFSITE_USER}@${OFFSITE_HOST}:${OFFSITE_DIR}/"; then | |
| 49 | + | echo "[$(date -Iseconds)] WAL-OFFSITE: Transfer complete" | |
| 50 | + | else | |
| 51 | + | echo "[$(date -Iseconds)] WAL-OFFSITE: Transfer FAILED" | |
| 52 | + | wam_alert "WAL offsite sync failed" "rsync to ${OFFSITE_HOST}:${OFFSITE_DIR} failed. Check Tailscale connectivity and SSH auth." | |
| 53 | + | exit 0 | |
| 54 | + | fi | |
| 55 | + | ||
| 56 | + | # Prune old offsite WAL segments | |
| 57 | + | DELETED=$(ssh -o ConnectTimeout=10 "${OFFSITE_USER}@${OFFSITE_HOST}" \ | |
| 58 | + | "find ${OFFSITE_DIR} -name '0*' -mtime +${OFFSITE_RETENTION_DAYS} -delete -print 2>/dev/null | wc -l" \ | |
| 59 | + | 2>/dev/null || echo "0") | |
| 60 | + | if [ "$DELETED" -gt 0 ]; then | |
| 61 | + | echo "[$(date -Iseconds)] WAL-OFFSITE: Pruned ${DELETED} segment(s) older than ${OFFSITE_RETENTION_DAYS} days" | |
| 62 | + | fi |
| @@ -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 @@ Run 20: 678 integration tests passing (2 timing-sensitive sandbox rate-limit tes | |||
| 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 @@ Run 20: 678 integration tests passing (2 timing-sensitive sandbox rate-limit tes | |||
| 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. | |
| 149 | + | ||
| 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. | |
| 123 | 152 | ||
| 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. | |
| 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 | |
| 126 | 160 | ||
| 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. | |
| 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) | |
| 133 | - | ||
| 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 | |
| 291 | + | ### Run 21 (2026-05-08) | |
| 292 | + | ||
| 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 @@ health.rs (844), webhook/checkout.rs (792), exports.rs (737), license_keys.rs (7 | |||
| 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) | | |
| 160 | - | ||
| 161 | - | 5 of 7 Run 19 items fixed. 2 carried forward (1 chronic). No regressions. | |
| 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) | | |
| 329 | + | ||
| 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 @@ health.rs (844), webhook/checkout.rs (792), exports.rs (737), license_keys.rs (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 @@ Features documented in public docs that don't exist in code yet: | |||
| 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 @@ Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |||
| 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 @@ Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |||
| 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 @@ Human tasks in `human_todo.md`. Completed items in `todo_done.md`. | |||
| 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/ |
| @@ -0,0 +1,9 @@ | |||
| 1 | + | CREATE TABLE IF NOT EXISTS pending_uploads ( | |
| 2 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 3 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 4 | + | s3_key TEXT NOT NULL, | |
| 5 | + | bucket TEXT NOT NULL DEFAULT 'main', | |
| 6 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 7 | + | ); | |
| 8 | + | ||
| 9 | + | CREATE INDEX idx_pending_uploads_created_at ON pending_uploads(created_at); |
| @@ -0,0 +1 @@ | |||
| 1 | + | CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_uploads_s3_key ON pending_uploads(s3_key); |
| @@ -0,0 +1,7 @@ | |||
| 1 | + | # Seeds for failure cases proptest has generated in the past. It is | |
| 2 | + | # automatically read and these particular cases re-run before any | |
| 3 | + | # novel cases are generated. | |
| 4 | + | # | |
| 5 | + | # It is recommended to check this file in to source control so that | |
| 6 | + | # everyone who runs the test benefits from these saved cases. | |
| 7 | + | cc a3e300b2a5a86a80979ec622b3905c69331d262be59a7d29376307d69d150cab # shrinks to cents = -1 |
| @@ -97,42 +97,34 @@ We don't have the details yet: the legal structure, the governance model, the tr | |||
| 97 | 97 | ||
| 98 | 98 | --- | |
| 99 | 99 | ||
| 100 | - | ## Why These Prices Won't Go Up | |
| 101 | - | ||
| 102 | - | The most common way a platform raises prices is that it was never sustainable at its original prices. Ride-sharing companies burned billions in investor money to keep fares artificially low, then raised them once competition was gone. Streaming services launched at a loss to build subscriber counts, then hiked prices once everyone was locked in. | |
| 103 | - | ||
| 104 | - | We are not doing that. | |
| 105 | - | ||
| 106 | - | **There's no hidden subsidy.** The platform is self-funded from personal savings, not investor money being burned down. Both the founder and the company are completely debt-free: no loans, no lines of credit, no financial obligations beyond operating costs. Current prices cover current costs with room to spare. | |
| 107 | - | ||
| 108 | - | **Margins widen with growth, they don't shrink.** Fixed costs barely move as creator count grows. Each new creator adds mostly margin. A platform with 500 members costs roughly the same to operate as one with 100, but earns five times as much. | |
| 100 | + | ## Price Stability | |
| 109 | 101 | ||
| 110 | - | **We don't have the costs that force other platforms to raise prices.** No sales team. No office. No investor returns. No algorithmic infrastructure. No paid user acquisition funnels. We spend on sponsoring events, hackathons, and community programs, but that's a rounding error compared to the growth marketing budgets that force other platforms to raise prices. | |
| 102 | + | Most platforms raise prices because they were never sustainable at their original prices. Ride-sharing companies burned billions in investor money to keep fares artificially low. Streaming services launched at a loss to build subscriber counts, then hiked prices once everyone was locked in. | |
| 111 | 103 | ||
| 112 | - | **Hiring is funded by the surplus, not by price increases.** The margin between what you pay and what your tier costs to serve is wide enough to fund salaries, reserves, and development at current prices. We need more creators at the same price, not more revenue per creator. | |
| 104 | + | We are not doing that. Current prices cover current costs with room to spare. Here's why they'll stay that way: | |
| 113 | 105 | ||
| 114 | - | **Hosting costs trend down, not up.** Storage, bandwidth, and compute have gotten cheaper every year for two decades. CDN pricing is a commodity. The long-term trend works in our favor. | |
| 106 | + | **No hidden subsidy.** Self-funded from personal savings, not investor money. Both the founder and the company are completely debt-free: no loans, no lines of credit, no financial obligations beyond operating costs. | |
| 115 | 107 | ||
| 116 | - | None of this is a guarantee against the unexpected. A payment processor doubling its fees or a regulatory change could force adjustments. But the ordinary pressures that cause platforms to raise prices (growth costs, investor returns, executive compensation, marketing budgets) don't apply here. | |
| 108 | + | **Margins widen with growth.** Fixed costs barely move as creator count grows. Each new creator adds mostly margin. A platform with 500 creators costs roughly the same to operate as one with 100, but earns five times as much. | |
| 117 | 109 | ||
| 118 | - | --- | |
| 110 | + | **We lack the costs that force price increases.** No sales team. No office. No investor returns. No algorithmic infrastructure. No paid user acquisition funnels. Hiring is funded by the existing surplus, not by charging more per creator. | |
| 119 | 111 | ||
| 120 | - | ## Price Stability | |
| 112 | + | **Hosting costs trend down.** Storage, bandwidth, and compute have gotten cheaper every year for two decades. The long-term trend works in our favor. | |
| 121 | 113 | ||
| 122 | - | We will not raise prices beyond inflation unless there are substantial changes in the hosting and delivery infrastructure market. What would justify a price increase: | |
| 114 | + | ### What would justify a price increase | |
| 123 | 115 | ||
| 124 | 116 | - Major cloud providers significantly increase storage or bandwidth pricing | |
| 125 | 117 | - CDN costs rise due to market consolidation or regulatory changes | |
| 126 | 118 | - Payment processors increase their fees in ways we can't absorb | |
| 127 | 119 | - New compliance requirements add unavoidable costs | |
| 128 | 120 | ||
| 129 | - | What will never justify a price increase: | |
| 121 | + | ### What will never justify a price increase | |
| 130 | 122 | ||
| 131 | 123 | - We want to grow faster | |
| 132 | 124 | - We want to hire more people | |
| 133 | 125 | - Competitors charge more | |
| 134 | 126 | ||
| 135 | - | If we ever raise prices, we'll give 90 days notice, explain exactly what changed, and grandfather existing members at their current rate for at least 12 months. | |
| 127 | + | If we ever raise prices, we'll give 90 days notice, explain exactly what changed, and grandfather existing creators at their current rate for at least 12 months. | |
| 136 | 128 | ||
| 137 | 129 | --- | |
| 138 | 130 |
| @@ -98,7 +98,7 @@ Makenot.work is currently a one-person operation. Here is what protects you if s | |||
| 98 | 98 | 2. **Your data is exportable now.** Don't wait for an emergency. Export regularly. Your content, metadata, transactions, and contact list are always available for download. | |
| 99 | 99 | 3. **The source code is public.** The complete codebase is available under the PolyForm Noncommercial license. Anyone can inspect, fork, or reference it. | |
| 100 | 100 | 4. **Infrastructure is simple.** One server, one database, one object storage bucket. A technically competent person could keep it running or wind it down. | |
| 101 | - | 5. **Daily backups exist offsite.** Database backups are replicated to a separate machine daily. | |
| 101 | + | 5. **Continuous backups exist offsite.** Daily database dumps are replicated to a separate machine. WAL (write-ahead log) continuous archiving caps potential data loss at ~5 minutes and enables point-in-time recovery. | |
| 102 | 102 | ||
| 103 | 103 | The honest answer: if the founder disappeared, the server would continue running (automatic restarts, health monitoring), but no one would respond to issues. The platform would eventually go offline when hosting bills went unpaid. Before that, creators would have time to export, and fan payments would still be in their Stripe accounts regardless. | |
| 104 | 104 | ||
| @@ -145,7 +145,7 @@ No browsing profiles. No behavioral tracking. No selling data. Verifiable in the | |||
| 145 | 145 | - Current uptime published live at [makenot.work/health](https://makenot.work/health), including 24-hour and 7-day percentages. | |
| 146 | 146 | - Monitored by two independent systems: an internal background monitor with email alerts, and an external monitor (PoM) on separate infrastructure. | |
| 147 | 147 | - Automatic restart within seconds on crash. | |
| 148 | - | - Daily database backups replicated to a separate machine. | |
| 148 | + | - Daily database backups replicated to a separate machine, with continuous WAL archiving for point-in-time recovery. | |
| 149 | 149 | ||
| 150 | 150 | As a single-server, single-operator platform, we cannot yet guarantee the sub-9-hours-per-year downtime that 99.9% requires; that needs redundancy or 24/7 on-call coverage. See Planned Guarantees below for our path to 99.9%. | |
| 151 | 151 |
| @@ -82,9 +82,7 @@ Prices reflect what it costs to store and deliver each content type. Use the [pr | |||
| 82 | 82 | | Software, plugins, sample packs | Small Files ($20) | 250GB storage, 500MB/file | | |
| 83 | 83 | | Games, large applications | Big Files ($30) | 500GB storage, 20GB/file | | |
| 84 | 84 | | Video content, courses | Big Files ($30) | 500GB storage, 20GB/file | | |
| 85 | - | | All of the above + live streaming | Everything ($60) | 500GB storage, 20GB/file | | |
| 86 | - | ||
| 87 | - | *Live streaming is on the roadmap but not yet available. The Everything tier always includes the full feature set, current and future.* | |
| 85 | + | | All current and future features + live streaming (roadmap) | Everything ($60) | 500GB storage, 20GB/file | | |
| 88 | 86 | ||
| 89 | 87 | ### What Every Tier Includes | |
| 90 | 88 | ||
| @@ -176,7 +174,7 @@ Prices won't go up unless infrastructure costs force it. If they ever do, we'll | |||
| 176 | 174 | ||
| 177 | 175 | ### Where Your Money Goes | |
| 178 | 176 | ||
| 179 | - | Surplus from membership fees funds, in order: a livable wage for the maintainer, hiring, reserves, and development. The first hires will come through a residency program training people without traditional credentials into full-stack engineers. The goal is graduation, not retention. See [Platform Economics](./economics.md) for the full breakdown. | |
| 177 | + | Surplus from creator tier fees funds, in order: a livable wage for the maintainer, hiring, reserves, and development. The first hires will come through a residency program training people without traditional credentials into full-stack engineers. The goal is graduation, not retention. See [Platform Economics](./economics.md) for the full breakdown. | |
| 180 | 178 | ||
| 181 | 179 | --- | |
| 182 | 180 |
| @@ -115,6 +115,6 @@ An item belongs to one project but can have many tags. See [Tagging System](./ta | |||
| 115 | 115 | ||
| 116 | 116 | Pin items or projects to your profile page. Rearrange pinned content by dragging. | |
| 117 | 117 | ||
| 118 | - | ### Archive | |
| 118 | + | ### Hide | |
| 119 | 119 | ||
| 120 | - | Hide old content without deleting. Archived items don't appear in search, but direct links still work. Unarchive anytime. | |
| 120 | + | Hide old content without deleting. Hidden items don't appear in search, but direct links still work. Unhide anytime. |
| @@ -22,7 +22,7 @@ Add a DNS TXT record to prove you own the domain: | |||
| 22 | 22 | ||
| 23 | 23 | The verification code is shown on the settings page after adding your domain. | |
| 24 | 24 | ||
| 25 | - | After adding the DNS record, return to Settings > Domain and click "Verify." The platform checks via DNS-over-HTTPS (Cloudflare resolver) so propagation is usually fast. | |
| 25 | + | After adding the DNS record, return to Settings > Domain and click "Verify." The platform checks via DNS-over-HTTPS (Cloudflare resolver) so propagation is fast — typically under two minutes. | |
| 26 | 26 | ||
| 27 | 27 | ### 3. SSL Certificate | |
| 28 | 28 |
| @@ -10,7 +10,7 @@ Fan+ members receive: | |||
| 10 | 10 | - **Fan+ badge**: Visible on your profile | |
| 11 | 11 | - **Platform support**: Direct contribution to keeping the platform running with 0% creator fees | |
| 12 | 12 | ||
| 13 | - | Fan+ will never gate access to any creator's content. It is separate from creator membership tiers; a fan can join both. | |
| 13 | + | Fan+ will never gate access to any creator's content. It is separate from per-project membership tiers (which fans join); a fan can have both Fan+ and project memberships. | |
| 14 | 14 | ||
| 15 | 15 | ## How It Works | |
| 16 | 16 |
| @@ -27,7 +27,7 @@ To sell your work, apply for creator access: | |||
| 27 | 27 | 4. Optionally, request a **free trial** (2-6 weeks) | |
| 28 | 28 | 5. Submit your application | |
| 29 | 29 | ||
| 30 | - | Most applications are approved within a few days. | |
| 30 | + | Most applications are approved within 1 business day. All applications receive a response within 5 business days. | |
| 31 | 31 | ||
| 32 | 32 | ### Free Trials | |
| 33 | 33 | ||
| @@ -36,7 +36,7 @@ Check "Request a free trial" when you apply. Pick a trial length (2, 4, or 6 wee | |||
| 36 | 36 | **Trial details:** | |
| 37 | 37 | - Trials are available by application only. Check the box when you apply for creator access. | |
| 38 | 38 | - One trial per person. If you need more time, email support before your trial ends. | |
| 39 | - | - When the trial expires, your content is hidden (not deleted). Join any tier to restore everything instantly, or export your data and leave. | |
| 39 | + | - When the trial expires, the same [cancellation grace period](./tiers.md#cancellation) applies: your content stays accessible for 30 days, then is hidden (not deleted). Join any tier to restore everything instantly, or export your data and leave. | |
| 40 | 40 | - During the trial you have full access to all features of your assigned tier, including publishing, selling, and receiving payments (Stripe connection required for payments). | |
| 41 | 41 | ||
| 42 | 42 | **Stripe availability:** Receiving payouts requires Stripe, which supports creators in [46+ countries](https://stripe.com/global). Check that list before applying if you're outside the US/EU/UK. |
| @@ -106,7 +106,7 @@ Fans pay monthly for ongoing access to your content. You decide what members get | |||
| 106 | 106 | - Early access to releases | |
| 107 | 107 | - Behind-the-scenes content | |
| 108 | 108 | ||
| 109 | - | Membership tiers are separate from [Fan+](./fan-plus.md), which is a platform-wide membership. | |
| 109 | + | Per-project membership tiers are separate from [Fan+](./fan-plus.md), which is a platform-wide membership. | |
| 110 | 110 | ||
| 111 | 111 | ### Creating a Membership Tier | |
| 112 | 112 |
| @@ -36,7 +36,7 @@ Include: | |||
| 36 | 36 | ||
| 37 | 37 | 1. **Acknowledgment** - We confirm receipt (usually within 24 hours) | |
| 38 | 38 | ||
| 39 | - | 2. **Review** - Your appeal is reviewed with fresh eyes. Once the team grows, this will be someone other than the original decision-maker | |
| 39 | + | 2. **Review** - Your appeal is reviewed with fresh eyes. Currently, appeals are reviewed by the founder. Once the team grows, appeals will be reviewed by someone other than the original decision-maker | |
| 40 | 40 | ||
| 41 | 41 | 3. **Decision** - We notify you of the outcome with explanation | |
| 42 | 42 |