3 Commits
Author SHA1 Message Date
serfowiandClaude Opus 5 c617bc9687 M1 security: close the password backdoor, apply re-review, pin E.164
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>
2026-08-21 01:19:32 -04:00
serfowiandClaude Opus 5 cebeda7f4c M1: authentication with better-auth, verified end to end
Switches from the planned Auth.js v5 to better-auth 1.7.1. The plan
assumed the blocker would be schema fit; it is not. @auth/drizzle-adapter
accepts our tables verbatim. What rules Auth.js out is that credentials
providers hardcode JWT and never call adapter.createSession, and the
config assertion that would catch it only fires when EVERY provider is
credentials — so adding Google suppresses the warning and the app ships
silently broken. Phone OTP with database sessions is not reachable there
without hand-building the whole OTP security layer.

Also corrects a premise: better-auth's drizzle-orm peer is declared
OPTIONAL, so no 0.38 -> 0.45 upgrade is forced. Verified on 0.38.4.

- auth schema rewritten to better-auth 1.7.1's own getSchema() output:
  sessions/accounts/verifications reshaped, emailVerified and
  phoneVerified are BOOLEAN (a timestamptz there fails 100% of signups),
  accounts.issuer added, phone_otps dropped. Ban state now comes from the
  admin plugin rather than a second bannedAt column.
- Session resolution is one file. Everything downstream is written
  against our own Session type, so the provider stays swappable.
- Ban enforcement lives in the resolver because Session carries no ban
  field and protectedProcedure promises a non-banned user.
- Phone OTP sign-in, Google, role selection, tRPC user router.
- Synthetic emails for phone-first users, with isSyntheticEmail() gating
  every future send. Pros must supply a real address; clients need not.
- Duplicate-account detection, since both signup routes stay open and
  nothing correlates a phone to a Google identity. Detects only — merging
  accounts that carry reviews and payments needs its own tooling.
- SMS sender refuses to fall back to console logging in production.
- declaration:false for the app, which is the actual fix for the TS2742
  wall from better-auth's transitive zod under pnpm.

Verified against a live server: OTP sent, code verified, uuid PK honoured,
database session written, and an authenticated tRPC call resolved. A
signed-in stranger gets NOT_FOUND on another client's deck; anonymous
gets UNAUTHORIZED.

124 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:40:23 -04:00
serfowiandClaude Opus 5 19623bcccb M0: foundation — monorepo, PostGIS schema, deck query, app shell
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>
2026-08-20 13:32:35 -04:00