Changelog

What's shipping

Every meaningful change we ship — new features, bug fixes, polish, infrastructure work. Newest at the top.

← HomeTaste engine →
  1. May 24, 2026
    Fix

    Stripe webhook secret no longer doubles as the internal-route auth

    Cluster #12 of the vibe-code cleanup campaign. The same STRIPE_WEBHOOK_SECRET was being used for three different jobs: (1) verifying Stripe signatures on /api/webhooks/stripe (legit), (2) gating the Vercel-Stripe-forwarder's /api/admin/internal/set-tier* calls (leak), and (3) as a fallback x-api-key on /api/taste/scan-all (second leak). Stripe Dashboard read access leaked the internal-route auth, and rotating the Stripe secret would have silently broken every n8n + Vercel-forwarder call. Decoupled into a dedicated FSCOUT_INTERNAL_SHARED_SECRET (matched on Vercel as INTERNAL_SHARED_SECRET). Stripe Dashboard access now leaks Stripe and nothing else.

    • Backend: new config field internal_shared_secret bound to FSCOUT_INTERNAL_SHARED_SECRET env var
    • Backend: _verify_internal helper (gating /api/admin/internal/set-tier*, /api/taste/scan-trigger, /api/notifications/send-digest, /api/collections/check-prices) now reads internal_shared_secret instead of stripe_webhook_secret
    • Backend: /api/taste/scan-all dropped its stripe_webhook_secret fallback for the x-api-key check — cron must use CRON_API_KEY only
    • Frontend: Vercel Stripe webhook forwarder reads INTERNAL_SHARED_SECRET when calling backend /api/admin/internal/* — added a symmetric 503 guard so missing-env returns a clear error instead of silently sending empty secret
    • Discovery during cluster: n8n's 'FashionScout Taste Scanner' workflow has 2,024 consecutive failed executions (wrong header name + hardcoded literal secret). Confirmed via n8n MCP. Filed as ISS-076. No user impact because the in-process 6h background scanner does the real work, but it's wasted n8n quota.
    • A41 (delete dead Vercel forwarder) + A38d (delete dead internal set-tier endpoints) deferred to a launch-time cluster — app is in Stripe sandbox until ready, and the Stripe Dashboard reconfig those need is better done at the live-flip boundary.
    Commitse593b1039d5ba9a503f78cdc116e3894253
  2. May 19, 2026
    New

    Background scheduler: every paid user's feed refreshes every 6h, even if they don't open the site

    Founder asked 'for all users their own taste does it work, also always updates and checked'. Audit answer: per-user taste IS isolated correctly (each user has independent ratings/vector/feed, verified across 5 real accounts). But 'always updates' was the gap — auto-scan only fired when the user opened /taste, so inactive users like jefabettmob@gmail.com (Pro, 9 ratings) and tesfamyohannes@gmail.com (Pro, 3 ratings) had feeds that were 14 days stale. /api/taste/scan-all endpoint existed for cron triggers but no external cron was wired. Now: an in-process background thread spawns at boot, runs every 6h, finds every paid user with ≥1 like whose feed is >24h stale, and refreshes via scan_for_user. Self-contained — survives Railway recycling, no external cron needed.

    • Spawned at FastAPI boot via _start_background_scanner (alongside the existing model-prewarm thread)
    • Cycle: every 6 hours, fetches up to 50 stale-feed paid users in one DB read, refreshes each via recalculate_taste_vector + scan_for_user + store_feed_items
    • Stale = feed's newest item created >24h ago
    • Min 1 like to qualify — brand-new users still hit the cold-start path on first /taste visit, not background-spammed
    • First run delayed 5 min after boot to stagger against fresh-deploy resource pressure
    • Per-user errors caught + logged; one bad scrape doesn't tank the cycle
    New

    Taste scanner now hits 6 marketplaces, not just Grailed + Mercari

    Founder asked 'why we have only grailed as marketplace it has to work around all marketplaces'. Auditing: services/shopping.py had scrapers for Grailed, Mercari Japan, Depop, Poshmark, TheRealReal, eBay (text via SerpAPI), Google Shopping, plus Google Lens + eBay visual. The taste path (scan_for_user) was only invoking Grailed + Mercari Japan. Four marketplaces sat fully built but unused. Wired all six text-search marketplaces into the parallel scan. 6× the source diversity per query.

    • scan_for_user now invokes Grailed + Mercari Japan + Depop + Poshmark + TheRealReal + eBay in parallel for every query
    • Workers: 12 (was 6) — 6 markets × top queries needs more parallelism. Scan budget extended 25s → 35s to give all markets a fair shot.
    • Google Lens + eBay-visual stay out of this path because they take Image not query string — those run from /scout instead.
    • eBay path gated by FSCOUT_SERPAPI_KEY presence — silently skipped if not configured.
    Fix

    Recency decay on feed ranking — old items sink, fresh items surface

    After fixing the scanner dedup deadlock + manually backfilling 180 fresh items into the founder's feed, the top-K STILL served the same March Levi's. Reason: legacy items had similarity_score=0.98 (an artifact from an old scoring era when scores ran hot) while fresh items get ~0.7. The ORDER BY blended_score query naturally favored the artificially-high old scores. Added exp(-days/14) recency decay to BOTH the personalized and fallback ranking branches. A 14-day-old item is now at 0.37× its score, 30-day at 0.12×, 55-day at 0.019×. Fresh items keep ~1.0×.

    • Personalized branch: blended_score multiplied by exp(-days/14)
    • Fallback branch: similarity_score multiplied by the same decay (renamed to decay_score for clarity)
    • Verified against founder's feed: top-8 flipped from 5 same March Levi's denim → 8 fresh Yohji Yamamoto items from today's scan (Ground Y, AAR Bomber, Y'saccs, Y's At Work)
    • 14-day half-life chosen for active resale-shopper cadence; tunable via the divisor
    Fix

    ACTUAL FIX: scanner now rotates queries — was running same 8 brands → ON CONFLICT ate everything

    Founder said 'nothing got fixed yet'. Drilled in. Their feed was 153 items from March 25, 55 days stale. Earlier today's auto-scan fix would have helped IF a scan-me had actually produced new items. Ran scan_for_user against admin@on9.fashion locally to debug: returned 0 candidates. extract_taste_profile produced sensible queries (Armani, Chrome Hearts, Alexander McQueen, etc.). Grailed/Mercari returned 200+ results. But store_feed_items inserted ZERO rows — every URL was already in taste_feed under (user_id, marketplace_url) UNIQUE. The scanner has been hitting Grailed with the same 8 brand keywords for 55 days, returning the same listings, all dedup'd at the database. The user's feed couldn't grow even with daily scans. Rewrote build_search_queries to produce 16 ROTATED queries per scan: every brand bare, every brand × randomly-chosen style, every style as standalone, random style pairs. The query set shuffles. Different Grailed/Mercari pages hit each scan. Also manually ran a brute-force varied scan against the founder's account RIGHT NOW: 180 new items inserted, feed went 153 → 324. Their next /taste visit will see real variety.

    • build_search_queries now produces 16 queries (was 10), all rotated randomly per scan
    • Every liked brand gets a bare query AND a brand×style query (was: cap 8 brands, only top 3 got styles)
    • Style-only queries ALWAYS fire (was: only when <3 brands — useless for users with broad taste)
    • Random style pairs for cross-aesthetic discovery
    • Result shuffled so consecutive scans hit different marketplace cache states
    • Manually backfilled founder's feed: 153 → 324 items with 180 fresh from today's varied scan
    New

    Taste engine now learns from saves, clicks, and search queries (not just thumbs-up)

    Founder asked 'doesn't taste build on what items it likes during search and other interactions like Pinterest?' Investigation: only explicit /api/taste/rate likes fed the engine. Saves, clicks on chat-result cards, and search queries all flowed past the taste signal pipe. Three small wires fix that.

    • Save → taste signal: /api/collection/save now fires _record_taste_signal_async(signal='save', weight=0.70) right after the row insert. Every save is now a strong soft positive — the engine learns from your collection.
    • Click → taste signal: product-card's two <a> tags (image + body) wire onClick + onAuxClick to recordTasteClick. Every click on a result tile in /chat is a weak positive (weight=0.20). The recordTasteClick helper has been exported in src/lib/api.ts since v1 but no component called it — now it does.
    • Search → taste signal: /chat/stream now fires _record_taste_signal_async(signal='search', weight=0.10) on every text query (≥3 chars, paid tier). Query text gets embedded by FashionSigLIP and merged into the taste_vector on the next recompute. Soft nudge — searching for 'rick owens' nudges taste toward that aesthetic without overwhelming explicit likes.
    • New signal type 'search' added to SIGNAL_WEIGHTS dict (was: click/save/hover/dismiss). Synthetic query://<hash> URL key so identical searches upsert into one row instead of creating duplicates.
    • All three writes go through a single _record_taste_signal_async helper that does INSERT + bg text embedding in a daemon thread — caller never waits.
    Fix

    P0 taste-engine bugs: taste_summary wrong-column + taste_vector not refreshed on rate

    Deep investigation after founder said 'ai taste engine is not working and is always static, need full investigation and be humble'. Found two real bugs in the learning chain (separate from the staleness/mark-seen fixes from 72501ba): (1) taste_profiles PK is `id` (TEXT, set to user_id) — NO `user_id` column exists. taste_summary.py was running `WHERE user_id = %s` against it on every call. Every query threw UndefinedColumn, was caught by bare except, returned None/False. Result: needs_recompute() always False → recompute_summary() never ran → taste_summary stayed empty string for every user → taste_rerank.add_taste_match_score() no-op'd because get_summary returned empty → LLM-summary-driven ranking signal has been DEAD since the column rename. (2) recalculate_taste_vector was only invoked from /api/taste/scan-me — never from /api/taste/rate. Users who rated dozens of items but didn't manually click Scan had a stuck taste_vector that didn't reflect their new likes. Both fixed. Also wrote an honest architecture analysis (see commit body): the discovery side of the engine is keyword-based against a hardcoded 60-brand list; the embedding cosine only re-ranks within that keyword-filtered pool. The 'AI' label is generous.

    • taste_summary.py: needs_recompute() + get_summary() + recompute_summary INSERT all changed from WHERE user_id = %s to WHERE id = %s (matches the actual schema). LLM summary signal is now wired through for real.
    • /api/taste/rate background embedding thread now also calls recalculate_taste_vector after UPDATE-ing item_embedding. Taste vector now refreshes on every rating (~1s after the like), not just on manual scan.
    • Honest architecture note in code: scanner is keyword-based on hardcoded brands/styles, not embedding-driven. Discovery breadth is bounded by the 60-brand KNOWN_BRANDS list, not by the taste_vector.
    Fix

    P0 taste-engine overhaul: stale-feed auto-scan + mark-seen on every load + /clusters merged into /taste

    Founder reported: 'why the fuck taste engine still recommends the same items for a week after I asked to fix this every day and still the same thing also why matrix implemented as different page ... ranking on taste engine still doesn't work'. Pulled their actual data from prod, found three structural bugs. Founder's admin@on9.fashion account had 153 feed items but the most-recent insert was 2026-03-25 — 55 days stale. All 153 items were stuck at status='unseen' because mark-seen only ran on manual refresh click. The /taste/clusters page existed as a separate '10×10 matrix' surface when the founder just wants /taste itself to show ~100 clothes. Fixed all three.

    • FIX A — /taste auto-scan triggers when feed is >24h stale, not just when warming_up empty. The original gate only fired for first-visit users with no data; founder's data made status='personalized' so the scan never ran and the same top-K kept surfacing. New gate: status='warming_up' AND no items, OR newest item's created_at > 24h ago, OR feed has <30 unique items. 5-min throttle (was 60s) so a refresh storm doesn't hammer scrapers.
    • FIX B — mark-seen now runs on EVERY fetchFeed (not just manual refresh). Each served item gets status flipped 'unseen' → 'seen' immediately. Next fetch picks up fresh unseen items first. Was the actual root cause behind 'same items every visit'.
    • FIX C — /taste/clusters page replaced with a redirect to /taste. The '10×10 matrix' surface is gone — founder explicitly didn't want it. /taste page (which already has 100+ item grid via infinite scroll) now serves the role. Filter-bar 'Clusters' discoverability link removed too.
    • FIX D — personalized scans now pull 20/query (was 10), cold-start unchanged at 25. Doubles the chance each scan introduces actually-fresh items vs dedup-ing against the existing pool.
    • Verifier updates: 'Clusters link visible' check inverted to 'Clusters link removed'; /taste/clusters probe now asserts redirect lands on /taste; /taste CDN-image health check moved here too.
    Polish

    Copy fix: /taste/clusters warming_up subtitle

    Captured a visual of /taste/clusters on prod and noticed the warming_up subtitle 'Like a few more pieces and refresh — the matrix sharpens with each signal' implied the user had pieces to like OUTSIDE this view. But cold-start users have nothing to like elsewhere (the chicken-and-egg trap the auto-scan was built to escape). New copy nudges action ON the tiles in front of them.

    • Old: 'Like a few more pieces and refresh — the matrix sharpens with each signal.'
    • New: 'Tap pieces you like below — the matrix sharpens with each signal.'
    • Visual confirmed earlier today's auto-scan polish is delivering: real archive items rendering (Helmut Lang-style outerwear, Margiela-style coats, Rick Owens sneakers, CDG patterns), 45 images loaded across 5+ cluster rows.
    Infra

    Verifier: bump login wait_for_url timeout 35s → 60s (flake fix)

    The verifier's login step had two recurring failure modes today, both transient: NextAuth credentials rate-limit pressure (mitigated by the existing 60s-retry-between-attempts) AND the wait_for_url after submit hitting its 35s ceiling when Vercel is cold + Railway is warming the model thread. The chain (cosmos 2-step → NextAuth authorize() → backend /api/auth/login → JWT mint → redirect to /chat) can take 30-45s end-to-end on a cold environment. Bumped the wait_for_url ceiling to 60s — generous enough to absorb the worst observed latency, tight enough to still surface a genuinely-hung login as a failure.

    • _attempt_login_inner: wait_for_url timeout 35000 → 60000.
    • Cluster CDN images held at 43/43 in the post-fix run (the 28 → 43 → ? trend continues to densify with each cold-start scan).
    • Verifier still at 94/94 PASS — patch is verifier-only, no backend change.
    Polish

    Polish: cold-start scans pull 25/query (was 10) — denser cluster matrix

    Yesterday's auto-scan polish (485beed) densified /taste/clusters from 14 → 28 tiles. Still half-empty vs the 100-tile spec. Root cause: each editorial seed query was pulling only 10 results from Grailed + 10 from Mercari = 20/seed × 8 seeds = 160 raw → ~30-50 after dedup. Bumping cold-start specifically to 25/query yields 400 raw → ~80-150 dedup, enough to fill most of the matrix on first visit. Personalized users keep at 10/query — their queries are narrower so each result is more relevant.

    • Cold-start path (`not rated_titles`): per_query_limit = 25 (was 10).
    • Personalized path: unchanged at 10/query.
    • Expected effect on next /taste/clusters cold-start: matrix density jumps from ~28 tiles toward ~80-100.
    • Cost: ~2.5x marketplace API hits per cold-start scan. Worth it since cold-start scans are 1-2 per user (auto-fires on /taste OR /taste/clusters first visit only).
    Polish

    Polish: /taste/clusters auto-scans on cold-start (was 15 tiles, promised 100)

    Found while reviewing verifier output. The cluster page header promises a '10 × 10 matrix' but cold-start Pro users were getting ~15 sparse tiles — the Myntra filter from earlier this week cut 80% of the foundation pool, and the warming_up status's subtitle 'Like a few more pieces and refresh' is a chicken-and-egg trap (nothing to rate from). Mirror the /taste page pattern: when the cluster endpoint returns status=warming_up, auto-trigger /taste/scan-me in the background + refetch once it lands. The sparse 15 stays visible during the 5-15s scan so the user sees movement; the dense matrix swaps in when the scan returns.

    • useCallback-wrapped fetchClusters helper.
    • After first fetch: if status === 'warming_up', POST /taste/scan-me + refetch.
    • Sparse matrix stays visible during the scan window — no spinner blocking.
    • Same editorial-seed scan that already works on /taste populates the foundation pool, so the second fetch returns real archive items (Margiela, Raf Simons, etc).
    Infra

    Verifier: /api/auth/signup backend validation locked in (94/94)

    /api/auth/signup had no backend regression coverage — only the frontend 3-step form was probed. Added 4 validation probes that exercise the rejection paths (missing email, missing password, bad email format, short password) using deliberately-bogus inputs so no real user gets created. Skipped: the 10/hr/IP rate limit (would burn the budget) + the XSS-in-name guard from d644e60 (would require an otherwise-valid body which creates a real user as a side effect).

    • POST /auth/signup with missing email → 422 (Pydantic body validation)
    • POST /auth/signup with missing password → 422
    • POST /auth/signup with bad email format → 400 ('Invalid email format')
    • POST /auth/signup with 1-char password → 400 ('at least 8 characters')
    Infra

    Verifier: lock /api/admin/internal/* secret guard (90/90) — payment-critical

    The /api/admin/internal/set-tier + /set-tier-by-customer endpoints are called by the Next.js Stripe webhook handler to update user tiers — a missing/wrong guard would let anyone promote themselves to admin. They use _verify_internal (hmac.compare_digest, locked in via ddc514a) and uniform 'Forbidden' responses. Added 4 regression probes confirming both endpoints return 403 with no secret and 403 with bad secret.

    • POST /admin/internal/set-tier with no x-internal-secret → 403
    • POST /admin/internal/set-tier with bogus secret → 403
    • POST /admin/internal/set-tier-by-customer with no secret → 403
    • POST /admin/internal/set-tier-by-customer with bogus secret → 403
    • These complement the existing internal-webhook probes (send-digest/check-prices/scan-trigger) — together they cover every _verify_internal-gated route.
    Infra

    Verifier: lock in /api/auth/forgot-password no-enumeration (86/86)

    The endpoint deliberately returns 200 + 'if an account exists, a reset link was sent' regardless of whether the email is registered — common security practice to prevent attackers from enumerating valid email addresses. Locked in with a regression probe so a future commit that 'helpfully' returns 404 for unknown emails gets caught.

    • POST /api/auth/forgot-password with no-such-user-xyz789@example.test → 200
    • Response body MUST contain 'if an account exists' (the generic message).
    • 5/hr/IP rate-limit NOT exercised here — would burn the budget for the verifier IP for the rest of the hour.
    Fix

    Rate-limit + scoping probe on /api/on9/market-check (SerpAPI cost protection)

    Each /api/on9/market-check call fires shopping_service.search_marketplaces — SerpAPI + eBay + scraper hits at ~$0.01-0.05 per call. Endpoint was properly user-scoped (404 on missing/foreign item) but had no rate limit. A paid user could loop on this and rack up real costs against us. Added 30/hr/user rate limit (generous for honest workflow, expensive to abuse) + a scoping probe.

    • 30/hr/user rate limit via _rate_limit_or_429 (key=f'on9-market-check:{user_id}').
    • Audit confirmed /api/on9/stats + /api/on9/inventory/{id}/listing are also user-scoped (no IDOR).
    • New probe: POST market-check on missing item → 404 (locks the scoping in).
    Infra

    Verifier: lock in /api/saved-searches user-scoping (83/83)

    Audited the 4 saved-searches endpoints. All properly user-scoped via composite (id, user_id) predicates — DELETE returns 404 instead of 403 to avoid leaking existence of other users' searches. No IDOR holes. Added 3 regression probes locking the behavior in.

    • DELETE /api/saved-searches/<missing> → 404
    • POST /api/saved-searches/<missing>/refresh → 404
    • GET /api/saved-searches → 200 (endpoint reachable)
    • Audit confirmed: all 4 endpoints (GET list, POST create, DELETE, POST refresh) scope by user_id at the SQL layer.
    Fix

    Sweep: migrate 3 webhook endpoints + cron to constant-time secret compare

    Audited every x-internal-secret + x-api-key check site. Found 3 webhook endpoints (send-digest, check-prices, scan-trigger) and 1 cron endpoint (taste/scan-all) using inline `secret == header` — same timing-oracle pattern I already fixed in _verify_internal (ddc514a). Now all 3 webhook endpoints delegate to _verify_internal (hmac.compare_digest + uniform 403 Forbidden). The cron endpoint with its two-key fallback (cron_key OR webhook_secret) uses hmac.compare_digest directly. Plus 4 new regression probes locking in the 403 responses.

    • /api/notifications/send-digest → _verify_internal (was inline `secret != header`).
    • /api/collections/check-prices → _verify_internal (same).
    • /api/taste/scan-trigger → _verify_internal (same).
    • /api/taste/scan-all (cron, dual-key) → hmac.compare_digest for both cron_key and webhook_secret fallback paths.
    • step_internal_webhook_guards added — 4 probes: send-digest (no secret + bad secret), check-prices (no secret), scan-trigger (bad secret). All MUST return 403.
    • Audit confirmed /api/notifications GET/PATCH/read-all are all properly user-scoped (no IDOR).
    Fix

    Harden /api/auth/verify-email — 1M-code brute force was possible

    Audit found real exploitability. /api/auth/verify-email had NO rate limit AND would accept unlimited wrong codes against the same in-flight entry until its 15-minute expiry — letting an attacker who knows a target email try the full 1M 6-digit space in well under the window. /api/auth/send-verification had no rate limit either, so a session-cookie-thief could email-bomb the user's inbox.

    • verify-email: 10/hr/IP rate limit (generous for real users mistyping, tight against brute-force).
    • verify-email: 5-attempt-per-code lockout. After 5 wrong tries the code is burned and the user must request a new one — this is the real defense, because IP rate-limits can be defeated by cloud IP rotation but every code requires a fresh /send-verification call which is itself rate-limited per user.
    • verify-email: constant-time hash compare via hmac.compare_digest (defense-in-depth, sha256 is fixed-length so timing leak is minimal but matches our ddc514a pattern).
    • send-verification: 3/hr/user_id rate limit. A real user who deleted the first email can retry twice; an attacker can't grind the inbox.
    • Backward-compat: _verification_codes entries are now 4-tuples (hash, expires_at, attempts, max_attempts) — verify-email accepts both old 2-tuples (in-flight at deploy time) and new 4-tuples.
    • Regression probe: POST /verify-email with no-code-issued email → 400 (surface contract).
    Fix

    Harden /api/on9/inventory/{id}/list + lock existing price/sold guards

    /api/on9/inventory/{id}/list (mark items as listed on marketplaces) accepted any string in the platforms array — no XSS strip, no length cap, no count cap, no dedupe. Sibling to the inventory POST + PUT XSS strips from yesterday. Hardened + added 7 regression probes covering price/list/sold input validation (some of these guards already existed from 2026-05-17 — never had probes).

    • platforms[] now validated: at-least-1, max 20 entries, max 80 chars per platform name, XSS strip (javascript:/data:/vbscript:/file: prefixes, <script/<iframe substrings), case-insensitive dedupe, whitespace trim.
    • Returns 400 for: type-mismatch, empty list, oversized list, XSS payload. Same error vocabulary as the inventory POST/PUT.
    • 7 new regression probes in step_on9_action_guards covering: price (negative list, min > list), list (empty, XSS, too many), sold (negative price, empty platform).
    • Existing 2026-05-17 guards on /price + /sold (negative-price rejections, empty-platform reject) had no test coverage — now locked in.
    Fix

    Harden /api/auth/reset-password — 8-char min + rate limit + probe

    Found two real gaps while auditing the password-reset flow. (1) NO minimum password length check on reset — signup requires 8+ chars but reset accepted anything, including a 1-char password. A user phished into a 'set to 1' reset would have a trivially-guessable account. (2) No rate limit on the reset endpoint — an attacker could brute-force the 1-hour token space. Both fixed; regression probe added.

    • Minimum 8-char password enforced on reset-password (parity with signup).
    • Rate limit: 10/hr/IP on /api/auth/reset-password — tight enough to make token brute-force expensive, loose enough that a real user's retry doesn't get blocked.
    • New step_reset_password_api_guards probe: short password → 400, empty password → 400, bogus token (with valid password) → 400. All green on prod.
    • Verifier total: pending re-run (was 65/65, expecting 68/68).
    • Note: in-memory _reset_tokens dict still process-local — fine for single-replica Railway today, but a horizontal-scale day would need Redis-backed token store.
    Infra

    Verifier: /api/curator/* 403-on-non-admin coverage (65/65)

    Audited the 6 /api/curator/* endpoints (approve, reject, profile, discover, score, reset). All cleanly gated via Depends(_require_admin) — same canonical mechanism used by other admin routes. Locked in via the existing step_admin_endpoints_reject_non_admin probe extended to sample 3 curator paths.

    • Extended admin-probe to also check: GET /curator/profile, GET /curator/discover, POST /curator/reset. All MUST be 403 for the Pro test account.
    • Verifier total: 65/65 PASS (was 62/62).
    • Audit confirmed all 6 curator handlers use Depends(_require_admin) — clean baseline.
    Infra

    Verifier: schema-level regression probes for /api/billing/checkout

    Payment-critical endpoint had no regression coverage. Three schema-level probes added — exercises the validation surface without ever calling stripe.Customer.create or stripe.checkout.Session.create (would burn Stripe credits + create real-side artifacts on every verifier run). Total verifier coverage: 62/62 PASS.

    • Empty body → 422 (CheckoutRequest tier field is required).
    • tier='admin' → 400 ('admin' not in price_id_map, no upgrade path — confirms the lookup-table doesn't accept arbitrary tiers).
    • interval='eternal' → must not raise a Python exception (intentionally tolerant: interval falls back to 'monthly' for unknown values, which is the existing behavior; we just don't want a 5xx).
    • The happy-path 200-with-Stripe-URL is NOT probed — explicitly skipped to avoid creating Stripe-side artifacts on every cycle.
    Infra

    Verifier: lock in /api/admin/* 403-on-non-admin behavior

    Audited every /api/admin/* endpoint for IDOR/auth gaps. All 13 endpoints properly gated by _check_admin_inline (tier=='admin' OR email in ADMIN_EMAILS). Added a regression probe that POSTs/GETs to 5 sampled admin paths (stats, users, metrics/overview, foundation/status, refresh-user-tier) as the YC Pro test account and asserts every response is HTTP 403. Catches any future commit that accidentally removes the admin gate.

    • step_admin_endpoints_reject_non_admin: 5 admin endpoints probed, all MUST return 403 for a Pro-tier (non-admin) caller.
    • Verifier total: 59/59 PASS (was 54/54). The 13 internal endpoints (those gated by _verify_internal with the shared secret) are NOT covered here; their gate is a different mechanism and the test account has no internal secret.
    • Audit confirmed no IDOR holes — all admin handlers call _check_admin_inline (or the equivalent inline check) before any other work.
    Fix

    Stripe webhook: fix silent-drop on DB failures + timing-safe internal compare

    Audit of /api/webhooks/stripe found two issues. (1) customer.subscription.updated and customer.subscription.deleted caught DB exceptions silently and returned 200 to Stripe — Stripe saw success, didn't retry, user got stuck at the wrong tier on any transient DB blip. Now re-raises so Stripe's exponential-backoff retry kicks in (immediate, 5m, 1h, ... up to 3 days). (2) The internal-endpoint shared-secret check used `provided != secret`, which is a timing oracle. Switched to hmac.compare_digest. Attack surface was small (Vercel-edge-to-Railway is the only realistic caller) but the fix is one import + one line.

    • subscription.updated: removed silent-catch. DB error → 500 → Stripe retries. 'No user found' (race with checkout-session-completed) still returns 200 + warn-logs, so Stripe doesn't retry forever for legitimately-not-our-customer events.
    • subscription.deleted: same fix.
    • _verify_internal: hmac.compare_digest + uniform 'Forbidden' response so missing-config and bad-secret can't be distinguished via body or timing.
    • checkout.session.completed handler unchanged — it doesn't have the silent-catch pattern.
    • Webhook signature verification + ISS-016 tier-resolution-from-prices already in place; not touched.
    Infra

    Housekeeping: removed dead TasteEngine.get_feed + add_to_feed methods

    Completing the deprecation that started with fd92f81 (taste/monitor → scan-me). Both v1 methods wrote to / read from the legacy taste_feeds (plural) table that no user-facing query reads from. Confirmed via grep that nothing calls them anywhere — including services/, scripts/, and frontend. ~100 LOC of zombie code that ran every cold-start init_db only to sit unused. Now gone. The taste_feeds CREATE TABLE statements are intentionally left in init_db (destructive DB change deferred to a DBA pass).

    • Removed TasteEngine.get_feed (read from taste_feeds plural)
    • Removed TasteEngine.add_to_feed (write to taste_feeds plural)
    • Comment block at the removal site explains the lineage for anyone confused why taste_feeds still exists.
    • Net -97 lines.
    Fix

    Sweep: stop leaking raw exception text across 6 more endpoints

    Continuation of the b3293d5 Stripe-leak fix. Grepped api.py for every `detail=f'...{exc}'` and `detail=str(exc)` pattern outside of intentional user-facing messages. Found 6 sites surfacing raw psycopg / SQLAlchemy / embedder exception text — column names, constraint detail, INSERT statement text. Each becomes a generic message + a server-side log with user_id context for triage.

    • /api/auth/reset-password — 'Failed to update password. Please try again.' (was leaking SQL constraint detail).
    • /api/auth/update — 'Failed to update profile. Please try again.'
    • /api/admin/internal/set-tier-by-customer — 'set-tier-by-customer internal error' (matters for Stripe webhook retry logs which would otherwise echo the exception).
    • /api/taste/rate — 'Failed to save rating. Please try again.' (was leaking INSERT INTO taste_ratings text).
    • /api/price-alerts/create — 'Failed to create price alert. Please try again.'
    • /api/admin/foundation/search — 'Embed failed' (admin-only but still cleaner not to surface embedder internals).
    • Two intentional `str(exc)` paths kept: 409 duplicate-save messages (users SHOULD see 'Item with url X already saved') and the SSRF DNS 400 (caller-input-error class).
    Fix

    Deprecate /api/taste/monitor — was writing to the same dead taste_feeds (plural) table

    Same family as the save_from_feed fix from earlier today. /api/taste/monitor scraped marketplaces and called taste_engine.add_to_feed which writes to taste_feeds (plural) — the legacy table that NO user-facing query reads from. So every call burned SerpAPI/scraper credits + database writes for zero user impact. Frontend exported a wrapper (runTasteMonitor) but no UI page calls it. Re-wired the endpoint to delegate to /api/taste/scan-me (the v2 endpoint that writes to taste_feed singular and has the editorial cold-start scan).

    • /api/taste/monitor now delegates to taste_scan_me — same auth/tier/quota semantics, items land in the right table, cold-start gets editorial seeds.
    • External API consumers (if any) keep working; they now get real items in their feed instead of orphan writes.
    • Note: the legacy TasteEngine.add_to_feed + get_feed methods + the taste_feeds table itself still exist in store.py for backwards-compat; they should be removed in a follow-up housekeeping pass.
    Fix

    Billing: stop leaking raw Stripe exception text to the user

    Both /api/billing/checkout and /api/billing/portal had an `except Exception as exc: raise HTTPException(500, f'Stripe error: {exc}')` pattern that surfaced the raw Stripe SDK exception to the browser. Those messages include internal IDs (customer cus_XXX, price price_XXX, request req_XXX) which are useless to the user and enable enumeration. Now log full detail server-side with the user_id + tier context, return a generic 'Could not start checkout. Please try again in a moment.' (502) to the client.

    • /api/billing/checkout: 500 → 502 with generic message. Server log includes user_id/tier/interval + the original exc for triage.
    • /api/billing/portal: same treatment.
    • Existing 503 'Stripe is not configured' + 400 'Invalid tier' + 503 'price misconfigured' paths unchanged — those are deterministic + safe to keep.
    Fix

    Sweep: 10MB upload cap applied to ALL file endpoints (was missing on 6 of them)

    Audited every `await file.read()` site in api.py after fixing detect-garments earlier. Of the 10 sites, only /api/scout had the 10MB cap; 6 others (/api/shop, /api/search/hybrid, /api/on9/inventory, 3 curator endpoints) had NO cap at all, /api/chat regular had the cap but a bare-except was silently swallowing the 413, and /api/chat/stream was missing the cap. Now uniformly capped via a new _enforce_image_size(contents) helper and the MAX_IMAGE_BYTES constant.

    • New MAX_IMAGE_BYTES = 10MB constant + _enforce_image_size() helper next to _safe_decode_image. Raises 413 with 'Image too large (max 10MB)'.
    • Applied to: /api/shop, /api/search/hybrid, /api/on9/inventory (when file provided), /api/curator/approve, /api/curator/reject sibling, /api/curator/score, /api/chat/stream.
    • Fixed /api/chat regular: inline 413 raise was wrapped in a bare-except that downgraded it to a 200-with-no-image warning. Now properly re-raises HTTPException.
    • /api/scout + /api/chat/detect-garments keep their existing inline checks (refactor to helper is a follow-up).
    • Net effect: a paid user (or compromised session) can no longer POST a 100MB image to any endpoint and OOM a worker. The smallest gap was probably /api/search/hybrid which also runs FashionSigLIP embedding on the giant raster — double cost.
    Fix

    Cap /api/chat/detect-garments at 10MB to match /api/scout

    Found while sweeping file-upload endpoints for missing limits. /api/chat/detect-garments accepted arbitrary-size uploads — a 100MB image would balloon PIL memory, burn SegFormer + Moondream + VLM tokens scanning a giant raster, and eventually OOM the worker. /api/scout already caps at 10MB; this aligns the sibling endpoint.

    • 10MB cap on detect-garments uploads (returns 413 with 'Image too large (max 10MB)').
    • Endpoint already had auth + Plus/Pro paywall + 60/hr/user rate limit; this just bounds the worst-case payload per request.
    • Refactored the except clause so the new HTTPException doesn't get swallowed by the existing catch-all 'Invalid image' handler.
    Infra

    Verifier: schema-level /api/scout contract probes (empty + garbage)

    Adding regression coverage for the founder's core product (Scout — photo→detect→search). Running the full image-upload pipeline on every verifier cycle would burn SerpAPI credits, so this is a schema-level guard: prove the endpoint rejects bad inputs cleanly. The actual photo→marketplace flow is exercised manually + by real users; this catches accidentally widening the surface (e.g. losing the file-required guard or the PIL decode safety).

    • step_scout_contract added — POSTs an empty multipart (asserts 422 from File(...) requirement) + 64 bytes of zeros (asserts 422 from _safe_decode_image PIL guard).
    • Verifier total: 54/54 PASS (was 52/52). YC test account is Pro tier so the 402 free-tier check is not exercised here; left as future coverage when a free test account is wired.
    • Future: when we wire a free test account, add a 402-on-free probe; when we add a stable test image fixture, add the full pipeline probe with marketplace assertions.
    Fix

    Follow-up: save_from_feed wrote nonexistent boolean columns — really fixed now

    Yesterday's a116912 fixed the TABLE name (taste_feeds → taste_feed) but it was still writing kwargs `saved=True, seen=True` to a table whose schema doesn't have those columns. taste_feed has a TEXT `status` column with values 'unseen'/'seen'/'dismissed' — there is no boolean `saved`. So my UPDATE failed because `column 'saved' does not exist`, the exception was caught + logged, function returned False, API returned 404. Verifier caught it. Now writing status='seen' (correct schema) and the saved-items mirror handles the 'saved' concept.

    • _set_feed_flag now driven by status='seen' / status='dismissed' kwargs — matches the actual TEXT column on taste_feed.
    • save_from_feed: flag the row as 'seen' + insert into saved_items (the canonical 'saved' marker, since /collection reads from saved_items).
    • mark_seen + dismiss methods updated to use status='seen' / status='dismissed' instead of boolean kwargs.
    • Will verify post-deploy that step_taste_save_lands_in_collection turns from FAIL → PASS.
    Fix

    CRITICAL FIX: /collection was permanently empty for everyone — bad table name in feed flag write

    The actual root cause behind the founder's 'mytaste has to work on collection' complaint. The TasteEngine._set_feed_flag() helper (which save_from_feed, dismiss, mark_seen all delegate to) was running its UPDATE against `taste_feeds` (plural — an empty legacy table created in init_db but never populated). Every other query in the codebase uses `taste_feed` (singular). So every save / dismiss / mark_seen call has been silently affecting 0 rows for who knows how long — API returned 404, user saw 'failed to save' toast, /collection always empty. Fixed the table name + ALSO made save_from_feed mirror the feed row into saved_items so it actually shows up in /chat?view=collection.

    • _set_feed_flag now writes to `taste_feed` (singular) — fixes save / dismiss / mark_seen for every user.
    • save_from_feed now ALSO inserts a saved_items row (via save_collection_item) — the feed flag alone wasn't enough since /collection reads from saved_items.
    • Duplicate-URL saves gracefully no-op via the existing UniqueViolation handler in save_collection_item.
    • New verifier probe step_taste_save_lands_in_collection: saves first feed item, asserts POST returns 200 (was 404 before fix), then verifies it appears in /collection/saved by url match, then cleans up the test row.
    • This bug was masking everything else. The XSS hardening, Myntra filter, cold-start scan, and routing fixes from the last 2 days were all real improvements, but none of them mattered until the user could actually SAVE something. Now they can.
    Polish

    Polish: visible 'Scanning archive marketplaces…' state during cold-start auto-scan

    After yesterday's editorial cold-start scan (2526bb1), new paid users hit /taste, the auto-scan fires invisibly, and they stare at the 'Nothing waiting yet — Scan to surface pieces' empty state for 10-15 seconds until items pop in. No feedback that something is happening. Wired the scanning UI flag into the auto-scan too — the button label flips to 'Scanning…' and the empty-state heading + body copy updates to 'Scanning archive marketplaces… / Pulling editorial pieces from Grailed + Mercari. Takes ~10 seconds.' Real wait, real message.

    • Auto-scan effect now calls setScanning(true) at start, setScanning(false) in a finally block.
    • Empty-state copy in /taste page switches based on `scanning` flag — clear feedback during the network round-trip.
    • Same 60s lastScanAtRef throttle remains so re-renders don't fire duplicate scans.
    Polish

    Polish: Collection icon updates URL in place instead of bouncing through /collection

    Audited /collection after yesterday's icon-routing fix. Turns out /collection is a deprecated route that immediately redirects to /chat?view=collection (LegacyCollectionRedirect, see src/app/(app)/collection/page.tsx). So my fix made clicking the Collection icon do: navigate to /collection → URL changes → React unmounts /chat → /collection mounts → useEffect router.replace → /chat re-mounts from scratch. Wasteful. Now: stay in /chat, flip the view via setView + fetchCollection, AND router.replace the URL so the address bar reflects what the user clicked. That was the founder's original complaint anyway — that the URL didn't change.

    • Collection icon → chat.setView('collection') + fetchCollection() + router.replace('/chat?view=collection', { scroll: false }). No full re-mount, address bar reflects state.
    • Search-engine icon → setView('chat') + router.replace('/chat'). Same in-shell pattern.
    • Taste + Settings continue to navigate() because they're real dedicated pages, not deprecated redirects.
    • Updated step_chat_sidebar_routing assertion: accept either '/collection' (legacy) OR 'view=collection' (canonical) so both code paths verify.
  3. May 18, 2026
    Infra

    Regression probes for today's two biggest fixes

    Locking in the Myntra-filter and /chat-sidebar-routing fixes with permanent regression coverage in verify_prod.py. Without these, a future commit that accidentally removed the SQL filter or reverted the navigate() callbacks would slip into prod silently. Cycle would only catch it on the next founder report. With these, every Playwright run flags the regression immediately.

    • step_taste_feed_no_myntra: GET /taste/feed, assert every item.image_url is free of 'myntassets' and 'myntra' (case-insensitive). Today this passes with 15 real Grailed/Mercari items; before the filter chain it would have been 24/24 Myntra.
    • step_chat_sidebar_routing: from /chat, open the mobile sidebar, tap the Collection button, assert URL changes to /collection. The pre-a30cc26 behavior (URL stays at /chat, internal view tab flips) now fails this probe.
    • Routing probe runs LAST in the auth flow because it navigates away from /chat to /collection — any subsequent /chat-dependent step would break otherwise.
    • Full verifier sweep: 50/50 PASS (was 48/48 before adding the two new probes; no existing checks regressed from today's data + cold-start work).
    Fix

    VERIFIED: /taste shows real Grailed + Mercari archive items on cold-start

    Founder demanded verification, not guesses. Ran the verification probe against prod immediately after the editorial cold-start scan landed (2526bb1). Result on the YC Pro account (zero ratings, zero saved items): within 5 seconds of /taste page load the feed returned 15 REAL marketplace listings — Grailed + Mercari Japan, zero Myntra. Sample titles confirm the editorial seed worked: 'Archive 2004 Vintage Sanded Broke Denim', 'Maison Margiela V-Neck Sweater Blue Luxury Vintage', 'Raf Simons Archive Redux SS21 Window Denim 33', 'VINTAGE SUNAOKUWAHARA ISSEY MIYAKE SOFT CARGO MULT'. Verified on desktop (1280x900) AND mobile (390x844). Screenshots saved to /tmp/taste_after_fix.png + /tmp/taste_mobile_after_fix.png.

    • Probe captured: status (not warming_up), count=15, myntra=0, marketplaces=[Grailed, Mercari Japan].
    • Mobile probe captured: 30 image elements rendered, 0 myntra hits, all images from media-assets.grailed.com.
    • Visual confirmation: screenshots show real archive denim, Margiela sweaters, Issey Miyake cargos rendering in the taste grid.
    • Bonus: separately verified the mobile /chat sidebar fully opens at 280x844 on a 390px viewport with sidebar-open class, no overflow — the earlier 'sidebar half not visible' complaint was the embedded landing-page mockup which 26261ef already hid below md.
    Fix

    P0 fix: /taste actually works for cold-start users now (real archive items, not empty)

    Founder: 'mytaste has to work on collection and on user's taste!!! that is the whole point!!!' Triaged via Playwright on the YC Pro account (0 ratings, 0 saved items). Feed status was 'foundation' returning 24 Myntra rows. Filter shipped (a30cc26 + bc805f1) flipped it to 'warming_up' empty — honest, but empty isn't a product. This ship makes the cold-start scan actually populate the feed with REAL marketplace items.

    • taste_scanner.scan_for_user: when user has no rated_titles AND no saved items, instead of returning empty, scan with 8 editorial seed queries (Rick Owens, Maison Margiela, Raf Simons, Helmut Lang, Yohji Yamamoto, Comme des Garçons, Issey Miyake, Junya Watanabe). Hits Grailed + Mercari → 24+ real listings populate taste_feed.
    • Frontend /taste useEffect: when fetchFeed returns status='warming_up' for a paid user with empty feed, auto-fire /taste/scan-me + re-fetch. Throttled by the same lastScanAtRef 60s guard so re-renders don't spam.
    • Now: paid cold-start user hits /taste → ~5-15s later → 24 real Grailed/Mercari archive listings. They rate a few, taste vector forms, next scan becomes personalized.
    • Verified on prod with Playwright: YC Pro account before fix returned 24 Myntra rows (status=foundation); after filter returned warming_up empty (status=warming_up, count=0, myntra=0). Cold-start scan is the next ship.
    Fix

    P0 fix: /taste no longer shows Myntra sandals/sarees as 'your taste'

    Founder hit this hard: 'taste engine is filled with fake data items still!!!' The /taste feed was serving rows from a placeholder dataset (ceyda/fashion-products-small, hosted on assets.myntassets.com) that was ingested as a stub before the real Vogue Runway data was loaded into production. Result: an editorial-archive product was showing women's saree, men's loungewear, generic flip-flops. Filter at the SQL layer so these rows never surface, regardless of who's looking.

    • TASTE_FEED_DENY_SQL constant injected into 7 user-facing query sites: /taste/feed (personalized + fallback), /taste/feed/clusters (personalized + fallback), /taste/locked, locked-similar fallback, search-by-title. Rows with image_url ILIKE %myntassets% or %myntra% are excluded.
    • Empty feed after filter falls through to the existing 'warming_up' status which the frontend already handles with a 'rate 10 items to activate your taste engine' onboarding gate. Honest empty state beats fake catalog.
    • Reversible: drop the constant + the 7 injections to roll back instantly. No data deleted.
    • Companion fix: /chat sidebar icons (Collection, Settings) used to flip an internal view tab instead of routing to the /collection and /settings pages. URL stayed at /chat which read as 'icons don't lead to the right parts of the website'. Each icon now navigates properly. (Taste already worked this way.)
    Fix

    P0 mobile fix: landing page broken on phone — buttons not visible/clickable

    Founder reported the landing page was completely broken on mobile. Triaged: the mad-hero embeds a full desktop product preview (real ChatSidebar + ChatThread + ChatComposer inside a ProductWindow). On a 390px viewport the preview takes up the entire screen below the Try Scout / See pricing CTAs, rendering a half-drawn fake-app shell that reads as 'the landing is broken.' Compounded by a hamburger button whose Tailwind `size-8` utility was computing to 8x8 inside the pill-nav cascade (failed WCAG 2.5.5 touch target). Both fixed in one ship.

    • Hide the embedded ProductWindow mockup below md (768px) via .mad-hero-product-wrap rule in globals.css. Desktop visual unchanged.
    • Hamburger trigger button: explicit inline width:44px / height:44px / minWidth:44 / minHeight:44 + 22px Menu icon. 44px meets WCAG 2.5.5 minimum.
    • Root-caused why size-8 was rendering as 8x8: still TBD (likely inline-flex shrink-to-content interaction with the pill-nav padding cascade). Inline-style override is the pragmatic fix while we debug the cascade.
    Fix

    Signup display-name XSS + length-cap guard

    Sweeping the family some more. /api/auth/signup wrote `body.name` straight to the users table with no length cap and no XSS check — an attacker could register with name='<script>alert(1)</script>' which then renders in their /settings page + nav dropdown. React escapes today (so this is defense-in-depth, not a live exploit), but signup is the first place a name enters the system and the right gate to hold the line at.

    • Reject signup names with javascript:/data:/vbscript:/file: scheme prefixes (400).
    • Reject signup names containing <script or <iframe substrings (400).
    • Enforce 100-char cap on signup name (same as /auth/update).
    • Defaults to email-local-part when name is empty — unchanged behavior.
    • No new verifier probe: signup is rate-limited 10/hr/IP and the verifier would burn that budget across cycles. The identical XSS-check logic is already exercised every cycle via the /auth/update probe (3 checks).
    Fix

    XSS + length-cap guard on /api/saved-searches writes

    Continues the sweep-the-family pattern: today we have closed on9/inventory POST + PUT, /auth/update display name, and now /saved-searches. The saved-search create endpoint already had a 500-char query cap + empty-check but accepted <script>/javascript: substrings on query, image_path, and garment_id. The /saved page re-renders these fields — React escapes today, but the same future-consumer argument applies — block at write so no downstream tool needs to trust them.

    • Reject query/image_path/garment_id starting with javascript:/data:/vbscript:/file: schemes.
    • Reject query/image_path/garment_id containing <script or <iframe substrings.
    • image_path capped at 1024 chars (CDN URL realistic max); garment_id capped at 128 (UUID/slug shape).
    • Existing 500-char query cap + empty-check unchanged.
    • 3 new regression probes in verify_prod.py for query/image_path/garment_id.
    Fix

    Defense-in-depth XSS strip on display name (/api/auth/update)

    Continues the sweep-the-family pattern from today's earlier on9/inventory POST + PUT fixes. The display-name update endpoint already rejected empty + over-100-char names but did NOT block <script>/javascript: substrings. React escapes when rendering the name in /settings + the nav dropdown so there is no live XSS, but writing untrusted strings to the users table that get re-read by every future consumer is the kind of hole worth closing pre-emptively.

    • Reject display names starting with javascript:/data:/vbscript:/file: scheme prefixes.
    • Reject display names containing <script or <iframe substrings.
    • Existing empty + length checks unchanged.
    • 3 new regression probes in verify_prod.py to make this stay closed.
    Fix

    Mass-assignment + XSS guard on PUT /api/on9/inventory/{id}

    Found while sweeping for sibling holes after the POST fix. The dormant PUT endpoint took a raw `updates: dict` and spread `**updates` straight into the SQL UPDATE — any logged-in user could touch ANY column on their own rows: user_id (transfer item to another user), cost_price / sold_price (analytics tampering), id (primary-key rename), plus the same XSS surface. No frontend page calls it today, but it ships in the typed client (api.gen.ts) so we close it before anyone wires it up.

    • Allowlist of 16 writable fields (title, brand, category, color_primary, size, condition, description, notes, source_platform, source_url, status, list_price, min_price, style_tags, images, listed_platforms). Anything else is silently dropped.
    • Write-time XSS strip (javascript:/data:/vbscript:/file: + <script>/<iframe>) on every string field, with per-field length caps.
    • source_url must start with http:// or https:// (same shape as 9c2feea).
    • list_price / min_price coerced to float + rejected if negative (matches the /price endpoint).
    • If every supplied key is forbidden or unknown, body becomes empty → 400 instead of silently no-op.
    • 5 new regression probes in verify_prod.py covering each rejection path.
    Fix

    Defense-in-depth XSS strip on /api/on9/inventory

    Inventory writes used to store title/brand/category/source_platform/size/condition verbatim. Same family as the /taste/quiz (bf7cdbf) + /taste/hide-brand (d5dfa44) + /collection/save (9c2feea) fixes from the last 48h — close the gap before any consumer (PDF export, listing copy generator, future email digest) interpolates these into HTML and gets popped.

    • Strip + length-cap on all string fields written to on9 inventory (title 200, brand 100, category 80, source_platform 80, size 40, condition 80).
    • 400 on javascript:/data:/vbscript:/file: scheme prefixes or <script/<iframe substrings anywhere in those fields.
    • source_url must start with http:// or https:// (mirrors /collection/save check).
    • Also fixed a downstream bug: item_details was writing raw `title` even though the upstream guard had stripped it into `title_clean`. Now uses `title_clean` everywhere.
    • React already escapes when rendering, so no live XSS — this is hardening for future consumers.
    Fix

    Correction: Google OAuth works — yesterday's diagnosis was wrong

    Previous wake reported Google OAuth was broken. Re-verified with a longer-wait Playwright probe + cross-check via JS location.href: clicking 'Continue with Google' DOES successfully redirect to accounts.google.com with correct client_id + redirect_uri. The earlier 'Server error' came from probing /api/auth/signin/google directly (bypasses CSRF protection on purpose). Apologies for the false alarm.

    • Verified: button click → accounts.google.com/v3/signin/identifier with client_id 739193825810-...apps.googleusercontent.com and redirect_uri https://mytaste.fit/api/auth/callback/google.
    • Root cause of false diagnosis: Playwright's page.url getter occasionally lags the actual frame URL. JS location.href + page.title() (Sign in - Google Accounts) confirmed the redirect.
    • Vercel env vars are fine — no founder action needed.
    • trustHost: true commit (1ed9192) still valid — defensive, no harm.
    Fix

    Google OAuth is misconfigured on Vercel (needs founder action)

    Diagnosis pass found the 'Continue with Google' button is silently broken. Direct navigation to /api/auth/signin/google returns NextAuth's 'Server error — problem with the server configuration' page. This is a missing/wrong AUTH_GOOGLE_ID or AUTH_GOOGLE_SECRET env var on Vercel, OR the Google OAuth app's redirect URI isn't set to https://mytaste.fit/api/auth/callback/google. Requires founder action — can't be fixed from code.

    • trustHost: true added to NextAuth config defensively (was the suspected cause but not the actual bug).
    • Verifier guard added: step_auth_providers_endpoint asserts /api/auth/providers returns 200 in-browser. Stays green; the provider IS registered, just unusable.
    • Action items for founder: 1) Verify AUTH_GOOGLE_ID + AUTH_GOOGLE_SECRET are set on Vercel for the production environment. 2) Confirm Google Cloud Console OAuth app has https://mytaste.fit/api/auth/callback/google in authorized redirect URIs. 3) After fixing, re-test by clicking 'Continue with Google' on /auth/login.
    • Email + password login flow is unaffected and works end-to-end.
    Fix

    Login was silently broken — trustHost missing on NextAuth

    Caught a real launch-blocker. /api/auth/providers was returning HTTP 400 intermittently — when a request hit the wrong Vercel edge region, NextAuth v5's host check failed because trustHost wasn't set. Browser signIn() treats non-2xx as fatal, so users hitting the broken region saw the login spinner hang forever with no error. Both credentials AND Google OAuth were affected.

    • Added trustHost: true to src/auth.ts (NextAuth v5 only defaults to true when VERCEL=1 OR not production — custom-domain prod hit the gap).
    • Added explicit secret: AUTH_SECRET || NEXTAUTH_SECRET passthrough as defensive (NextAuth v5 sometimes needs it spelled out).
    • Verifier added step_auth_providers_endpoint — fails the wake if /api/auth/providers ever returns non-200 again.
    • Reproduction was specifically Playwright clicking 'Continue with Google' → URL stays at /auth/login, no accounts.google.com redirect, no error toast. Pure silent failure.
    New

    Teammate's new login + signup design — audited clean

    Ilyas's cosmos-style progressive-disclosure flow (PR #8) audited across 4 viewports (360 / 390 / 768 / 1280). Both auth surfaces work end-to-end with zero overflow and zero console errors.

    • Login: 2 steps (email → password) with Back button on step 2, Google OAuth on step 1.
    • Signup: 3 steps (email → password → name) — verified step transitions render correctly + Back button works.
    • All viewports clean: scrollWidth = innerWidth on mobile (360 + 390), tablet (768), desktop (1280).
    • Verifier extended with step_signup_flow_renders (29/29 → 32/32 checks). Walks first 2 transitions + asserts step 3 'Create account' button without actually submitting (would pollute the users table).
    Fix

    Caught transient login outage during off-hour audit

    While auditing post-merge state, /api/auth/providers returned HTTP 400 transiently — silently hung NextAuth's signIn() so users on /auth/login saw the spinner and never advanced past the password step. Issue cleared within minutes; root cause still unconfirmed (probably Vercel-edge cold start with NextAuth). Also updated the verifier to handle the new cosmos-style two-step login flow.

    • Diagnosed via Playwright probe — captured the 400 on /api/auth/providers + the resulting silent stall on the password-step submit.
    • Re-tested 5 minutes later: providers returned 200, full login round-trip succeeded.
    • Verifier updated to drive the new two-step UI (email → Next → password → Sign in), uses .type() for password field so React state updates correctly through the canAdvance gate.
    • 29/29 green again. If the providers 400 recurs, the wake will catch it.
    Fix

    Landing: clip absolute-positioned overflow on mobile

    Follow-up to the pill-nav fix. The hero chat-mock, manifesto pull-quote H2, and a few jsx-scoped ghost panels still spilled past the 390px viewport (positioned absolute at left:195 / right:-234). Clipping at body level kills the horizontal scrollbar without rewriting every component.

    • Added overflow-x: clip on body — vertical scroll unaffected.
    • Catches the long tail of decorative elements designed for desktop that don't reflow on phones.
    • Proper component-level fixes can land later without regressing the mobile experience in the meantime.
    Fix

    Landing nav-pill overflowed mobile by ~110px

    The newly-wired mad-* landing's top nav had a hard min-width: 460px in globals.css. Any phone narrower than ~492px (i.e. every iPhone) got a horizontal scrollbar on the homepage. Caught by Playwright overflow scan.

    • Wrapped min-width in min(460px, calc(100vw - (var(--page-px) * 2))) so the pill stays inside the viewport on narrow screens.
    • Added max-width with the same expression as a hard cap.
    • Desktop unchanged — the floor still pins to 460px on anything > 524px wide.
  4. May 17, 2026
    Infra

    Removed dead /api/taste/like + /dislike endpoints

    Followed up on the dead-endpoint discovery from earlier today. Decided to remove rather than fix — the new TasteEngine is a single-tenant singleton, so routing these endpoints to its approve/reject methods would cross-pollute every user's taste vector. Active per-user rating API is POST /api/taste/rate which works correctly.

    • Removed 132-line dead /api/taste/like and /api/taste/dislike handlers from api.py.
    • Removed unused tasteLike() / tasteDislike() wrappers from src/lib/api.ts (zero call sites in the entire src tree).
    • Less attack surface (the SSRF fix from c369511 was the only thing protecting them), less dead code, smaller OpenAPI surface for clients.
    • If the per-user TasteEngine ever ships, the new endpoints will have new names and a clean profile-id model — no need to inherit this broken shape.
    Infra

    /api/taste/like + /dislike confirmed dead endpoints

    Post-SSRF audit traced both endpoints — they call taste_engine.like() and .dislike() which don't exist on the current TasteEngine (it has approve/reject from a refactor). Result: 500 on every valid call. Frontend doesn't use either (the active rating flow is /api/taste/rate); they're pure dead weight. Marking transparently here while we decide: fix the routing or remove the endpoints.

    • Both endpoints accept the request, validate the URL via the new SSRF helper, decode the image — then crash on AttributeError when calling taste_engine.like(...) because the method doesn't exist.
    • Frontend wrappers (tasteLike() / tasteDislike() in src/lib/api.ts) are defined but uncalled — 0 call sites in the entire src tree.
    • Active flow is POST /api/taste/rate which works correctly (foundation-URL collision fix shipped earlier today, fc8265d).
    • SSRF fix from c369511 still valuable — closes the abuse surface even though the endpoints are otherwise broken.
    Fix

    SECURITY: closed SSRF in /taste/like, /taste/dislike + 3 sibling endpoints

    Adversarial audit caught a real Server-Side Request Forgery vector. /taste/like (and 4 siblings) accepted a `url` body field and did `httpx.get(url, follow_redirects=True)` — a Pro user could probe AWS metadata (169.254.169.254), hit internal services, scan the cloud VPC, or use a redirector to bypass naive scheme checks.

    • New _safe_fetch_image helper: rejects non-http(s) schemes, resolves the hostname + blocks private/loopback/link-local/cloud-metadata IPs, re-validates on EVERY redirect hop (was follow_redirects=True), caps response at 10MB.
    • Patched 4 user-facing fetch sites in api.py (/taste/like, /taste/dislike, scout-helper, curator/approve, curator/reject) plus the background embed_image_url in taste_v2.py.
    • AWS/GCP/Azure metadata endpoint (169.254.169.254) is now explicitly blocked even via redirect chain.
    • Hardest find of the day — this would have been an immediate compromise vector if exploited on a cloud VM with an IAM role attached.
    Fix

    Defense-in-depth: /taste/hide-brand strips XSS-y brand names

    Brand names from the hide-from-brand menu went straight into taste_profiles.hidden_brands JSON. React escapes them when rendering but write-time stripping prevents leaks through any future tool (admin dashboards, exports, LLM-summary context).

    • Reject brand strings starting with javascript:, data:, vbscript:, file:.
    • Reject brand strings containing <script or <iframe.
    • Length cap of 80 chars + lowercase normalisation kept as-is.
    • Cleaned 1 javascript:alert(1) entry from YC reviewer's hidden_brands during the audit.
    Fix

    Defense-in-depth: /taste/quiz now strips XSS-y inputs

    Same family of bug as the /collection/save XSS fix. The quiz endpoint wrote raw user input into taste_ratings.item_title — frontend escapes by default but stripping bad inputs at write time prevents leaks through any tool (admin dashboards, exports, future LLM context) that might interpolate them unsafely later.

    • Reject brand/style/category strings starting with javascript:, data:, vbscript:, file:.
    • Reject strings containing <script or <iframe.
    • Cap each string at 100 chars (also prevents row bloat).
    • Added Playwright regression test for /collection/save XSS — fails the build if the URL guard regresses.
    Fix

    Security: blocked stored-XSS vector in /collection/save

    /api/collection/save accepted javascript: URLs verbatim. Any user could 'save' a malicious URL, and if the frontend ever renders saved items as <a href={url}>, clicking it would execute arbitrary JS in their session. Closed.

    • URL + image_url must now be http:// or https:// — anything else (javascript:, data:, file:, etc.) → 400.
    • Empty title + empty url → 400 (prevents pollution of the collection sidebar with blank rows).
    • Cleaned 3 test rows from YC reviewer's collection during the audit.
    • Cross-tenant + admin-gate checks all verified clean in the same pass — DELETE/GET someone else's conversation → 404, /api/admin/* without admin tier → 403.
    Fix

    Display-name editing was completely broken

    Every user who tried to save their display name from /settings hit a 404 + 'Failed to update name' toast. The frontend has been POSTing to /api/auth/update since the v2 swap but the endpoint never existed (ISS-068 from the May 2 audit).

    • Built /api/auth/update endpoint with proper validation (empty → 400, > 100 chars → 400, no-op → 400).
    • Partial-update safe: only SETs the columns the caller actually sent, never NULLs untouched fields.
    • Returns the updated user object so the frontend can refresh local state without a second round trip.
    • Closes the last open issue from the May 2 audit's P1 list.
    Fix

    Saved-searches accepted empty + 5000-char queries

    Two latent bugs in /api/saved-searches: an empty query was saved silently (sidebar pollution) and a 5000-char query would have been sent verbatim to scrapers on refresh.

    • Empty query → 400 unless an image_path was provided (image-only saves still work).
    • Queries > 500 chars → 400 (matches the chat composer cap shipped earlier today).
    • Caught 2 stray test entries in YC reviewer's saved-searches during the audit and cleaned them.
    Fix

    On9 mutation endpoints accepted nonsense inputs

    Adversarial audit caught three real bugs in the resale inventory mutation endpoints. Negative prices propagated into /on9/stats and would have shown negative inventory values on the dashboard.

    • set_pricing now 400s on negative list_price or min_price (was returning 200 and storing -50).
    • set_pricing now 400s if min_price > list_price (prevents 'minimum acceptable' > 'asking' nonsense).
    • mark_sold now 400s on empty platform string (was creating sale rows with platform='' which fragmented analytics).
    • mark_sold now 400s on negative price (was returning profit=-150 on a $100-cost item 'sold for -$50').
    New

    Landing redesign — wired the dormant mad-* components

    The redesigned landing page (manifesto, live, engines, about, index, footer) was sitting in the May 5 merge but src/app/page.tsx still imported the older v2 components. Pulled the wiring through.

    • Replaced the v2 hero-center + feature-section + proof-band stack with the 8 mad-* components (mad-hero, mad-manifesto, mad-live, mad-engines, mad-about, mad-featured, mad-index, mad-footer).
    • Zero backend changes — pure frontend wiring. tsc --noEmit clean.
    • Fixed 3 type errors where the redesign passed undefined for chat-thread props that became required after the v2 merge (searchPhase, activeMarketplaces, streamResultCount → 'idle', [], 0).
    • Playwright check added: asserts the new section markers (manifesto/engines/live/index) appear in the rendered DOM.
    Fix

    On9 inventory accepted empty POSTs

    An empty POST to /api/on9/inventory used to silently create a blank inventory row — no title, no photo, no source. Anyone could flood the /on9 dashboard with placeholder garbage.

    • Endpoint now 400s if the caller sends neither a title NOR a photo.
    • Photo-first flow (just upload an image, server analyses + auto-fills title) still works unchanged.
    • Caught one test artifact in YC reviewer's inventory during the audit and cleaned it.
    Fix

    Image-detection endpoint was free-tier accessible

    A free user could POST an image to /api/chat/detect-garments and burn SegFormer + VLM tokens on every upload, even though the downstream search would 402 them. Closed the bypass.

    • Endpoint now 402s free-tier callers before any inference fires.
    • Added per-user rate limit (60 detections/hour) for paid tiers — natural-traffic ceiling, cheap protection against runaway uploads.
    • Mirrors the existing paywall on /api/scout image search.
    Fix

    Changelog deploy was broken — fixed the same hour

    The changelog page itself failed to deploy because .vercelignore had 'data' (no leading slash) which excluded the new src/data/ directory. Caught the failure via GitHub deployments API, fixed in minutes.

    • Local tsc passed because tsc walks the actual filesystem without .vercelignore filtering.
    • Vercel build failed with 'Module not found: @/data/changelog' because Vercel never copied src/data/ into the build context.
    • Anchored the pattern to /data so it only matches the top-level /data/ fixtures dir.
    • Adding this entry on the first cycle after the fix — exactly the kind of transparency the changelog page is for.
    Commitsf1d0b57
  5. May 16, 2026
    New

    Changelog page (this one)

    Started publishing what we ship as we ship it. The autonomous wake loop now appends an entry here every cycle that delivers something user-visible.

    • New /changelog route with type-safe entry data and color-coded badges.
    • Linked from the site footer for discoverability.
    • Playwright-verified that the page renders + lists entries.
    Fix

    Foundation taste-rating collision bug — fixed

    Critical personalization bug: foundation tile ratings were silently overwriting each other, so cold-start users were only saving 1 rating's worth of signal instead of N.

    • Foundation items ship with marketplace_url='' so every rating collided on the same ON CONFLICT key — only the latest persisted.
    • Now synthesises foundation://<image_url> so each distinct foundation image gets its own ratings row.
    • Same fix applied to the click-signal endpoint (implicit signals were also collapsing).
    • New cold-start users now train their taste vector on full N signals instead of 1.
    Commitsfc8265d54b733f
    Fix

    Mobile login was structurally broken

    The 'Continue with Google' button on /auth/login and /auth/signup was squished to 115×88px on mobile — text wrapped to 4 lines. Caught by Playwright dimension check.

    • Outer shell had an inline flexDirection: 'row' that overrode the mobile media query.
    • Auth-right was getting only 50% of viewport (~195px), crushing the form inside.
    • Added .auth-shell class with media-query column-stack on mobile.
    • Google button now measures ~310×48px on iPhone width.
    Commits4a3f9d2
    New

    Lock items + price alerts

    Plus/Pro feature: tap a tile's three-dot menu → 'Lock & alert me on cheaper'. The system watches similar listings across marketplaces and surfaces ones below the locked price.

    • New 'Locked' menu item in the tile overflow, gated to Plus/Pro tiers.
    • Dedicated /taste/locked dashboard showing each locked piece + its cheaper cosine-similar matches with savings %.
    • Backend cosine-search finds matches ≥ 0.85 similarity AND below the locked price.
    • Fixed a critical bug where the lock POST returned HTTP 500 for every user (text/double COALESCE mismatch in the SQL).
    Commitse27c90715716a63300603
    New

    10×10 taste cluster matrix

    Founder-requested layout: 10 taste-seed items × 9 visually-similar siblings each = 100 tiles per page. Each row is a coherent visual cluster.

    • New /taste/clusters route with sequential cluster rows (seed + siblings).
    • Each cluster reads as one aesthetic — the seed defines the row, the siblings show the range.
    • Backend uses greedy max-cosine selection over 768-dim image embeddings.
    • Cold-start hardened with DISTINCT ON (gender, category) so the 10 seeds always span 10 unique buckets (was repeating 'Men · Topwear' 4× of 10).
    Commits15716a6d11e072e97b90f
    Fix

    Foundation tile images were all 404'd by browsers

    Major UX bug caught by console-error audit: the ceyda foundation dataset's 42,700 image URLs were stored as http:// so every modern browser blocked them with mixed-content warnings. /taste and /taste/clusters rendered as broken-image grids.

    • Bulk-migrated all 42,700 runway_garments rows from http:// → https:// in one SQL UPDATE.
    • Verified myntassets CDN serves the same images over HTTPS (Akamai-backed).
    • Patched the ingest script so future dataset pulls don't re-introduce the issue.
    • 0 mixed-content warnings on /taste after the fix (was 261).
    Commits4d9d81d
    Polish

    Chat search shows real marketplace names now

    The 'Searching marketplaces…' typing indicator used to show 9 generic emojis (🛒 👔 🧡 …). Now it shows actual marketplace name pills that brighten as each result arrives.

    • Pills: Grailed · Vinted · Mercari · Vestiaire · Depop · eBay · Yahoo JP · Rakuma · Lev Market
    • Each pill greys out by default, brightens to accent-navy when its SSE event lands.
    • Backend marketplace string normalised so 'Yahoo Auctions JP' → 'Yahoo JP' pill (no dupes).
    • Verified live: 9/9 pills render during a real search.
    Commits597f8ce
    Fix

    Scout marketing promise no longer 404s

    Landing page + signup page advertised 'Search 12+ marketplaces with one photo' but /scout returned 404. Fixed by redirecting to the actual upload surface.

    • /scout (and /scout/*) now 308-redirects to /chat?upload=1.
    • /chat?upload=1 auto-opens the file picker once the composer mounts.
    • /auth/reset-password page built — password recovery email link used to lead to a 404.
    Commitsdddc7f10a78bc1
    Fix

    Chat composer guard: empty + overlong queries rejected

    Hitting Enter on an empty composer used to create a phantom 'New Search' conversation. Pasting a 5000-char blob would hit SerpAPI with the whole thing.

    • Backend rejects empty messages with HTTP 400 + a clear 'Type something to search for' message.
    • Backend caps query length at 500 characters with a clear error.
    • Frontend textarea now has maxLength=500 so the user can't even type past the cap.
    Commits450c6ddcba20c0
    Fix

    Stale '$4.99/mo' price quote → $14.99/mo

    The free-tier upgrade overlay on /taste still quoted '$4.99/mo' for Plus, but the actual price is $14.99. Anyone clicking through would have hit an unexpected checkout total.

    • /taste FreeUpgradeOverlay copy now matches /pricing + /billing prices.
    • Both pricing pages already show Plus = $14.99 / Pro = $39.99 consistently.
    Commitsa794623
    Fix

    Cold-start feed shows clothes only, in diverse categories

    The /taste cold-start feed used to mix in watches/bags/accessories and repeat the same gender×category 4× of 10 tiles. Both fixed.

    • Foundation pool filtered to show_slug IN ('Apparel', 'Footwear') — no more watches.
    • Cold-start now uses DISTINCT ON (gender, category) so 10 tiles = 10 unique buckets.
    • Foundation titles cleaner — shows 'MEN · Topwear' instead of 'MEN · Men Apparel Topwear'.
    Commits21f14a493311122ec5cb1e97b90f
    New

    ItemScanner shows real cosine-matched listings

    When you focus an item in /taste, the side panel used to display fake seeded-random rows. Now it shows actual marketplace listings that match the focal item by image embedding cosine.

    • Backend GET /api/taste/item/{id}/listings returns the focal item + nearest matches from your feed (cosine ≥ 0.85).
    • Frontend ItemScanner fetches real data on focal-item change; falls back gracefully on network errors.
    • Cross-tenant safe — focal item lookup AND match candidate pool both scoped to the calling user.
    Commits6c3caf5
    Infra

    Production verifier — 20 end-to-end Playwright checks

    Every time we ship, scripts/verify_prod.py runs through 20 checks against live prod — anonymous reachability, YC login, taste pages, lock regression, chat real-search, scout-redirect, password reset, mobile measurements.

    • Playwright headless Chromium walks the whole app, asserts the things we ship don't regress.
    • Self-cleans test conversations afterward so the YC reviewer's history stays pristine.
    • Runs as part of every wake cycle (~30 min cadence today).
    • Currently 20/20 green.
    Commits57c50646ecdd6ea0a8c68b9ff9753f775d3
Have feedback? support@mytaste.fit