The homepage was a marketing brochure -- a hero paragraph, a "three
steps" explainer and a tag list. It described the gesture in prose
while the actual Tinder deck sat behind /deck/[jobId], reachable only
after signing in AND posting a job. Nobody opening the app ever saw
the product.
Now / renders a phone illustration with the real app running inside
it: the same <Deck>, the same drag physics, real verified pros. On a
phone the bezel collapses and the deck simply fills the viewport --
drawing a picture of a phone on a phone is absurd, and it would eat
the width the cards need.
- Removed the fixed app bar and bottom tab bar. AppShell now renders
the screen title as an in-flow h1; the bar owned the only h1 on
every screen, so dropping it silently would have left every page
headingless. The /jobs "post" action moved from bar chrome into the
content, since the tab bar was its only other route there.
- getShowcaseDeck(): a deck with no job behind it. getDeck is
job-scoped (joins jobs for category and location, anti-joins
swipes), which an anonymous visitor has none of, so this centres on
the launch city. Eligibility rules are copied verbatim -- nobody may
appear in the shop window who could not appear on a real deck.
- deck.showcase: the only public procedure in the router. list and
swipe stay behind clientProcedure. It reads nothing about the caller
and writes nothing, so a right swipe on the entry screen is purely
local. No real tradesperson is contacted until a job is posted.
- <Deck> filled a hardcoded 560px desktop box; it now fills its
container. The card counter moved out from between the two action
buttons so the thumb zone holds nothing but the two controls.
Verified against the seeded database: 22 eligible pros returned, and
all three seeded traps excluded for the right reason -- Pau Ribas
(22km out, 5km radius), Unverified Ulla (pending), Away Arnau (not
accepting). 7 new integration tests cover exactly that.
Also corrects a label I had written as "Plumbers near you" -- the
showcase deck is not category-filtered and shows every trade.
Includes concurrent edits to the mobile shell, ui/ primitives and
DESIGN.md made outside this session.
typecheck, lint, build clean; 141 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Acts on an adversarial review of the M1 auth and authorization code.
Five findings fixed; the rest recorded in SECURITY-FINDINGS.md as the
M1 exit criteria rather than left in a tool transcript.
- auth: serve /phone-number/request-password-reset, /phone-number/
reset-password and /sign-in/phone-number as 404. better-auth's
phoneNumber() registers all three unconditionally -- they are NOT
gated on emailAndPassword.enabled:false. Left live they form a
silent second credential path: request-password-reset stores an OTP
and sends no SMS (sendPasswordResetOTP was never configured, so the
owner is never told), reset-password mints a bcrypt credential row,
and sign-in/phone-number then accepts it forever with no OTP. The
OTP gate still applies, so this is not remote unauthenticated
takeover -- it converts one momentary OTP compromise into permanent
access the victim cannot see or rotate.
- auth: drop bearer(). It accepts the plaintext sessions.token column
as an Authorization credential, making any single leaked row a
replayable login. The mobile client it was added for is hypothetical.
- auth: pin E.164 via phoneNumberValidator, and add toE164/isE164 to
@linkder/shared. phone is UNIQUE and bans are per-account, so
"+34600111222" and "0034600111222" being separately storable meant
one handset could hold two accounts and a ban was escapable by
retyping. 15 tests.
- auth: NEXT_PUBLIC_APP_URL now throws in production instead of
falling back to localhost, which was silently dropping Secure and
the __Secure- prefix from the production session cookie.
- pro.upsertProfile: actually apply requiresReReview. It was computed,
returned to the client and never acted on, so a verified plumber
could become a verified electrician in another city by ignoring a
response flag. Now demotes to pending in the same transaction and
audits it. Trade changes count as material (they did not before) --
the licence is per-trade. Needed a verified -> pending edge in
VERIFICATION_GRAPH, which did not exist.
Removed two untracked scratch repro files. The impersonation repro
depended on bearer() for transport and no longer applies as written;
the underlying finding (resolveSession drops impersonatedBy, so admin
actions are audited as the victim) is open and documented.
typecheck, lint, build clean; 127 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Greenfield scaffold for Linkder, a swipe-to-hire marketplace for local
professional services.
- pnpm/turbo monorepo: apps/web, packages/{shared,db}
- Postgres 16 + PostGIS via docker compose (ports 5442/6389 to avoid
clashing with other local stacks)
- Drizzle schema, 23 tables, geography(Point,4326) with GiST indexes
- Domain core in packages/shared: integer-cent money, status transition
graphs, deck ranking weights, cancellation policy — 46 unit tests
- Deck query: filtering in Postgres on the GiST index, ranking in JS so
the weights stay tunable — 18 integration tests against a seeded DB
- Deterministic seed placing pros at known distances, including three
that must NOT appear on a deck (out of radius, unverified, away)
- Next.js 15 app shell with a working swipe deck
- CI: typecheck, lint, test, build against live postgres+redis
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>