soldi build log
Daily ledger of vertical slices shipped toward the PRD MVP. Each entry is the slice that landed, the verification evidence, and what's queued next.
2026-06-01 direction change: adopting the
closer — v2prototype design system and expanding scope to Comps + Pipeline + Sequences. Seedocs/ROADMAP.md,docs/DESIGN_SYSTEM.md, anddocs/SPEC_{COMPS,PIPELINE,SEQUENCES}.md. Next build slice is the design-system migration (foundation), then the 3 new pages.
2026-06-01 — Slice 05: Pipeline board (read-only CRM kanban)
What shipped
migrations/0003_pipeline.sql— reconcilesportfolios.statusto the 6-stage enum (new | contacted | offer | under-contract | assigned | dead; legacyfollow_up/under_contract/convertedmapped over), addsportfolio_stages(1:1,current_stage/stage_entered_at/next_action_*/offer_sent_amount_cents/assignment_*/stale_days_threshold) +portfolio_actions(activity log) +portfolios.next_action_due_at(indexed). Seeds ~6 demo portfolios forU_SEED_GHOSTspread across stages (incl. a stale "offer" 18d row, an under-contract, an assigned) with matching stage + action rows.worker/pipeline.ts— pure aggregation (no D1):groupCardsByStage(all 6 stages always present, fixed order),computeKpis(leadsInPipe excl. dead, underContract count+gpCents, assignedMtd, conversionPct guarded against /0, avgStageAgeDays, staleCount),applyFilter,isStale(per-row threshold),rowToCard,buildPipeline.worker/pipeline.test.ts(+14 tests).worker/routes/pipeline.ts—GET /pipeline?filter=…:currentUsergate (401), Zodfilterenum (400 on bad value), joinsportfolios ⨝ portfolio_stages ⨝ leadsfor the session user,stage_age_dayscomputed viajulianday('now') - julianday(stage_entered_at)in SQL (never client-parsed). Mounted inworker/index.tsviaapi.route('/', pipelineRoutes).- Frontend:
fetchPipeline()+ Pipeline DTO types insrc/lib/api.ts;relativeDue()(today/overdue/future/none) insrc/lib/format.ts(+4 tests);src/pages/Pipeline.tsx6-column kanban (6→3 @1300px scoped<style>, stage-colored left borders, KPI row, filter chips w/ counts, card next-action/offer/progress,.skeleton+ empty + error states);/pipelineroute inApp.tsx; Pipeline promoted fromsoonLinksto a realNavLinkinTopNav(Comps/Sequences still disabled).
Verification
$ bun run verify → typecheck clean · Tests 87 passed (87) · build green
(69 prior + 14 worker/pipeline.test.ts + 4 new relativeDue cases)
worker/pipeline.test.ts: grouping (6 stages, empty stages count:0),
KPIs (dead excluded, gpCents, assignedMtd, conversionPct no NaN on empty),
applyFilter (due_today/overdue/stale_14d/under_contract; all = identity),
per-row stale threshold.
As-built divergence from PRD: third stage key shipped as offer
(display "Offer Out"), not the PRD's offer-sent, consistently across the enum,
migration, and API DTO. under_contract filter key unchanged. docs/prd/FEATURE.md
updated to SHIPPED + reconciled.
Demo access: migrations/0004_demo_login.sql sets the seeded board owner
(U_SEED_GHOST) to demo@soldi.cc / soldidemo (PBKDF2 hash computed via
worker/auth.ts) so the populated Pipeline is reachable in the demo — /pipeline
is gated to the logged-in user, so a fresh signup sees an empty board.
Independently re-verified: 87 tests, typecheck/build green, 0001–0004 apply
clean, GET /pipeline returns all 6 stages + KPIs, kanban screenshot confirmed.
Process note: this slice was the first run of the reusable
.claude/workflows/feature-lifecycle.js (discover→plan→build/verify→sexy→
simplify→confirm→document). args did not reach the script, so the Discover
phase read docs/ROADMAP.md and self-selected Pipeline — a useful robustness
property, but pass-args propagation should be confirmed for targeted runs.
Next up: Pipeline writes (slice 3) — PUT /portfolios/:id/stage (drag-drop
moves) + POST …/actions + next-action edits, with the sequence-enrollment
event hook the portfolio_actions table was scaffolded for.
2026-06-01 — Slice 04: Keystone — cron auction resolution + buy-it-now
What shipped
worker/settlement.ts— puresettleOutcome({status,endTimeIso,nowMs,topBid})→skip | sold | unsold(usesparseDbTime).settlement.test.ts(+7 tests).worker/scheduled.ts—settleDueAuctions(env,now)+ exportedscheduled()handler; cron["* * * * *"]in wrangler.jsonc. Settles auctions pastend_time: SOLD → statusended_sold+ winning ids, converts leader hold→charge (held_balance/balancedown,total_spent/leads_wonup, streak bump),wallet_transactions 'charge', insertsportfoliosrow; UNSOLD →ended_unsold. Status-guarded UPDATEs (idempotent) + per-auction try/catch (resilient).worker/routes/buynow.ts—POST /auctions/:id/buy-now: instant settlement (insert is_buy_now bid, release prior leader hold, charge buyer, ended_sold, portfolio row). Errors 401/404/409/402.- Frontend:
buyNow()in api.ts; LeadDetail "Buy it now" button enabled withsubmitBuyNow(mirrors submitBid; toast + balance refresh). Skeleton extracted to keep the file < 400 lines.
Verification
$ bun run verify → typecheck clean · Tests 69 passed (69) · build green
# wrangler dev --test-scheduled on :8787 (fresh bootstrap)
buy-now A_CHI_TAXLIEN_03 → bought:true, status ended_sold; buyer 50000→39200,
totalSpent 10800, leadsWon 1, streak 1; portfolios row (status 'new') created.
real bid 13835 (held 13835) → force end_time past → /__scheduled trigger →
auction ended_sold (winning_user_id set); buyer 39200→25365, held→0,
totalSpent 24635, leadsWon 2, streak 2; portfolios=2. Idempotent on re-run.
Next up: Portfolio KPIs + weekly Leaderboard from D1 (now that settlement populates real data); then CI + a Playwright bid/buy-now e2e.
2026-06-01 — Ops: cloud-docs response persistence (KV)
Added KV-backed submission capture to the soldi-docs worker:
- KV namespace
RESPONSES(29c8518…) bound intools/cloud-docs/wrangler.jsonc. - Worker:
POST /api/responsesstores{id, submittedAt, country, userAgent, answers}underresp:<id>;GET /api/responses[/:id]lists/fetches. All open (light, non-confidential — no auth, no token to paste). Everything else → assets. - Questionnaire: added a "Submit to soldi →" button that POSTs the export object
to
/api/responses(graceful fallback to local download when offline). - Verified live: POST→
{ok,id}, list/by-id round-trip (country US, answers intact), invalid JSON→400, open GET→200. Read responses:curl https://soldi-docs.camolechowski.workers.dev/api/responses.
Docs IA + custom domain: home is now a client-doc index ("Released to you" =
Product Direction; "Internal references" = roadmap/specs/build-log). The
questionnaire is served at the clean path /product-direction (old
/questionnaire.html 307→redirects). Custom domain docs.soldi.cc added to
the worker (routes custom_domain; soldi.cc is an active zone on the account) —
workers.dev URL still serves too.
2026-06-01 — Ops: reproducible setup + codebase-wide simplify (round 2)
Setup hardened: pinned wrangler dev to :8787; added bootstrap
(reset+migrate+seed), start (build+dev), verify (typecheck+test+build),
db:reset:local. Clean-slate bootstrap → verify → start proven: health OK,
12 auctions from D1, SPA 200.
Simplify round 2 (8 agents, disjoint lanes incl. worker):
worker/index.ts402→36 lines, split intoworker/routes/{auctions,bid,auth}.ts- shared
worker/users.ts(UserRow/publicUser/USER_SELECT_SQL/currentUser);currentUser(c)takes the Context directly; STATUS_WHERE map; deduped bids SQL; removed dead SessionVars.
- shared
- frontend: deduped
FeedState,QUALITY_ROWSmap, hoisted per-render consts,StatusDotprimitive,postJsonhelper in api.ts,run()helper in session.tsx, deleted deadformatPriceDelta. index.css: removed 23 dead back-compat aliases (verified zero orphaned utility usages acrosssrc/).- Verified: typecheck clean · 62/62 tests · build green · 0 orphaned classes ·
login + marketplace screenshots confirm no visual regression. Snapshot:
.backup_src_round2.tgz.
2026-06-01 — Ops: UI polish pass + cloud docs deployed
Polish workflow (25 agents): per-page apply of the make-it-sexy sub-skills
(cutting-edge-ux-patterns, fluid-micro-interactions, high-end-ui-assembly,
modern-visual-aesthetics; rsc-streaming-architectures correctly skipped for a
Vite SPA) then make-it-simpler, then a final unscoped DRY pass. Net: micro-
interactions/reveal staggers, deduped FeedState/SectionHeading/Collapse,
DRYed bid-reload + min-bid rounding, null-safe badges. No new deps; index.css
frozen except the solo final pass. Verified: typecheck clean · 62/62 tests ·
build green · screenshot. Pre-pass snapshot at .backup_src_phaseUX.tgz.
Cloud docs deployed → tools/cloud-docs/ (Workers Static Assets). build.ts
renders docs/*.md + BUILD_LOG.md → soldi-branded HTML and folds in the client
questionnaire. Live (personal CF account, creds from .env):
https://soldi-docs.camolechowski.workers.dev (routes: /roadmap, /design-system,
/spec-comps, /spec-pipeline, /spec-sequences, /build-log, /questionnaire.html).
Gotcha: an assets-only Worker (no
main) returned a persistent edgeerror 1105/ 503 on workers.dev despite a successful upload and an enabled subdomain. Fix: add a minimalmainentry (src/index.ts→env.ASSETS.fetch(req)) with anASSETSbinding — the canonical Workers-Static-Assets form. Use this in thetools/wrangler template.
2026-06-01 — Phase A: Design-system migration (closer-v2 reskin, still "soldi")
What shipped (foundation written inline; 4 page-restyles fanned out in parallel)
src/index.css— rewrote@theme+:rootto the closer-v2 metallic palette (bull/bear/warn/hot/accent-lavender/gold + surface/text/border tiers), added Fraunces/Instrument Serif/Hanken Grotesk/JetBrains Mono tokens, the 56px grid wash (body::before), new shadows/radii, keyframes (price-flash, urgent-pulse, reveal-up), and a button variant system (.btn-primarynow bull,.btn,.btn-ghost,.btn-danger,.pill,.chip,.qscore). Old token names kept as aliases so utilities keep resolving during the migration. No Robinhood green.index.html— swapped font<link>to Fraunces/Instrument Serif/Hanken Grotesk/ JetBrains Mono; body bg#0B0D0E.- Restyle agents (disjoint files): shell (TopNav wordmark = "soldi" in
Instrument Serif + bull dot; tab-bar nav with disabled Comps/Pipeline/Sequences
"soon" tabs; LiveTicker; Layout), floor+cards+ui (Marketplace "The floor",
AuctionCard/Grid, QualityBadge→
.qscoreconic circle, PriceDisplay/Countdown/ HotBadge/DistressTag), lead-detail (bidding panel — logic preserved), and portfolio+misc (Portfolio/Leaderboard/Activity/Login). - Name stays "soldi" everywhere (worker, package, wordmark). Only "closer" reference left is an internal doc comment noting the prototype's origin.
Verification (commands + key output)
$ bun run typecheck → tsc -b clean
$ bun run test → Test Files 5 passed (5) · Tests 62 passed (62) (logic intact)
$ bun run build → ✓ built; dist/assets/index-*.css 35.00 kB
# wrangler dev :8787 + chrome-devtools screenshots:
# / (marketplace) → soldi wordmark, tab bar, "The floor" (Fraunces),
# conic quality circles, distress/equity/age chips,
# mono prices, bull quick-bid buttons.
# /lead/A_PHX_PROBATE_07 → Instrument Serif address, Fraunces section heads,
# bid input prefilled to min ($250), bull "Sign in to
# bid" CTA, restyled bid history. Bidding UI intact.
Next up
Phase B — fan out the 3 new pages (Pipeline read · Comps UI+mock · Sequences
read) per docs/ROADMAP.md, each with its own migration + Hono route + page,
worktree-isolated, verified per vertical.
2026-06-01 — Slice 03: Bidding end-to-end (increments, holds, anti-snipe)
What shipped
worker/bidding.ts— pure, unit-testable rules:minBidIncrement($5 or 5%, whichever greater),minNextBid,applyAntiSnipe(final-2-min window → +2min, max 5 extensions),validateBid, andparseDbTime(normalizes SQLite's space-separateddatetime()output to ISO-UTC).worker/index.ts—POST /api/v1/auctions/:id/bid: auth-gated, Zod body, loads auction + current leader, validates, applies anti-snipe, and writes atomically viaDB.batch— insert bid, bumpcurrent_price/bid_count/end_time/snipe_extensions, place the new leader's hold, release the prior leader's hold, and recordhold/releasewallet_transactions. Returns the refreshed auction +extendedflag. Error map: 401 unauthorized, 404 not_found, 409 auction_ended/already_leading, 422 bid_too_low, 402 insufficient_funds.worker/bidding.test.ts— 20 unit tests (increment floor/percent, snipe window/boundary/cap/ended, db-time parsing both formats, all validation paths).src/lib/api.ts—placeBid()helper.src/pages/LeadDetail.tsx— live bid form (input prefilled to min next bid), error-code→toast mapping, "Extended! 2:00 added" toast on anti-snipe, 5s auction polling (PRD §12), balance refresh after a successful bid.
Verification (commands + key output)
$ bun run typecheck → tsc -b clean (exit 0)
$ bun run test → Test Files 5 passed (5) · Tests 62 passed (62)
$ bun run build → ✓ 62 modules transformed; built in ~0.6s
# local D1 + wrangler dev on :8787 (after rm -rf .wrangler/state/v3/d1
# && bun run db:migrate:local && bun run db:exec:local migrations/0002_seed.sql)
POST /auctions/A_CHI_PREFCL_01/bid (no auth) → 401
POST … {amount:13000} (< minNext 13335) → 422 bid_too_low
POST … {amount:60000} (> available 50000) → 402 insufficient_funds
POST … {amount:13335} (valid, bidder A) → 201 currentPrice 13335, bidCount 6
POST … {amount:15000} (A already leader) → 409 already_leading
GET /auth/me (A) → heldBalance 13335, balance 50000
POST … {amount:15000} (bidder B outbids A) → 201
GET /auth/me (A) → heldBalance 0 | (B) → heldBalance 15000 (prior hold released)
wallet_transactions(A) → hold 13335 then release 13335 (reference A_CHI_PREFCL_01)
# anti-snipe: forced A_DAL_CODE_09 end_time to +60s, bid as B
POST … {amount:7850} → {extended:true}; end_time 04:14:51 → 04:16:51 (+2:00); snipe_extensions 0→1
Gotcha caught during verify
D1's datetime() returns space-separated timestamps (2026-06-01 04:13:27, no
T, no Z), which Date.parse turns to NaN in workerd. First anti-snipe
test silently failed (extended:false, no extension) AND the auction_ended
guard would never trip (NaN <= now is false). Added parseDbTime to normalize
both SQLite and ISO formats; re-verified the extension fires (+2min, ext 0→1).
Next up
Design-system migration (foundation slice) per docs/DESIGN_SYSTEM.md — port
the closer v2 tokens/typography/components into index.css + index.html and
restyle the existing pages, no data changes. Then the 3 new pages (Pipeline,
Comps, Sequences) per docs/ROADMAP.md Phase B.
2026-05-29 — Slice 01: Marketplace feed end-to-end from D1
What shipped
migrations/0002_seed.sql— 13 leads + 12 auctions across IL/FL/AZ/TX/GA/CA, all distress types, quality 34–91, freshness from 15min to 32h old, 1 discount auction, 1 placeholder user, 5-bid history on the Phoenix probate auction. All times anchored todatetime('now', ...)so "ending soon"/"new" tabs stay realistic across runs.worker/mappers.ts— snake_case D1 row → camelCase frontendAuctionDTO with nestedleadandbids[], includingQualityBreakdownJSON parsing guarded by Zod and a fallback for malformed JSON. SharedAUCTION_SELECT_SQLfor list + detail.worker/index.ts—GET /api/v1/auctions?status=active|discount|all&limit=N(Zod-validated query) andGET /api/v1/auctions/:idreturning the auction with full bid history joined tousers.display_handle. Proper 404 for unknown ids.worker/mappers.test.ts— 8 unit tests covering the row→DTO mapping, JSON parse fallbacks, discount flags, status coercion, bid attachment.src/lib/api.ts— tiny typed fetch helpers (fetchAuctions,fetchAuction) with AbortSignal support.src/pages/Marketplace.tsx— replacesMOCK_AUCTIONSwith live API. Loading skeleton, error card, empty-tab card. Tab filters now run against real D1 data.src/pages/LeadDetail.tsx— fetches the single auction by id, renders a bid history panel when present, loading/error/missing states.
Verification (commands + key output)
$ bun run typecheck
$ tsc -b
# (clean)
$ bun run build
$ tsc -b && vite build
✓ 60 modules transformed.
dist/index.html 0.82 kB │ gzip: 0.46 kB
dist/assets/index-Cf1hfNuH.css 23.05 kB │ gzip: 5.25 kB
dist/assets/index-CLIXHb-m.js 252.42 kB │ gzip: 79.57 kB
✓ built in 574ms
$ bun run test
Test Files 3 passed (3)
Tests 28 passed (28)
Local D1 + wrangler dev smoke (after rm -rf .wrangler/state/v3/d1 && bun run db:migrate:local):
$ curl -sS http://localhost:8788/api/v1/health
{"ok":true,"service":"soldi","time":"2026-05-30T04:13:07.901Z"}
$ curl 'http://localhost:8788/api/v1/auctions?status=all&limit=100' → count: 12
A_CHI_TAXLIEN_03 Chicago, IL q=62 price=$54 ends 04:54 (ending-soon)
A_PHX_TAXLIEN_13 Phoenix, AZ q=80 price=$29 status=discount
A_CHI_PREFCL_01 Chicago, IL q=87 price=$127
A_PHX_PROBATE_07 Phoenix, AZ q=89 price=$238
A_MIA_DIVORCE_06 Miami, FL q=81 price=$172
A_DAL_ABSENTEE_08 Dallas, TX q=65 price=$62
A_ATL_PREFCL_10 Atlanta, GA q=76 price=$111
A_CHI_PROBATE_02 Chicago, IL q=78 price=$142
A_MIA_TAXLIEN_05 Miami, FL q=91 price=$210
A_HOU_PROBATE_11 Houston, TX q=83 price=$132
A_SPRING_VACANT_04 Springfield,IL q=34 price=$25
A_DAL_CODE_09 Dallas, TX q=70 price=$69
$ curl http://localhost:8788/api/v1/auctions/A_PHX_PROBATE_07
bids: 5 (PhoenixVolume @ $190 → $205 → $218 → $225 → $238)
qualityBreakdown: {equity:25, motivation:25, propertyValue:20, contactQuality:12, dataCompleteness:7}
$ curl /api/v1/auctions/does_not_exist → HTTP 404 {"error":"not_found"}
$ curl '/api/v1/auctions?status=discount' → count: 1 (A_PHX_TAXLIEN_13)
$ curl / → HTTP 200, <title>soldi - Distressed Property Lead Auctions</title>
$ curl /lead/A_CHI_PREFCL_01 → HTTP 200 (SPA fallback)
Gotcha caught during verify
First seed run inserted only 9/12 auctions silently. Root cause: SQLite
datetime() takes modifiers as separate arguments — '+23 hours 30 minutes'
is not a valid single modifier and returns NULL, which then violated
end_time NOT NULL under INSERT OR IGNORE. Fixed three rows to use
datetime('now','+23 hours','+30 minutes') form. Re-applied migrations after
wiping .wrangler/state/v3/d1; now 12/12.
Next up
Auth + identity (the second slice that unlocks bidding, watching, portfolio,
wallet). Concretely: a register/login pair on /api/v1/auth, password hashing
with the Web Crypto API (PBKDF2 — no node bcrypt in Workers), a signed
session cookie or short JWT, a useSession() hook, and a GET /user/profile
endpoint backed by the existing users row. The bidding slice depends on
having a user_id to charge/hold against.
2026-05-30 — Slice 02: Auth + identity end-to-end
What shipped
worker/auth.ts— Web Crypto PBKDF2-SHA256 password hashing (100k iterations, 16-byte salt, 32-byte hash; stored aspbkdf2$<iters>$<salt-b64>$<hash-b64>), constant-time compare, HMAC-SHA256-signed session token (<body-b64>.<sig-b64>, 30-day TTL, expiry embedded in payload),buildSessionCookie/clearSessionCookie/readSessionCookiehelpers (HttpOnly,SameSite=Lax), andgenerateId/generateHandleFromNamehelpers.worker/types.ts—Envnow carriesSESSION_SECRET. Local secret in.dev.vars(gitignored), required by both/auth/registerand/auth/login.worker/index.ts— five new endpoints under/api/v1:POST /auth/register(Zod-validated{email, password>=8, name}, 409 on dup email, sets cookie, also writes awallet_transactionssignup_bonusrow crediting the $500 demo balance).POST /auth/login(verifies hash, updateslast_active_at, sets cookie, 401 on bad creds).POST /auth/logout(clears cookie).GET /auth/me(returns{ user | null }, never 401 — used by the session hook on every page load).GET /user/profile(401 if unauth; returns the same public-user shape).- Shared
USER_SELECT_SQL+publicUser()redactpassword_hashand other internal flags before returning to the client.
worker/auth.test.ts— 14 unit tests: salting, wrong-password, malformed-hash, token round-trip, tampered body, foreign secret, expired token, malformed token, cookie shape, cookie clear, cookie read, id uniqueness, handle suffix.src/lib/api.ts—fetchMe/login/register/logouttyped helpers withcredentials: 'same-origin'and a sharedreadErrorthat pulls the API's{ error }code out of the body.src/lib/session.tsx—SessionProvider+useSession()hook with{ user, loading, error }state pluslogin/register/logout/refreshactions; auto-fetches/auth/meon mount.src/pages/Login.tsx— dark Robinhood-style login + register form (toggleable,?mode=registerdeep-link), inline error mapping for the API error codes (invalid_credentials,email_taken,invalid_body,server_misconfigured), accent-glow focus rings.src/layouts/TopNav.tsx— replaces the mock balance + initials chip with the real session. Shows a loading shimmer while/auth/meresolves, a real balance pill + initials button (with a popover forSign out) once authed, andSign in/Sign uplinks when not authed.src/App.tsx— wraps the router in<SessionProvider>and adds the/loginroute.
Verification (commands + key output)
$ bun run typecheck
$ tsc -b
# (clean — exit 0, no output)
$ bun run test
Test Files 4 passed (4)
Tests 42 passed (42)
$ bun run build
✓ 62 modules transformed.
dist/index.html 0.82 kB │ gzip: 0.46 kB
dist/assets/index-DGVY73fC.css 27.88 kB │ gzip: 5.92 kB
dist/assets/index-DD7iVCN9.js 260.12 kB │ gzip: 81.49 kB
✓ built in 593ms
Local wrangler dev smoke (after wiping .wrangler/state/v3/d1 and
re-running bun run db:migrate:local). The dev server bound to a
random port (59545) this run — wrangler@3.114.17 no longer pins
to 8788; check the boot banner.
$ curl /api/v1/auth/me → {"user":null}
$ curl /api/v1/user/profile → HTTP/1.1 401 Unauthorized
$ curl POST /auth/register {bad} → HTTP/1.1 400 Bad Request
(Zod issues for email/password/name)
$ curl POST /auth/register → HTTP/1.1 201 Created
{cam@soldi.test, hunter222, "Cam Olechowski"}
Set-Cookie: soldi_session=…; HttpOnly; SameSite=Lax; Max-Age=2592000
{user.id: U_MPT9JLPC_…, displayHandle: CamOlechowsk7389,
balance: 50000, heldBalance: 0, createdAt/lastActiveAt set}
$ curl /auth/me (with cookie) → {user: {…full profile…}}
$ curl /user/profile (with cookie) → HTTP/1.1 200 OK
$ curl POST /auth/register {dup} → HTTP/1.1 409 Conflict {"error":"email_taken"}
$ curl POST /auth/login {wrong pw} → HTTP/1.1 401 Unauthorized {"error":"invalid_credentials"}
$ curl POST /auth/login {correct} → HTTP/1.1 200 OK + Set-Cookie + updated last_active_at
$ curl POST /auth/logout → HTTP/1.1 200 OK + Set-Cookie: …; Max-Age=0
$ curl /auth/me (post-logout) → {"user":null}
$ curl /api/v1/auctions?status=all&limit=200 → count: 12 (slice 01 still green)
$ curl / and /login → HTTP/1.1 200 OK (SPA + fallback intact)
$ wrangler d1 execute soldi --local --command \
"SELECT type, amount, description FROM wallet_transactions WHERE user_id = 'U_…';"
→ {type: 'credit', amount: 50000, description: 'Welcome bonus'}
Gotcha caught during verify
Wrangler 3.114 no longer defaults wrangler dev to port 8788 — it
picks a random ephemeral port (this run: 59545) and prints it in the
boot banner. The first curl against the assumed 8788 failed
("Couldn't connect"). Pulled the port out of the wrangler stdout
([wrangler:inf] Ready on http://localhost:59545) before re-running
the smoke battery. Worth pinning dev.port in wrangler.jsonc on a
later polish slice so the port stops moving between runs.
Next up
Bidding — the slice that finally turns soldi into an auction house.
Concretely: POST /api/v1/auctions/:id/bid with Zod-validated amount,
PRD §8 increment table enforcement, balance + held-balance accounting
(reserve the new highest bid, release the prior leader's hold), PRD §9
anti-snipe (+30s if the bid lands in the last 30s, capped at 12
extensions / 6 min), insert into bids, update auctions
current_price / bid_count / end_time / snipe_extensions, and
return the refreshed auction. Frontend: a bid input + submit on
LeadDetail wired through the session, optimistic bid-history
prepend, balance refresh, and error toasts for the failure modes
(insufficient_funds, bid_too_low, auction_ended,
unauthorized). Tests: a worker-side unit test for the increment +
anti-snipe rules.