- Backend: validate display.currency against 10 supported ISO 4217 codes
(USD, GBP, EUR, CAD, AUD, JPY, CHF, MXN, BRL, INR); return 400 on
unsupported code with a clear message listing accepted values
- Frontend: useCurrency composable fetches rates from open.er-api.com
with 1-hour module-level cache and in-flight deduplication; falls back
to USD display on network failure
- Preferences store: adds display.currency with localStorage fallback for
anonymous users and localStorage-to-DB migration for newly logged-in users
- ListingCard: price and market price now convert from USD using live rates,
showing USD synchronously while rates load then updating reactively
- Settings UI: currency selector dropdown in Appearance section using
theme-aware CSS classes; available to all users (anon via localStorage,
logged-in via DB preference)
- Tests: 6 Python tests for the PATCH /api/preferences currency endpoint
(including ordering-safe fixture using patch.object on _LOCAL_SNIPE_DB);
14 Vitest tests for convertFromUSD, formatPrice, and formatPriceUSD
Add GET /api/search/async that returns HTTP 202 immediately and streams
scrape results via SSE to avoid nginx 120s timeouts on slow eBay searches.
Backend:
- New GET /api/search/async endpoint submits scraping to ThreadPoolExecutor
and returns {session_id, status: "queued"} before scrape begins
- Background worker runs same pipeline as synchronous search, pushing
typed SSE events: "listings" (initial batch), "update" (enrichment),
"market_price", and None sentinel
- Existing GET /api/updates/{session_id} passes new event types through
as-is (already a generic pass-through); deadline extended to 150s
- Module-level _search_executor (max_workers=4) caps concurrent scrape sessions
Frontend (search.ts):
- search() now calls /api/search/async instead of /api/search
- loading stays true until first "listings" SSE event arrives
- _openUpdates() handles new typed events: "listings", "market_price",
"update"; legacy untyped enrichment events still handled
- cancelSearch() now also closes any open SSE stream
Tests: tests/test_async_search.py (6 tests) covering 202 response,
session_id registration in _update_queues, empty query path, UUID format,
and no-Chromium guarantee. All 159 pre-existing tests still pass.
Closes#49. Also closes Forgejo issue #1 (SSE enrichment streaming, already
implemented; async search completes the picture).
Documents the cf-orch allocation pattern (cf_text openai_compat backend
with cf_orch block). Snipe's trust query builder can route through
cf-text when CF_ORCH_URL is set, rather than hitting ollama directly.
Under high concurrency (100+ users), shared_db write contention causes
database is locked errors in the unguarded post-scrape block. These were
surfacing as 500s because there was no exception handler after line 663.
Now catches OperationalError and returns raw listings with empty trust
scores/sellers (degraded mode) instead of crashing. The SSE queue entry
is cleaned up on this path so no orphaned queue accumulates.
Root cause: shared_db (sellers, market_comps) is SQLite; at 100 concurrent
writers the WAL write queue exceeds the 30s busy timeout. Long-term fix
is migrating shared state to Postgres (see snipe#NN).
Refs: infra#12 load test Phase 2 spike findings
Fixes recurring `400 Missing access token` errors in Snipe logs.
`_fetch_public_key()` was making unauthenticated GET requests to
eBay's Notification API (`/commerce/notification/v1/public_key/{kid}`),
which requires an app-level Bearer token (client_credentials grant).
Wires in the existing `EbayTokenManager` as a lazy module-level
singleton so every public key fetch carries a valid OAuth token.
Also adds `GET /api/ebay/webhook-health` for Uptime Kuma compliance
monitoring — returns 200 + status dict when all five required env vars
are present, 500 with missing var names otherwise.
Runbook: circuitforge-plans/snipe/ebay-webhook-compliance-runbook.md
Kuma monitor: id=19 on heimdall status page (Snipe group)
- theme.css: add background: var(--color-surface) to body so it responds
to theme changes (was hardcoded #0d1117 via FOFT guard in index.html,
causing mixed dark/light on light theme)
- index.html: add Plausible analytics snippet (cookie-free, self-hosted,
skips localhost; reports to hostname + circuitforge.tech rollup)
- index.html: clarify FOFT guard comment — bundle overrides both html
and body once loaded
Phase 2 (snipe#4): after bulk-reporting sellers to eBay T&S, Snipe now
persists which sellers were reported so cards show a muted "Reported to
eBay" badge and users aren't prompted to re-report the same seller.
- migration 012 adds reported_sellers table (user DB, UNIQUE on seller)
- Store.mark_reported / list_reported methods
- POST /api/reported + GET /api/reported endpoints
- reported store (frontend) with optimistic update + server persistence
- reportSelected wires into store after opening eBay tabs
Phase 3 prep (snipe#4): community blocklist share toggle
- Settings > Community section: "Share blocklist with community" toggle
(visible only to signed-in cloud users, default OFF)
- Persisted as community.blocklist_share user preference
- Backend community signal publish now gated on opt-in preference;
privacy-by-architecture: sharing is explicit, never implicit
Add 10 new tests covering the three previously untested flag paths:
- zero_feedback: flag fires + composite capped at 35 even with all-20 signals
- long_on_market: fires at >=5 sightings + >=14 days; NOT at <5 sightings or <14 days
- significant_price_drop: fires at >=20% below first-seen; NOT at <20% or no prior price
- established_retailer: duplicate_photo suppressed at feedback>=1000; fires below threshold
Also fix datetime.utcnow() deprecation in aggregator._days_since() and test helper
— replaced with timezone-aware datetime.now(timezone.utc) throughout.
Add _auth_label() classifier: local/anon/guest/authed — no PII, just
enough to distinguish traffic types in docker logs for log-based analytics.
Instrument /api/session: logs new_guest (with UUID) or auth=.../tier=...
on every session bootstrap. Instrument /api/search: expands existing
multi-search log line with auth=, tier=, adapter=, pages=, queries=,
listings= fields for grep/awk analysis of search behaviour by tier.
Add logging.basicConfig so app-level log.info() calls appear in docker
logs alongside the Uvicorn access log (previously suppressed by missing
root handler).
Both stores hardcoded /api/* paths without reading VITE_API_BASE, causing
/api/session to hit the menagerie root (no /snipe prefix) and receive a
302 redirect to circuitforge.tech/login instead of a session response.
Every other store already read VITE_API_BASE correctly.
- Rewrite landing hero subtitle with narrative opener ("Seen a listing
that looks almost too good to pass up?") — more universal than the
feature-list version, avoids category assumptions
- Update eBay cancellation callout CTA to "Search above to score
listings before you commit" — direct action vs. passive reminder
- Tile descriptions rewritten with concrete examples: "does this seller
actually know what they're selling?", "40% below median", quoted
"scratch and dent" pattern for instant recognition
- Sign-in strip: add specific page count ("up to 5 pages") and
"community-maintained" attribution for blocklist credibility
- Fix useTheme restore() to re-read from localStorage instead of using
cached module-level ref — fixes test isolation failure where previous
test's setMode('dark') leaked into the restore() system test
- Add 32-test Vitest suite: useTheme (7), searchStore (7),
ListingView component (18) — all green
Adds user-controlled theme selection independent of OS preference:
- useTheme composable: sets data-theme="dark"|"light" on <html>,
persisted to localStorage as snipe:theme. Follows the same pattern
as useSnipeMode.
- theme.css: [data-theme="dark"] and [data-theme="light"] explicit
attribute selectors override @media (prefers-color-scheme: light).
Media query updated to :root:not([data-theme="dark"]) so it has no
effect when the user has forced dark on a light-OS machine.
- App.vue: restoreTheme() called in onMounted alongside restoreSnipeMode.
- SettingsView: Appearance section with System/Dark/Light segmented
button group.
Replaces the Coming Soon placeholder. Clicking Details on any card opens
a full trust breakdown view:
- SVG score ring with composite score and colour-coded verdict label
- Auto-generated verdict text (identifies worst signals in plain English)
- Signal table with mini-bars: Feedback Volume/Ratio, Account Age,
Price vs Market, Category History — pending state shown for unresolved
- Red flag badges (hard vs soft) above the score ring
- Photo carousel with prev/next controls and img-error skip
- Seller panel (feedback count/ratio, account age, pending enrichment note)
- Block seller inline form wired to POST /api/blocklist
- Triple Red pulsing border easter egg carried over from card
- Not-found state for direct URL access (store cleared on refresh)
- Responsive: single-column layout on ≤640px
- ListingCard: adds Details RouterLink to price column
- search store: adds getListing(id) lookup helper
Silences PytestUnknownMarkWarning from '-m not browser' in GitHub
Actions CI. Any test requiring headed Chromium (Kasada bypass,
scraper integration) should be decorated with @pytest.mark.browser
so it is excluded from CI automatically.
Closes#40
After populateFromLLM() runs, the sidebar filter controls and search bar now
update to reflect the LLM-generated values. Adds two one-way watchers
(store.filters → local reactive, store.query → queryInput). Closes#39.
Adds optional community_store param to refresh(). Credentialed instances
publish leaf categories to the shared community PostgreSQL after a
successful Taxonomy API fetch. Credentialless instances pull from community
(requires >= 10 rows) before falling back to the hardcoded bootstrap.
Adds 3 new tests (14 total, all passing).
Corrections (#31):
- Add 010_corrections.sql migration (from cf-core CORRECTIONS_MIGRATION_SQL)
- Wire make_corrections_router() at /api/corrections (shared_db, product='snipe')
- get_shared_db() dependency aggregates corrections across all cloud users
Community module (#32#33):
- Init SnipeCommunityStore at startup when COMMUNITY_DB_URL is set
- Graceful skip if COMMUNITY_DB_URL is unset (local mode, community disabled)
- add_to_blocklist() publishes confirmed_scam=True seller_trust signal to
community postgres on every manual blocklist addition (fire-and-forget)
- BlocklistAdd gains flags[] field so active red-flag keys travel with signal
cf-orch community postgres (cf-orch#36) + cf-core module (cf-core#47) both merged.
Retake 01-hero, 02-results, 03-steal-badge, and hero.png after the
color-mix() refactor (d5651e5) so docs reflect corrected light/dark
tints on STEAL badge, flags, and trust score indicators.