Releases: datboip/StayBattle
Releases · datboip/StayBattle
Release list
v0.4.4
Security hardening from an external review, plus availability, teaser, and photo fixes.
Security
- Voter cookie is now HMAC-signed. It was unsigned JSON, and members receive the organizer's
voter_idin the page payload — so a member could hand-edit the cookie to the organizer's id and take over the battle (close it, kick people, roll the invite code). The cookie is signed with a per-instance secret auto-generated and persisted in the data dir; a forged or tampered cookie is rejected. Existing sessions are invalidated once (everyone re-signs in). - Submission photos stay blind against members too. During the submission phase the server sent every member the full listing objects (URLs, titles, coordinates, comments, submitter ids) and only hid them visually. Non-owned submissions now leave the server as
{ id, photos }only; full detail is sent for your own submissions. castVote/addCommentrequire battle membership. They checked identity but not membership or phase, and trusted the client-supplied display name. Votes now require the voting phase, both require membership, and both attribute to the authenticated name rather than whatever the client sends.
Added
- Availability now shows why a listing is booked. The scraper already stored Airbnb's reason (e.g. "Minimum stay is 3 nights") but the badge only ever rendered a bare "booked". It now shows the specific reason inline and in the tooltip. The availability badge also appears on your own submission cards during submission, so a listing that fails your trip dates is caught before voting.
- Hype shows as a pin on the teaser. A submitter's one-liner (e.g. "Private pool!") now appears as a pushpin note on that listing's tile in the "Current submissions" teaser, so the crew sees the pitch while browsing. (Note: this surfaces each hype to members during the submission phase.)
- Drive times are cached. The listing×place OSRM matrix was recomputed on every 6-second render, which could blow past the public OSRM demo server's 1-request/second limit with a few active voters. A
route_durationstable now caches each pair (a route between two fixed points never changes), so OSRM is only called when a new listing or place adds an uncached pair.
Changed
- Teaser photo crossfade is gentler (900ms → 1800ms) so cycling reads as a soft dissolve, not a flick.
Fixed
- Trophy-case delete button only shows for the organizer. It rendered for any signed-in voter but the server rejected non-organizers, so it silently failed. Now gated to the current battle's organizer, matching the server.
- Closed battles respect organizer availability overrides in the podium. The trophy calc kept a listing if it had any override at all; a listing the organizer marked "booked" could still win. It now uses the override's direction (
availability_override_status). - Listing cards show price and cancellation policy. Both were scraped and stored but never rendered.
- Airbnb UI icons no longer show up as listing photos. The scrape's host-allowlist accepted any
muscache.comURL, so Airbnb's own platform illustrations (/im/pictures/airbnb-platform-assets/…search-bar icons, the little-house and cartoon-person graphics) and host avatars leaked into the photo carousel. A stricterisListingPhotoUrl()now rejects those paths in both the GraphQL and HTML scrape paths; existing rows can be cleaned by re-scraping. - Submissions teaser: "Frozen" now stops the scroll. It previously only froze each tile's photo rotation, leaving the marquee drifting — so in the scrolling view "Frozen" looked like it did nothing. It now halts the scroll too.
- Submissions teaser: no more blank gap in the scrolling strip. With few submissions a row was narrower than the screen and the wrap-around exposed an empty tile; rows now repeat enough to always fill the width.
v0.4.3
Setup-flow papercuts.
Added
- Check-in auto-advances to check-out. Picking a check-in date now fills check-out with the next day (when it's empty or would fall on/before check-in) and opens its date picker, so setting a trip is one gesture instead of two disconnected fields. Shared
DateRangeFieldscomponent used by both the new-battle form and the organizer's trip-dates bar; pure date math insrc/lib/dates.tswith unit tests.
Fixed
- Sign out no longer needs a manual refresh.
useVoteris a per-component hook, so signing out in the header cleared its own copy but left the sign-in gate's separate copy stale until reload — you'd land on a dead form. It now broadcasts a same-tab auth-change event so every instance updates immediately.
v0.4.2
Patch release: un-bricks plain-http self-hosts.
Fixed
- Plain-http self-hosts were stuck on "Loading…". v0.4.1 added
upgrade-insecure-requests+ HSTS to the production CSP unconditionally. Browsers honour the upgrade directive even onhttp://origins, so a LAN install athttp://192.168.x.x:3000had every/_next/staticchunk rewritten tohttps://and failing withERR_SSL_PROTOCOL_ERROR— no JS ran, so the page never hydrated past the SSR "Loading…" fallback. Both directives are now opt-in viaSTAYBATTLE_HTTPS=true(a build-time flag / Docker build arg) for TLS-terminated deployments. Header logic moved tosrc/lib/security-headers.tswith vitest coverage. - Plain-http self-hosts answered "Sign in first" to every action. The voter cookie was
secure: NODE_ENV === "production"; browsers drop Secure cookies on any non-localhost http origin, so athttp://192.168.x.x:3000sign-in stuck client-side but the server never saw a session — "Start the battle", voting, everything refused.Secureis now gated on the sameSTAYBATTLE_HTTPS=trueflag (read at run time; the Dockerfile carries it into the runtime stage). Cookie attributes moved to a testedvoterCookieOptions()helper. - Public demo showed "checking…" on every listing forever.
scripts/admin/reset-demo.mjswiped the wholesettingstable, which dropped theavailability_reset_v1flag thatdb.tschecks at boot — so the next container restart re-ran the one-shot availability wipe and nulled every status the reset had just baked. The reset now re-arms the flag inside its own transaction. Integration test runs the real script against a temp DB. - Demo mode no longer scrapes Airbnb.
addListingwas not demo-gated, so any visitor could make the public demo fetchairbnb.com(README had claimed the scrape was skipped).addListingandrefreshAvailabilitynow refuse up front inSTAYBATTLE_DEMO_MODEwith a message saying why. - Re-check buttons surface refusals. Both
AvailabilityPaneland theBattleHeader"recheck all dates" button ignored the action result, so a refused recheck (demo mode, missing trip dates) either showed "checking 0 of N…" until a timeout or silently did nothing. Both now show the error inline. - "New code" / "Kick" silently did nothing on refusal.
InviteCodePanelignored the action result, so an expired session or a non-organizer cookie made the confirm dialog close with no visible effect. Refusals now render inline. - Demo modal header was a black smear in light mode. Its
bg-gradient-to-br … via-zinc-950gradient wasn't covered by the light-theme surface remap (which can't reach gradient stops). Tagged with the existing.sb-deep-surfacehelper so it swaps like the other dark gradient surfaces.
Added
- Stale battles archive themselves. A battle whose check-out day ended more than 7 days ago is moved to the trophy case on the next page load, exactly as the organizer's "close battle" does (listings, pins, participants, and trip dates cleared). Before, a trip that ended months ago blocked the whole one-battle-per-server instance until its organizer signed in with their PIN. Battles with no dates never go stale.
isStale()insrc/lib/battle.ts(unit-tested) + a sharedcloseCurrentBattle()inbattle-server.tsused by both paths (DB-level test).
Changed
- Demo discloses what is simulated. Sign-in screen gets a "Public demo" hint (anonymous visitors never saw the demo modal), the demo modal explains that badges / votes / comments are seed data, and the footer disclaimer has a demo-mode variant instead of claiming badges were "scraped from Airbnb's public page".
vitest.config.tsaliases theserver-onlymarker package so server-action modules can be imported in tests.
v0.4.1
Pre-public-flip hardening pass. The big change here is closing an IDOR across every server action — the prior model trusted client-supplied voter IDs, which would have been a 30-second exploit once the source was readable. This release derives identity from the server-read cookie and ignores the body parameter for auth purposes.
Security
- Closed an IDOR across all state-mutating server actions (
castVote,addComment,deleteComment,removeListing,kickParticipant,closeBattle,deletePastBattle,joinBattle,setBattleRequirements, etc.). Every action now derives identity fromreadVoterCookie()via three helpers (requireSelf/requireOrganizer/requireMember) and ignores the client-suppliedvoterIdfor auth. Verified end-to-end via Playwright: spoofing another voter's UUID returns"Sign in mismatch — please sign in again". - Voter cookie is now
httpOnly. XSS that exfils page state can no longer lift the session token. - Closed
deletePastBattleauth bypass. Previously when no active battle existed the check skipped entirely; now requires an active battle's organizer. - Rekeyed
joinBattlerate-limit. Per-voter throttle plus a per-battle ceiling so attackers rotating UUIDs can't brute the 6-char invite code. - CSP drops
unsafe-evalin production (was needed only for dev/Turbopack HMR).Strict-Transport-Securityis now sent in production with a 2-year max-age +includeSubDomains.upgrade-insecure-requestsadded. - GraphQL photo URLs are host-validated against an Airbnb CDN allowlist (
*.muscache.com,*.airbnbusercontent.com) before being written to the DB — closes a future XSS surface if the upstream response ever drifts. SECURITY.mdnow routes to GitHub Private Security Advisories instead of "email the maintainer (see git log)" — git-log addresses were noreply aliases that don't deliver inbound mail.
Added
- Full photo album pull via
HERO_DEFAULT+PHOTO_TOUR_SCROLLABLE_MODALsections in the existing GraphQL availability call. Typical jump from ~8 shots to 30-50, capped at 50 in the DB. docker.ymlpublishes both0.4.0andv0.4.0tags so theSTAYBATTLE_TAG=v0.4.0form documented ininstall.shactually resolves.
Changed
- Availability queue split-update. On fetch error, only
availability_statusis bumped tounknown+availability_checked_atis refreshed;price_display/amenities/cancellation_policy/unavailability_reasonare preserved. Previously a transient blip wiped cached metadata. scrape.tsthrows on!res.okinstead of returning an empty-but-valid shape, so the caller can surface "HTTP 403" to the user instead of silently saving a broken row.EMPTY_RESULTis now frozen + factory-wrapped. Two of three error paths inparseAvailabilityResponsewere returning the shared singleton by reference; downstreampushontoresult.amenities/result.photoswould have mutated subsequent "empty" results.joinBattleself-heals. Sets the voter cookie inside the action so a client with localStorage identity but no cookie (e.g. carried over from before the auth migration) no longer loops forever on JoinGate auto-fire.
Fixed
- DNS hygiene: deleted 5 Namecheap email-forwarding MX records + their orphan SPF (eliminates the registrar attribution leak), pointed
wwwCNAME at the apex (was Namecheap parking page), added strict DMARC (p=reject).