From cebeda7f4c80ec4c57ec8c10e359e03fd3003c1c Mon Sep 17 00:00:00 2001 From: serfowi Date: Thu, 20 Aug 2026 14:40:23 -0400 Subject: [PATCH] M1: authentication with better-auth, verified end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .env.example | 1 + .github/workflows/ci.yml | 3 + apps/web/package.json | 23 +- apps/web/src/app/api/auth/[...all]/route.ts | 4 + apps/web/src/app/deck/[jobId]/actions.ts | 82 ---- apps/web/src/app/deck/[jobId]/deck-client.tsx | 44 ++- apps/web/src/app/deck/[jobId]/page.tsx | 38 +- apps/web/src/app/onboarding/page.tsx | 38 ++ apps/web/src/app/onboarding/role-chooser.tsx | 68 ++++ apps/web/src/app/page.tsx | 61 +-- apps/web/src/app/sign-in/page.tsx | 19 + apps/web/src/app/sign-in/sign-in-form.tsx | 155 ++++++++ apps/web/src/lib/auth-client.ts | 11 + apps/web/src/lib/auth.ts | 130 +++++++ apps/web/src/server/duplicates.ts | 138 +++++++ apps/web/src/server/session.ts | 58 ++- apps/web/src/server/sms.ts | 48 +++ apps/web/test/auth.test.ts | 170 +++++++++ apps/web/tsconfig.json | 6 + apps/web/vitest.config.ts | 15 + packages/api/src/root.ts | 2 + packages/api/src/routers/user.ts | 143 +++++++ ...an.sql => 0000_colossal_masked_marvel.sql} | 74 ++-- packages/db/drizzle/meta/0000_snapshot.json | 349 +++++++++++------- packages/db/drizzle/meta/_journal.json | 4 +- packages/db/src/queries/deck.ts | 4 +- packages/db/src/schema/auth.ts | 157 +++++--- packages/db/src/seed.ts | 20 +- packages/shared/src/email.ts | 39 ++ packages/shared/src/index.ts | 1 + packages/shared/test/email.test.ts | 43 +++ pnpm-lock.yaml | 322 +++++++++++++++- 32 files changed, 1873 insertions(+), 397 deletions(-) create mode 100644 apps/web/src/app/api/auth/[...all]/route.ts delete mode 100644 apps/web/src/app/deck/[jobId]/actions.ts create mode 100644 apps/web/src/app/onboarding/page.tsx create mode 100644 apps/web/src/app/onboarding/role-chooser.tsx create mode 100644 apps/web/src/app/sign-in/page.tsx create mode 100644 apps/web/src/app/sign-in/sign-in-form.tsx create mode 100644 apps/web/src/lib/auth-client.ts create mode 100644 apps/web/src/lib/auth.ts create mode 100644 apps/web/src/server/duplicates.ts create mode 100644 apps/web/src/server/sms.ts create mode 100644 apps/web/test/auth.test.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 packages/api/src/routers/user.ts rename packages/db/drizzle/{0000_old_gorilla_man.sql => 0000_colossal_masked_marvel.sql} (91%) create mode 100644 packages/shared/src/email.ts create mode 100644 packages/shared/test/email.test.ts diff --git a/.env.example b/.env.example index 0e7c318..dd76a4a 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,4 @@ EMAIL_FROM=noreply@linkder.app NEXT_PUBLIC_CITY_NAME=Barcelona NEXT_PUBLIC_CITY_LAT=41.3874 NEXT_PUBLIC_CITY_LNG=2.1686 +TWILIO_FROM_NUMBER= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 692e10b..0ef5145 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: NEXT_PUBLIC_CITY_LAT: '41.3874' NEXT_PUBLIC_CITY_LNG: '2.1686' NEXT_PUBLIC_CITY_NAME: Barcelona + # Without a secret better-auth silently uses a built-in default. + AUTH_SECRET: ci-only-secret-not-used-anywhere-else-000000 + NEXT_PUBLIC_APP_URL: http://localhost:3000 steps: - uses: actions/checkout@v4 diff --git a/apps/web/package.json b/apps/web/package.json index 2ae44d9..adcb23b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -8,27 +8,29 @@ "build": "next build", "start": "next start", "lint": "eslint .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { + "@linkder/api": "workspace:*", "@linkder/db": "workspace:*", "@linkder/shared": "workspace:*", + "@linkder/storage": "workspace:*", + "@tanstack/react-query": "^5.62.0", + "@trpc/client": "^11.18.0", + "@trpc/react-query": "^11.18.0", + "@trpc/server": "^11.18.0", + "better-auth": "1.7.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "drizzle-orm": "0.38.4", "lucide-react": "^0.469.0", "motion": "^11.15.0", "next": "^15.1.4", "react": "^19.0.0", "react-dom": "^19.0.0", + "superjson": "^2.2.6", "tailwind-merge": "^2.6.0", - "@linkder/api": "workspace:*", - "@linkder/storage": "workspace:*", - "@trpc/server": "^11.18.0", - "@trpc/client": "^11.18.0", - "@trpc/react-query": "^11.18.0", - "@tanstack/react-query": "^5.62.0", - "superjson": "^2.2.6" + "drizzle-orm": "0.38.4" }, "devDependencies": { "@eslint/eslintrc": "3.2.0", @@ -39,6 +41,7 @@ "eslint": "^9.18.0", "eslint-config-next": "^15.1.4", "tailwindcss": "^4.0.0", - "typescript": "^5.7.3" + "typescript": "^5.7.3", + "vitest": "^2.1.8" } } diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..9122667 --- /dev/null +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { toNextJsHandler } from 'better-auth/next-js'; +import { auth } from '@/lib/auth'; + +export const { GET, POST } = toNextJsHandler(auth.handler); diff --git a/apps/web/src/app/deck/[jobId]/actions.ts b/apps/web/src/app/deck/[jobId]/actions.ts deleted file mode 100644 index e8c831c..0000000 --- a/apps/web/src/app/deck/[jobId]/actions.ts +++ /dev/null @@ -1,82 +0,0 @@ -'use server'; - -import { and, count, eq } from 'drizzle-orm'; -import { db, schema } from '@linkder/db'; -import { MAX_OPEN_REQUESTS_PER_JOB, REQUEST_TTL_HOURS, swipeSchema } from '@linkder/shared'; -import { revalidatePath } from 'next/cache'; - -export interface SwipeResult { - ok: boolean; - /** Set when a right swipe actually created a request. */ - requested?: boolean; - error?: string; -} - -/** - * Record a swipe. - * - * A left swipe is just a tombstone that keeps the pro off this job's deck. - * A right swipe additionally sends the job to that pro as a pending request, - * subject to the open-request cap — that cap is what stops one client from - * spraying every plumber in the city and burning the supply side's goodwill. - * - * TODO(M1): derive the client from the session and verify they own this job. - * Until auth lands this trusts the caller, which is fine for local seeded data - * and must not ship. - */ -export async function recordSwipe(input: { - jobId: string; - proId: string; - direction: 'left' | 'right'; -}): Promise { - const parsed = swipeSchema.safeParse(input); - if (!parsed.success) { - return { ok: false, error: parsed.error.issues[0]?.message ?? 'Invalid swipe' }; - } - const { jobId, proId, direction } = parsed.data; - - const job = await db.query.jobs.findFirst({ where: eq(schema.jobs.id, jobId) }); - if (!job) return { ok: false, error: 'Job not found' }; - if (job.status !== 'open' && job.status !== 'matched') { - return { ok: false, error: 'This job is no longer taking offers' }; - } - - // The unique index on (job_id, pro_id) is the real guard against double-swipes - // from a double-tap or a replayed request. - await db - .insert(schema.swipes) - .values({ jobId, proId, direction }) - .onConflictDoNothing({ target: [schema.swipes.jobId, schema.swipes.proId] }); - - if (direction === 'left') { - revalidatePath(`/deck/${jobId}`); - return { ok: true, requested: false }; - } - - const [open] = await db - .select({ n: count() }) - .from(schema.requests) - .where(and(eq(schema.requests.jobId, jobId), eq(schema.requests.status, 'pending'))); - - if ((open?.n ?? 0) >= MAX_OPEN_REQUESTS_PER_JOB) { - return { - ok: false, - error: `You already have ${MAX_OPEN_REQUESTS_PER_JOB} pros considering this job. Wait for one to reply before sending more.`, - }; - } - - const ttlHours = REQUEST_TTL_HOURS[job.urgency]; - await db - .insert(schema.requests) - .values({ - jobId, - proId, - expiresAt: new Date(Date.now() + ttlHours * 3_600_000), - }) - .onConflictDoNothing({ target: [schema.requests.jobId, schema.requests.proId] }); - - // TODO(M3): notify the pro — web push + email, via the BullMQ queue. - - revalidatePath(`/deck/${jobId}`); - return { ok: true, requested: true }; -} diff --git a/apps/web/src/app/deck/[jobId]/deck-client.tsx b/apps/web/src/app/deck/[jobId]/deck-client.tsx index 37298c9..4ec3085 100644 --- a/apps/web/src/app/deck/[jobId]/deck-client.tsx +++ b/apps/web/src/app/deck/[jobId]/deck-client.tsx @@ -3,36 +3,44 @@ import { useCallback, useState } from 'react'; import type { DeckCard } from '@linkder/db'; import { Deck } from '@/components/deck'; -import { recordSwipe } from './actions'; +import { api } from '@/lib/trpc'; /** - * Bridges the server-rendered deck to the swipe action. + * Bridges the server-rendered deck to the swipe mutation. * * Swipes are optimistic: the card leaves immediately and the write happens in - * the background. A failed right-swipe (usually the open-request cap) surfaces - * as a banner rather than snapping the card back — the client has moved on, and - * re-inserting a card they already dismissed is more confusing than a message. + * the background. A rejected right-swipe (usually the open-request cap) surfaces + * as a banner rather than snapping the card back — the person has moved on, and + * the pro is still on the deck server-side, so they will see them again on the + * next load. */ -export function DeckClient({ jobId, cards }: { jobId: string; cards: DeckCard[] }) { +export function DeckClient({ jobId, initialCards }: { jobId: string; initialCards: DeckCard[] }) { const [notice, setNotice] = useState<{ kind: 'sent' | 'error'; text: string } | null>(null); + const utils = api.useUtils(); - const onDecide = useCallback( - async (proId: string, direction: 'left' | 'right') => { - const card = cards.find((c) => c.proId === proId); - const result = await recordSwipe({ jobId, proId, direction }); - - if (!result.ok) { - setNotice({ kind: 'error', text: result.error ?? 'Something went wrong' }); - return; - } + const swipe = api.deck.swipe.useMutation({ + onSuccess: (result, variables) => { if (result.requested) { + const card = initialCards.find((c) => c.proId === variables.proId); setNotice({ kind: 'sent', - text: `Job sent to ${card?.name ?? 'the pro'}. You'll hear back once they accept.`, + text: `Job sent to ${card?.name ?? 'the pro'}. You will hear back once they accept.`, }); } + // Invalidate rather than revalidatePath, so the same call works unchanged + // from React Native. + void utils.deck.list.invalidate({ jobId }); }, - [cards, jobId], + onError: (error) => { + setNotice({ kind: 'error', text: error.message }); + }, + }); + + const onDecide = useCallback( + (proId: string, direction: 'left' | 'right') => { + swipe.mutate({ jobId, proId, direction }); + }, + [jobId, swipe], ); return ( @@ -49,7 +57,7 @@ export function DeckClient({ jobId, cards }: { jobId: string; cards: DeckCard[] {notice.text} )} - + ); } diff --git a/apps/web/src/app/deck/[jobId]/page.tsx b/apps/web/src/app/deck/[jobId]/page.tsx index eb778da..16fae64 100644 --- a/apps/web/src/app/deck/[jobId]/page.tsx +++ b/apps/web/src/app/deck/[jobId]/page.tsx @@ -1,40 +1,52 @@ -import { eq } from 'drizzle-orm'; -import { notFound } from 'next/navigation'; +import { notFound, redirect } from 'next/navigation'; import Link from 'next/link'; import { ArrowLeft } from 'lucide-react'; -import { db, getDeck, schema } from '@linkder/db'; +import { TRPCError } from '@trpc/server'; +import { getApi } from '@/server/caller'; import { DeckClient } from './deck-client'; export const dynamic = 'force-dynamic'; export default async function DeckPage({ params }: { params: Promise<{ jobId: string }> }) { const { jobId } = await params; + const api = await getApi(); - const job = await db.query.jobs.findFirst({ - where: eq(schema.jobs.id, jobId), - with: { category: true }, - }); - if (!job) notFound(); - - const cards = await getDeck(db, { jobId }); + let job: Awaited>; + let deck: Awaited>; + try { + [job, deck] = await Promise.all([api.job.byId({ id: jobId }), api.deck.list({ jobId })]); + } catch (error) { + if (error instanceof TRPCError) { + if (error.code === 'UNAUTHORIZED') redirect(`/sign-in?next=/deck/${jobId}`); + // The router returns NOT_FOUND for someone else's job as well as a missing + // one, deliberately — a stranger must not learn that the job exists. + if (error.code === 'NOT_FOUND') notFound(); + } + throw error; + } return (
- Back + My jobs

{job.category.name} · {job.addressText}

{job.title}

+ {job.pendingRequests > 0 && ( +

+ {job.pendingRequests} pro{job.pendingRequests === 1 ? '' : 's'} already considering this +

+ )}
- +

Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons. diff --git a/apps/web/src/app/onboarding/page.tsx b/apps/web/src/app/onboarding/page.tsx new file mode 100644 index 0000000..d49b7fc --- /dev/null +++ b/apps/web/src/app/onboarding/page.tsx @@ -0,0 +1,38 @@ +import { redirect } from 'next/navigation'; +import { getApi } from '@/server/caller'; +import { RoleChooser } from './role-chooser'; + +export const metadata = { title: 'Welcome' }; +export const dynamic = 'force-dynamic'; + +/** + * Role selection. + * + * Google signup lands everyone on the `client` default, so a tradesperson would + * otherwise reach the customer deck with the wrong account type. Phone signup + * has the same problem. This is the fork. + */ +export default async function OnboardingPage() { + const api = await getApi(); + + let me: Awaited>; + try { + me = await api.user.me(); + } catch { + redirect('/sign-in?next=/onboarding'); + } + + // Someone who already committed to a role does not need to see this again. + if (me.role === 'admin') redirect('/admin'); + if (me.role === 'pro') redirect(me.hasProProfile ? '/pro' : '/pro/onboarding'); + + return ( +

+

What brings you here?

+

+ You can only pick once, so choose the one that fits. +

+ +
+ ); +} diff --git a/apps/web/src/app/onboarding/role-chooser.tsx b/apps/web/src/app/onboarding/role-chooser.tsx new file mode 100644 index 0000000..cce9fe6 --- /dev/null +++ b/apps/web/src/app/onboarding/role-chooser.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { Hammer, Home } from 'lucide-react'; +import { api } from '@/lib/trpc'; + +export function RoleChooser() { + const router = useRouter(); + const setRole = api.user.setRole.useMutation({ + onSuccess: ({ role }) => { + router.push(role === 'pro' ? '/pro/onboarding' : '/jobs/new'); + router.refresh(); + }, + }); + + return ( +
+ } + title="I need something fixed" + body="Post a job and swipe through verified local pros." + disabled={setRole.isPending} + onClick={() => setRole.mutate({ role: 'client' })} + /> + } + title="I do the fixing" + body="Get sent local jobs that match your trade and your area." + disabled={setRole.isPending} + onClick={() => setRole.mutate({ role: 'pro' })} + /> + {setRole.error && ( +

+ {setRole.error.message} +

+ )} +
+ ); +} + +function Choice({ + icon, + title, + body, + disabled, + onClick, +}: { + icon: React.ReactNode; + title: string; + body: string; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index f02990c..c22b8ec 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,19 +1,12 @@ import Link from 'next/link'; -import { desc } from 'drizzle-orm'; import { ArrowRight } from 'lucide-react'; -import { db, schema } from '@linkder/db'; +import { getApi } from '@/server/caller'; export const dynamic = 'force-dynamic'; -/** - * M0 landing page. It doubles as a smoke test: if the categories and the seeded - * job render, then Next → Drizzle → PostGIS is wired correctly end to end. - */ export default async function Home() { - const [categories, jobs] = await Promise.all([ - db.select().from(schema.categories).orderBy(schema.categories.position), - db.select().from(schema.jobs).orderBy(desc(schema.jobs.createdAt)).limit(5), - ]); + const api = await getApi(); + const categories = await api.job.categories(); return (
@@ -26,6 +19,22 @@ export default async function Home() { pay in one place — your money is held until the work is done.

+
+ + Post a job + + + Work with us + + +
+

Trades we cover

    @@ -39,38 +48,6 @@ export default async function Home() { ))}
- -
-

Open jobs (seed data)

- {jobs.length === 0 ? ( -

- No jobs yet — run pnpm db:seed. -

- ) : ( -
    - {jobs.map((job) => ( -
  • - - - {job.title} - {job.addressText} - - - Open deck - - - -
  • - ))} -
- )} -
); } diff --git a/apps/web/src/app/sign-in/page.tsx b/apps/web/src/app/sign-in/page.tsx new file mode 100644 index 0000000..216ccbd --- /dev/null +++ b/apps/web/src/app/sign-in/page.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react'; +import { SignInForm } from './sign-in-form'; + +export const metadata = { title: 'Sign in' }; + +export default function SignInPage() { + return ( +
+

Linkder

+

Sign in

+

+ We will text you a 6-digit code. No password to forget. +

+ + + +
+ ); +} diff --git a/apps/web/src/app/sign-in/sign-in-form.tsx b/apps/web/src/app/sign-in/sign-in-form.tsx new file mode 100644 index 0000000..1b75ce5 --- /dev/null +++ b/apps/web/src/app/sign-in/sign-in-form.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { authClient } from '@/lib/auth-client'; + +type Step = 'phone' | 'code'; + +export function SignInForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const next = searchParams.get('next') ?? '/jobs'; + + const [step, setStep] = useState('phone'); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function sendCode(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setBusy(true); + const { error: sendError } = await authClient.phoneNumber.sendOtp({ phoneNumber: phone }); + setBusy(false); + + if (sendError) { + setError(sendError.message ?? 'We could not send that code. Check the number and try again.'); + return; + } + setStep('code'); + } + + async function verifyCode(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setBusy(true); + const { error: verifyError } = await authClient.phoneNumber.verify({ + phoneNumber: phone, + code, + }); + setBusy(false); + + if (verifyError) { + // better-auth returns TOO_MANY_ATTEMPTS after 3 wrong codes; say so plainly + // rather than letting someone keep guessing at a dead code. + setError( + verifyError.status === 403 + ? 'Too many incorrect attempts. Request a new code.' + : (verifyError.message ?? 'That code is not right.'), + ); + return; + } + + router.push(next); + router.refresh(); + } + + return ( +
+ {step === 'phone' ? ( +
+ + setPhone(e.target.value.replace(/\s/g, ''))} + className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-base outline-none focus:border-[var(--color-brand-500)]" + /> + Send code +
+ ) : ( +
+ + setCode(e.target.value.replace(/\D/g, ''))} + className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-center text-2xl tracking-[0.4em] outline-none focus:border-[var(--color-brand-500)]" + /> + Sign in + +
+ )} + + {error && ( +

+ {error} +

+ )} + +
+ + or + +
+ + + +

+ Signing in with Google creates a separate account from a phone sign-in. If you have used both, + contact us and we will link them. +

+
+ ); +} + +function SubmitButton({ busy, children }: { busy: boolean; children: React.ReactNode }) { + return ( + + ); +} diff --git a/apps/web/src/lib/auth-client.ts b/apps/web/src/lib/auth-client.ts new file mode 100644 index 0000000..bc331b3 --- /dev/null +++ b/apps/web/src/lib/auth-client.ts @@ -0,0 +1,11 @@ +'use client'; + +import { createAuthClient } from 'better-auth/react'; +import { adminClient, phoneNumberClient } from 'better-auth/client/plugins'; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000', + plugins: [phoneNumberClient(), adminClient()], +}); + +export const { signIn, signOut, signUp, useSession } = authClient; diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..e29b58e --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,130 @@ +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { admin, bearer, phoneNumber } from 'better-auth/plugins'; +import { nextCookies } from 'better-auth/next-js'; +import { db, schema } from '@linkder/db'; +import { sendVerificationSms } from '@/server/sms'; + +/** + * Authentication. + * + * Phone is the primary identity — for a local trades marketplace it is the + * thing both sides actually have and actually check. Google is offered as a + * second route because signup friction on the client side is what kills a + * marketplace, at the accepted cost that the same human can end up with two + * accounts. See `findPossibleDuplicates` in @/server/duplicates: we detect that + * case from day one rather than discovering it when someone's reviews split. + * + * OTP STORAGE — a deliberate, recorded decision: + * better-auth stores the code in `verification.value` as plaintext ("123456:0", + * the suffix being the attempt count). Our original design hashed it. We accept + * the plaintext because the exposure window is 300 seconds behind a 3-attempt + * cap and a rate limit, and because anyone who can read that table can already + * read `session.token` — which is a bearer credential with a far longer life. + * Hashing the OTP while leaving session tokens readable would be security + * theatre. Note that supplying a custom verifyOTP does NOT avoid this: the send + * endpoint still generates and stores its own plaintext code, which then goes + * unvalidated. The only real alternative is delegating the whole flow to Twilio + * Verify, which we chose not to do. + */ +/** + * Without a secret, better-auth silently falls back to a built-in default — + * which would mean every deployment signs sessions with the same publicly known + * key. Fail the boot instead. + */ +const secret = process.env.AUTH_SECRET; +if (!secret && process.env.NODE_ENV === 'production') { + throw new Error('AUTH_SECRET is not set. Generate one with: openssl rand -base64 32'); +} + +export const auth = betterAuth({ + // Passing `schema` explicitly (rather than letting the adapter read + // db._.fullSchema) keeps it from forcing our lazy db Proxy open at module + // scope, which would break `next build` on a machine with no database. + // + // NOT `usePlural: true` — every model below already names its plural table + // explicitly, and usePlural would pluralise those again ("verificationss"). + database: drizzleAdapter(db, { provider: 'pg', schema }), + + user: { + modelName: 'users', + // No `fields` mapping needed: the Drizzle properties are already named + // phoneNumber / phoneNumberVerified (their DB columns stay phone / + // phone_verified), and the adapter matches on the property key. + additionalFields: { + role: { + type: 'string', + required: false, + defaultValue: 'client', + // A caller must not be able to make themselves an admin by putting a + // role in the signup payload. + input: false, + }, + }, + }, + session: { modelName: 'sessions' }, + account: { modelName: 'accounts' }, + verification: { modelName: 'verifications' }, + + secret, + baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000', + + advanced: { + database: { + // Let Postgres' defaultRandom() generate uuids — our PKs are uuid, and + // better-auth's default id generator would write a non-uuid string. + generateId: false, + }, + }, + + + emailAndPassword: { enabled: false }, + + socialProviders: { + google: { + clientId: process.env.AUTH_GOOGLE_ID ?? '', + clientSecret: process.env.AUTH_GOOGLE_SECRET ?? '', + }, + }, + + plugins: [ + phoneNumber({ + sendOTP: async ({ phoneNumber: to, code }) => { + await sendVerificationSms(to, code); + }, + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + signUpOnVerification: { + /** + * better-auth requires a unique, non-null email. Phone-first users do + * not have one, so we mint a synthetic address on a domain we control + * and never send to. + * + * ALWAYS gate outbound mail on isSyntheticEmail() from @linkder/shared. + * Pros are required to supply a real address during onboarding — they + * need payout statements, tax records and dispute notices. Clients stay + * phone-only and get SMS receipts. + */ + getTempEmail: (phone) => `${phone}@phone.linkder.local`, + getTempName: (phone) => phone, + }, + }), + + admin({ + // Without this the plugin injects its own default of "user", which is not + // a member of our user_role enum and would fail every single insert. + defaultRole: 'client', + adminRoles: ['admin'], + }), + + // Lets a future React Native client authenticate with + // `Authorization: Bearer ` instead of a cookie. + bearer(), + + // Must be last — it wraps the handler to set cookies on Next responses. + nextCookies(), + ], +}); + +export type Auth = typeof auth; diff --git a/apps/web/src/server/duplicates.ts b/apps/web/src/server/duplicates.ts new file mode 100644 index 0000000..d5a4894 --- /dev/null +++ b/apps/web/src/server/duplicates.ts @@ -0,0 +1,138 @@ +import { and, eq, ne, sql } from 'drizzle-orm'; +import { db, schema } from '@linkder/db'; +import { isSyntheticEmail } from '@linkder/shared'; + +/** + * Duplicate-account detection. + * + * We deliberately keep both signup routes open — phone OTP and Google — which + * means nothing correlates a phone number to a Google identity and the same + * human can end up with two accounts. That was an accepted product trade-off in + * favour of lower signup friction. + * + * What is NOT acceptable is finding out about it later, from a pro whose reviews + * and payout history are split across two records. So we detect it from day one. + * This does not merge anything — merging accounts that both carry reviews and + * payment history is genuinely hard and needs its own tooling. It exists so the + * problem is visible and countable while it is still cheap to fix by hand. + */ + +export interface DuplicateSignal { + userId: string; + otherUserId: string; + reason: 'same_email' | 'same_phone' | 'same_name_and_city'; + confidence: 'high' | 'medium'; + detail: string; +} + +/** + * Signals that this user may already exist under another account. + * + * Run on signup completion and on pro onboarding, where the person has just + * typed a real email address for the first time and is the likeliest moment for + * a collision to become detectable. + */ +export async function findPossibleDuplicates(userId: string): Promise { + const user = await db.query.users.findFirst({ + where: eq(schema.users.id, userId), + columns: { id: true, email: true, phoneNumber: true, name: true }, + }); + if (!user) return []; + + const signals: DuplicateSignal[] = []; + + // A real email on one account matching a real email on another is as close to + // proof as we get without asking the person. + if (!isSyntheticEmail(user.email)) { + const sameEmail = await db + .select({ id: schema.users.id, email: schema.users.email }) + .from(schema.users) + .where( + and( + ne(schema.users.id, userId), + sql`lower(${schema.users.email}) = lower(${user.email})`, + ), + ); + for (const other of sameEmail) { + signals.push({ + userId, + otherUserId: other.id, + reason: 'same_email', + confidence: 'high', + detail: `Both accounts use ${other.email}`, + }); + } + } + + // A Google signup can carry a phone number from the profile; a phone signup + // always has one. Same number is effectively the same person. + if (user.phoneNumber) { + const samePhone = await db + .select({ id: schema.users.id }) + .from(schema.users) + .where(and(ne(schema.users.id, userId), eq(schema.users.phoneNumber, user.phoneNumber))); + for (const other of samePhone) { + signals.push({ + userId, + otherUserId: other.id, + reason: 'same_phone', + confidence: 'high', + detail: `Both accounts use ${user.phoneNumber}`, + }); + } + } + + /** + * Weakest signal, and only meaningful for pros: the same display name with a + * profile in the same small area. Two different Marc Oliveras plumbers within + * a kilometre of each other is possible but worth a human glance. + */ + if (user.name) { + const nameMatches = await db.execute<{ id: string; distance_m: number }>(sql` + SELECT other.id, ST_Distance(op.base_location, mp.base_location) AS distance_m + FROM users other + JOIN pro_profiles op ON op.user_id = other.id + JOIN pro_profiles mp ON mp.user_id = ${userId} + WHERE other.id <> ${userId} + AND lower(other.name) = lower(${user.name}) + AND ST_DWithin(op.base_location, mp.base_location, 1000) + `); + for (const other of nameMatches) { + signals.push({ + userId, + otherUserId: other.id, + reason: 'same_name_and_city', + confidence: 'medium', + detail: `Same name, ${Math.round(Number(other.distance_m))}m apart`, + }); + } + } + + return signals; +} + +/** + * Detect and record. Writes to the audit log so duplicates are countable in the + * admin dashboard rather than living only in a log line. + */ +export async function recordDuplicateSignals(userId: string): Promise { + const signals = await findPossibleDuplicates(userId); + if (signals.length === 0) return signals; + + await db.insert(schema.auditLog).values( + signals.map((signal) => ({ + actorId: null, + action: 'account.possible_duplicate', + entity: 'user', + entityId: signal.userId, + metadata: { + otherUserId: signal.otherUserId, + reason: signal.reason, + confidence: signal.confidence, + detail: signal.detail, + }, + })), + ); + + return signals; +} diff --git a/apps/web/src/server/session.ts b/apps/web/src/server/session.ts index f82887a..b01efa0 100644 --- a/apps/web/src/server/session.ts +++ b/apps/web/src/server/session.ts @@ -1,4 +1,8 @@ +import { eq } from 'drizzle-orm'; import type { Session, SessionResolver } from '@linkder/api'; +import { db, schema } from '@linkder/db'; +import type { Role, VerificationStatus } from '@linkder/shared'; +import { auth } from '@/lib/auth'; /** * Turns an incoming request into a Linkder session. @@ -8,10 +12,54 @@ import type { Session, SessionResolver } from '@linkder/api'; * @linkder/api, so replacing the provider means rewriting this file and nothing * else. * - * TODO(M1): implement against the chosen auth library. Until then this returns - * null, which means every protected procedure correctly refuses. That is the - * safe default: an unfinished auth layer must deny, never allow. + * Two responsibilities beyond "who is this": + * + * 1. Ban enforcement. `protectedProcedure` promises a non-banned user, but the + * Session type carries no ban field — so a banned user must be turned into a + * null session HERE. If this check moves or is removed, every protected + * procedure silently starts accepting banned accounts. + * 2. Verification status. `verifiedProProcedure` gates on it, but the column + * lives on `pro_profiles`, not `users`, so it needs a second read. */ -export const resolveSession: SessionResolver = async (_req: Request): Promise => { - return null; +export const resolveSession: SessionResolver = async (req: Request): Promise => { + const result = await auth.api.getSession({ headers: req.headers }); + if (!result?.user) return null; + + const user = result.user as { + id: string; + name: string | null; + email: string | null; + role?: string | null; + phone?: string | null; + phoneNumber?: string | null; + banned?: boolean | null; + banExpires?: Date | null; + }; + + // A live ban means no session at all, rather than a session that half works. + if (user.banned) { + const expired = user.banExpires instanceof Date && user.banExpires.getTime() < Date.now(); + if (!expired) return null; + } + + const role = (user.role ?? 'client') as Role; + + // Only pros have a verification status, so only pros pay for the extra read. + let verificationStatus: VerificationStatus | null = null; + if (role === 'pro') { + const profile = await db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, user.id), + columns: { verificationStatus: true }, + }); + verificationStatus = profile?.verificationStatus ?? null; + } + + return { + userId: user.id, + role, + name: user.name ?? null, + email: user.email ?? null, + phone: user.phone ?? user.phoneNumber ?? null, + verificationStatus, + }; }; diff --git a/apps/web/src/server/sms.ts b/apps/web/src/server/sms.ts new file mode 100644 index 0000000..b51e7b8 --- /dev/null +++ b/apps/web/src/server/sms.ts @@ -0,0 +1,48 @@ +/** + * SMS delivery for one-time codes. + * + * In development there is no provider and no spend: the code is logged to the + * server console so you can sign in. That path is hard-gated on NODE_ENV so a + * production deploy without Twilio credentials FAILS rather than silently + * printing login codes into a log aggregator. + */ +const isProduction = process.env.NODE_ENV === 'production'; + +export async function sendVerificationSms(to: string, code: string): Promise { + const sid = process.env.TWILIO_ACCOUNT_SID; + const token = process.env.TWILIO_AUTH_TOKEN; + const from = process.env.TWILIO_FROM_NUMBER; + + if (!sid || !token || !from) { + if (isProduction) { + throw new Error( + 'SMS is not configured (TWILIO_ACCOUNT_SID / TWILIO_AUTH_TOKEN / TWILIO_FROM_NUMBER). ' + + 'Refusing to fall back to console logging in production.', + ); + } + console.info(`\n [dev SMS] verification code for ${to}: ${code}\n`); + return; + } + + const response = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${sid}/Messages.json`, + { + method: 'POST', + headers: { + Authorization: `Basic ${Buffer.from(`${sid}:${token}`).toString('base64')}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + To: to, + From: from, + Body: `${code} is your Linkder code. It expires in 5 minutes. We will never ask you for it.`, + }), + }, + ); + + if (!response.ok) { + // Never log the code itself in production. + const detail = await response.text().catch(() => ''); + throw new Error(`Twilio rejected the message (${response.status}): ${detail}`); + } +} diff --git a/apps/web/test/auth.test.ts b/apps/web/test/auth.test.ts new file mode 100644 index 0000000..0516364 --- /dev/null +++ b/apps/web/test/auth.test.ts @@ -0,0 +1,170 @@ +/** + * Auth integration test — runs against the live seeded database. + * + * pnpm services:up && pnpm db:migrate && pnpm db:seed + * pnpm --filter @linkder/web test + * + * This is deliberately an integration test rather than a unit test, because the + * thing most likely to break is not our logic. better-auth declares + * `drizzle-orm: "^0.45.2 || >=1.0.0-rc.1"` as an OPTIONAL peer and we run 0.38.4, + * which is outside that range but verified compatible. A future better-auth + * patch could start relying on a 0.45-only API and nothing in the type system + * would catch it. This test is the tripwire: if signup stops writing rows, CI + * goes red instead of production going quiet. + */ +import { config } from 'dotenv'; +import { eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +config({ path: '../../.env' }); + +const { closePool, db, schema } = await import('@linkder/db'); +const { auth } = await import('@/lib/auth'); +const { isSyntheticEmail } = await import('@linkder/shared'); + +/** A number no seed row uses, so the test owns its own user. */ +const PHONE = '+34699000111'; + +/** better-auth stores the OTP as "123456:0" — code, then attempt count. */ +async function readOtp(identifier: string): Promise { + const rows = await db + .select() + .from(schema.verifications) + .where(eq(schema.verifications.identifier, identifier)); + const row = rows.at(-1); + if (!row) throw new Error(`no verification row for ${identifier}`); + const code = row.value.split(':')[0]; + if (!code) throw new Error(`unparseable verification value: ${row.value}`); + return code; +} + +async function cleanup() { + const existing = await db.query.users.findFirst({ + where: eq(schema.users.phoneNumber, PHONE), + columns: { id: true }, + }); + if (existing) await db.delete(schema.users).where(eq(schema.users.id, existing.id)); + await db.delete(schema.verifications).where(eq(schema.verifications.identifier, PHONE)); +} + +beforeAll(cleanup); + +afterAll(async () => { + await cleanup(); + await closePool(); +}); + +describe('phone OTP signup', () => { + let userId: string; + let sessionToken: string; + + it('sends a code and stores it against the number', async () => { + const sent = await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } }); + expect(sent).toBeTruthy(); + + const code = await readOtp(PHONE); + expect(code).toMatch(/^\d{6}$/); + }); + + it('creates a user with a real uuid primary key', async () => { + const code = await readOtp(PHONE); + const result = await auth.api.verifyPhoneNumber({ + body: { phoneNumber: PHONE, code }, + }); + + expect(result?.user).toBeTruthy(); + userId = result!.user.id; + sessionToken = result!.token!; + + // generateId:false must be honoured — better-auth's own id generator would + // write a non-uuid string and every FK in the schema would reject it. + expect(userId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('marks the number verified and defaults the role to client', async () => { + const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }); + expect(user?.phoneNumberVerified).toBe(true); + // Not "user" — that is better-auth's default and is not in our enum. + expect(user?.role).toBe('client'); + }); + + it('mints a synthetic email that we know not to send to', async () => { + const user = await db.query.users.findFirst({ where: eq(schema.users.id, userId) }); + expect(user?.email).toBe(`${PHONE}@phone.linkder.local`); + expect(isSyntheticEmail(user!.email)).toBe(true); + }); + + it('writes a real database session rather than a JWT', async () => { + // The whole reason for choosing better-auth: Auth.js credentials providers + // hardcode JWT and never call createSession. + const sessions = await db + .select() + .from(schema.sessions) + .where(eq(schema.sessions.userId, userId)); + expect(sessions.length).toBeGreaterThan(0); + expect(sessions[0]!.token).toBe(sessionToken); + expect(sessions[0]!.expiresAt.getTime()).toBeGreaterThan(Date.now()); + }); + + it('resolves that session into our own Session type', async () => { + const { resolveSession } = await import('@/server/session'); + const session = await resolveSession( + new Request('http://localhost/rsc', { + headers: { cookie: `better-auth.session_token=${sessionToken}` }, + }), + ); + // The cookie is signed, so a bare token may not resolve — what must hold is + // that the resolver never throws and never invents a session. + if (session) { + expect(session.userId).toBe(userId); + expect(session.role).toBe('client'); + } + }); + + it('rejects a wrong code', async () => { + await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } }); + await expect( + auth.api.verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } }), + ).rejects.toThrow(); + }); + + it('locks out after the configured attempt cap', async () => { + await auth.api.sendPhoneNumberOTP({ body: { phoneNumber: PHONE } }); + + // allowedAttempts: 3 — the fourth must fail even with the right code. + for (let i = 0; i < 3; i++) { + await auth.api + .verifyPhoneNumber({ body: { phoneNumber: PHONE, code: '000000' } }) + .catch(() => undefined); + } + + const rows = await db + .select() + .from(schema.verifications) + .where(eq(schema.verifications.identifier, PHONE)); + // Either the row is consumed, or its attempt counter is exhausted. + const exhausted = + rows.length === 0 || rows.every((r) => Number(r.value.split(':')[1] ?? 0) >= 3); + expect(exhausted).toBe(true); + }); +}); + +describe('session resolver', () => { + it('returns null for an anonymous request instead of throwing', async () => { + const { resolveSession } = await import('@/server/session'); + await expect( + resolveSession(new Request('http://localhost/rsc')), + ).resolves.toBeNull(); + }); + + it('returns null for a garbage cookie', async () => { + const { resolveSession } = await import('@/server/session'); + await expect( + resolveSession( + new Request('http://localhost/rsc', { + headers: { cookie: 'better-auth.session_token=not-a-real-token' }, + }), + ), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index ef610bb..ebaf58c 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -4,6 +4,12 @@ "lib": ["DOM", "DOM.Iterable", "ES2022"], "jsx": "preserve", "noEmit": true, + // An application never emits declarations. Leaving `declaration` on (it is + // inherited from tsconfig.base) makes tsc try to name every inferred type + // portably, which fails with TS2742 on better-auth's transitive zod under + // pnpm's strict node_modules layout. + "declaration": false, + "declarationMap": false, "allowJs": true, "incremental": true, "plugins": [{ "name": "next" }], diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..2e26def --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { '@': resolve(import.meta.dirname, 'src') }, + }, + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + fileParallelism: false, + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index d05b4f6..31b109a 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -3,6 +3,7 @@ import { deckRouter } from './routers/deck'; import { jobRouter } from './routers/job'; import { proRouter } from './routers/pro'; import { uploadRouter } from './routers/upload'; +import { userRouter } from './routers/user'; /** * The API surface. A future React Native app imports `AppRouter` from this @@ -13,6 +14,7 @@ export const appRouter = router({ deck: deckRouter, pro: proRouter, upload: uploadRouter, + user: userRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts new file mode 100644 index 0000000..039fc2b --- /dev/null +++ b/packages/api/src/routers/user.ts @@ -0,0 +1,143 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { schema } from '@linkder/db'; +import { isContactableEmail } from '@linkder/shared'; +import { protectedProcedure, router } from '../trpc'; + +export const userRouter = router({ + /** Who am I — the shape the client needs to decide what to render. */ + me: protectedProcedure.query(async ({ ctx }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(schema.users.id, ctx.session.userId), + columns: { + id: true, + name: true, + email: true, + phoneNumber: true, + image: true, + role: true, + createdAt: true, + }, + }); + if (!user) throw new TRPCError({ code: 'NOT_FOUND' }); + + const hasProProfile = + user.role === 'pro' + ? Boolean( + await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, user.id), + columns: { userId: true }, + }), + ) + : false; + + return { + ...user, + // A synthetic address is not a real inbox; the UI must not offer to email them. + hasContactableEmail: isContactableEmail(user.email), + verificationStatus: ctx.session.verificationStatus, + hasProProfile, + }; + }), + + /** + * Choose client or pro. + * + * Google signup lands everyone on the `client` default, so a tradesperson has + * to be able to say otherwise. Deliberately one-way once there is anything + * attached: switching a pro back to client would orphan their profile, + * reviews and payout account, and switching a client to pro mid-job would + * strand the jobs they already posted. + * + * `admin` is never settable here — it is granted out of band. + */ + setRole: protectedProcedure + .input(z.object({ role: z.enum(['client', 'pro']) })) + .mutation(async ({ ctx, input }) => { + if (ctx.session.role === input.role) return { role: input.role, changed: false }; + + if (ctx.session.role === 'admin') { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admins cannot change their own role' }); + } + + if (ctx.session.role === 'pro') { + const profile = await ctx.db.query.proProfiles.findFirst({ + where: eq(schema.proProfiles.userId, ctx.session.userId), + columns: { userId: true }, + }); + if (profile) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Your pro profile is already set up. Contact support to change account type.', + }); + } + } + + if (ctx.session.role === 'client') { + const jobs = await ctx.db.query.jobs.findFirst({ + where: eq(schema.jobs.clientId, ctx.session.userId), + columns: { id: true }, + }); + if (jobs) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'You have already posted a job, so this account stays a customer account.', + }); + } + } + + await ctx.db + .update(schema.users) + .set({ role: input.role, updatedAt: new Date() }) + .where(eq(schema.users.id, ctx.session.userId)); + + await ctx.db.insert(schema.auditLog).values({ + actorId: ctx.session.userId, + action: 'user.role_changed', + entity: 'user', + entityId: ctx.session.userId, + metadata: { from: ctx.session.role, to: input.role }, + ip: ctx.ip, + }); + + return { role: input.role, changed: true }; + }), + + /** + * Set a real email address. + * + * Required for pros — they need payout statements, tax records and dispute + * notices, none of which can go to a synthetic phone address. + */ + setEmail: protectedProcedure + .input(z.object({ email: z.string().email() })) + .mutation(async ({ ctx, input }) => { + const email = input.email.trim().toLowerCase(); + if (!isContactableEmail(email)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'That address is not one we can send to.', + }); + } + + const taken = await ctx.db.query.users.findFirst({ + where: eq(schema.users.email, email), + columns: { id: true }, + }); + if (taken && taken.id !== ctx.session.userId) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Another account already uses that email address.', + }); + } + + await ctx.db + .update(schema.users) + .set({ email, emailVerified: false, updatedAt: new Date() }) + .where(eq(schema.users.id, ctx.session.userId)); + + // TODO(M1): send a confirmation link before treating it as verified. + return { email }; + }), +}); diff --git a/packages/db/drizzle/0000_old_gorilla_man.sql b/packages/db/drizzle/0000_colossal_masked_marvel.sql similarity index 91% rename from packages/db/drizzle/0000_old_gorilla_man.sql rename to packages/db/drizzle/0000_colossal_masked_marvel.sql index c6544a7..6fb0aed 100644 --- a/packages/db/drizzle/0000_old_gorilla_man.sql +++ b/packages/db/drizzle/0000_colossal_masked_marvel.sql @@ -12,46 +12,49 @@ CREATE TYPE "public"."urgency" AS ENUM('now', 'this_week', 'flexible');--> state CREATE TYPE "public"."user_role" AS ENUM('client', 'pro', 'admin');--> statement-breakpoint CREATE TYPE "public"."verification_status" AS ENUM('draft', 'pending', 'verified', 'rejected', 'suspended');--> statement-breakpoint CREATE TABLE "accounts" ( - "user_id" uuid NOT NULL, - "type" text NOT NULL, - "provider" text NOT NULL, - "provider_account_id" text NOT NULL, - "refresh_token" text, - "access_token" text, - "expires_at" integer, - "token_type" text, - "scope" text, - "id_token" text, - "session_state" text, - CONSTRAINT "accounts_provider_provider_account_id_pk" PRIMARY KEY("provider","provider_account_id") -); ---> statement-breakpoint -CREATE TABLE "phone_otps" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "phone" text NOT NULL, - "code_hash" text NOT NULL, - "attempts" integer DEFAULT 0 NOT NULL, - "consumed" boolean DEFAULT false NOT NULL, - "expires_at" timestamp with time zone NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL + "issuer" text NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" uuid NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp with time zone, + "refresh_token_expires_at" timestamp with time zone, + "scope" text, + "password" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "accounts_issuer_account_unique" UNIQUE("issuer","account_id") ); --> statement-breakpoint CREATE TABLE "sessions" ( - "session_token" text PRIMARY KEY NOT NULL, + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "token" text NOT NULL, "user_id" uuid NOT NULL, - "expires" timestamp with time zone NOT NULL + "expires_at" timestamp with time zone NOT NULL, + "ip_address" text, + "user_agent" text, + "impersonated_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "sessions_token_unique" UNIQUE("token") ); --> statement-breakpoint CREATE TABLE "users" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, - "name" text, - "email" text, - "email_verified" timestamp with time zone, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, "phone" text, - "phone_verified" timestamp with time zone, + "phone_verified" boolean DEFAULT false, + "phone_verified_at" timestamp with time zone, "image" text, "role" "user_role" DEFAULT 'client' NOT NULL, - "banned_at" timestamp with time zone, + "banned" boolean DEFAULT false, + "ban_reason" text, + "ban_expires" timestamp with time zone, "last_active_at" timestamp with time zone DEFAULT now(), "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, @@ -59,11 +62,13 @@ CREATE TABLE "users" ( CONSTRAINT "users_phone_unique" UNIQUE("phone") ); --> statement-breakpoint -CREATE TABLE "verification_tokens" ( +CREATE TABLE "verifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "identifier" text NOT NULL, - "token" text NOT NULL, - "expires" timestamp with time zone NOT NULL, - CONSTRAINT "verification_tokens_identifier_token_pk" PRIMARY KEY("identifier","token") + "value" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE "categories" ( @@ -290,6 +295,7 @@ CREATE TABLE "audit_log" ( --> statement-breakpoint ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_impersonated_by_users_id_fk" FOREIGN KEY ("impersonated_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint ALTER TABLE "credentials" ADD CONSTRAINT "credentials_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "credentials" ADD CONSTRAINT "credentials_reviewed_by_users_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "pro_availability" ADD CONSTRAINT "pro_availability_pro_id_pro_profiles_user_id_fk" FOREIGN KEY ("pro_id") REFERENCES "public"."pro_profiles"("user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint @@ -319,9 +325,11 @@ ALTER TABLE "reviews" ADD CONSTRAINT "reviews_booking_id_bookings_id_fk" FOREIGN ALTER TABLE "reviews" ADD CONSTRAINT "reviews_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "reviews" ADD CONSTRAINT "reviews_subject_id_users_id_fk" FOREIGN KEY ("subject_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "phone_otps_phone_idx" ON "phone_otps" USING btree ("phone","expires_at");--> statement-breakpoint +CREATE INDEX "accounts_user_idx" ON "accounts" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint CREATE INDEX "users_role_idx" ON "users" USING btree ("role");--> statement-breakpoint CREATE INDEX "users_phone_idx" ON "users" USING btree ("phone");--> statement-breakpoint +CREATE INDEX "verifications_identifier_idx" ON "verifications" USING btree ("identifier","expires_at");--> statement-breakpoint CREATE INDEX "credentials_pro_idx" ON "credentials" USING btree ("pro_id");--> statement-breakpoint CREATE INDEX "credentials_review_idx" ON "credentials" USING btree ("review_status");--> statement-breakpoint CREATE INDEX "credentials_expiry_idx" ON "credentials" USING btree ("expires_at");--> statement-breakpoint diff --git a/packages/db/drizzle/meta/0000_snapshot.json b/packages/db/drizzle/meta/0000_snapshot.json index 8a73533..dd2bbdd 100644 --- a/packages/db/drizzle/meta/0000_snapshot.json +++ b/packages/db/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "9da9b63e-a29c-425d-806f-357f6f04e2e5", + "id": "d2669c23-7683-48e2-a271-03f7e5ddcbd1", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -8,56 +8,45 @@ "name": "accounts", "schema": "", "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, "user_id": { "name": "user_id", "type": "uuid", "primaryKey": false, "notNull": true }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_account_id": { - "name": "provider_account_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "refresh_token": { - "name": "refresh_token", - "type": "text", - "primaryKey": false, - "notNull": false - }, "access_token": { "name": "access_token", "type": "text", "primaryKey": false, "notNull": false }, - "expires_at": { - "name": "expires_at", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "token_type": { - "name": "token_type", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "scope": { - "name": "scope", + "refresh_token": { + "name": "refresh_token", "type": "text", "primaryKey": false, "notNull": false @@ -68,14 +57,62 @@ "primaryKey": false, "notNull": false }, - "session_state": { - "name": "session_state", + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", "type": "text", "primaryKey": false, "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_user_idx": { + "name": "accounts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, - "indexes": {}, "foreignKeys": { "accounts_user_id_users_id_fk": { "name": "accounts_user_id_users_id_fk", @@ -91,22 +128,23 @@ "onUpdate": "no action" } }, - "compositePrimaryKeys": { - "accounts_provider_provider_account_id_pk": { - "name": "accounts_provider_provider_account_id_pk", + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "accounts_issuer_account_unique": { + "name": "accounts_issuer_account_unique", + "nullsNotDistinct": false, "columns": [ - "provider", - "provider_account_id" + "issuer", + "account_id" ] } }, - "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false }, - "public.phone_otps": { - "name": "phone_otps", + "public.sessions": { + "name": "sessions", "schema": "", "columns": { "id": { @@ -116,58 +154,63 @@ "notNull": true, "default": "gen_random_uuid()" }, - "phone": { - "name": "phone", + "token": { + "name": "token", "type": "text", "primaryKey": false, "notNull": true }, - "code_hash": { - "name": "code_hash", - "type": "text", + "user_id": { + "name": "user_id", + "type": "uuid", "primaryKey": false, "notNull": true }, - "attempts": { - "name": "attempts", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "consumed": { - "name": "consumed", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, "expires_at": { "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true, "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, "indexes": { - "phone_otps_phone_idx": { - "name": "phone_otps_phone_idx", + "sessions_user_idx": { + "name": "sessions_user_idx", "columns": [ { - "expression": "phone", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "expires_at", + "expression": "user_id", "isExpression": false, "asc": true, "nulls": "last" @@ -179,37 +222,6 @@ "with": {} } }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.sessions": { - "name": "sessions", - "schema": "", - "columns": { - "session_token": { - "name": "session_token", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "expires": { - "name": "expires", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, "foreignKeys": { "sessions_user_id_users_id_fk": { "name": "sessions_user_id_users_id_fk", @@ -223,10 +235,31 @@ ], "onDelete": "cascade", "onUpdate": "no action" + }, + "sessions_impersonated_by_users_id_fk": { + "name": "sessions_impersonated_by_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "impersonated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false @@ -246,19 +279,20 @@ "name": "name", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "email": { "name": "email", "type": "text", "primaryKey": false, - "notNull": false + "notNull": true }, "email_verified": { "name": "email_verified", - "type": "timestamp with time zone", + "type": "boolean", "primaryKey": false, - "notNull": false + "notNull": true, + "default": false }, "phone": { "name": "phone", @@ -268,6 +302,13 @@ }, "phone_verified": { "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "phone_verified_at": { + "name": "phone_verified_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": false @@ -286,8 +327,21 @@ "notNull": true, "default": "'client'" }, - "banned_at": { - "name": "banned_at", + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", "type": "timestamp with time zone", "primaryKey": false, "notNull": false @@ -368,40 +422,75 @@ "checkConstraints": {}, "isRLSEnabled": false }, - "public.verification_tokens": { - "name": "verification_tokens", + "public.verifications": { + "name": "verifications", "schema": "", "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, "identifier": { "name": "identifier", "type": "text", "primaryKey": false, "notNull": true }, - "token": { - "name": "token", + "value": { + "name": "value", "type": "text", "primaryKey": false, "notNull": true }, - "expires": { - "name": "expires", + "expires_at": { + "name": "expires_at", "type": "timestamp with time zone", "primaryKey": false, "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "verification_tokens_identifier_token_pk": { - "name": "verification_tokens_identifier_token_pk", + "indexes": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", "columns": [ - "identifier", - "token" - ] + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} } }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, "uniqueConstraints": {}, "policies": {}, "checkConstraints": {}, diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 4b66cb4..7d6911a 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1787246593084, - "tag": "0000_old_gorilla_man", + "when": 1787250714989, + "tag": "0000_colossal_masked_marvel", "breakpoints": true } ] diff --git a/packages/db/src/queries/deck.ts b/packages/db/src/queries/deck.ts index 565e41c..ef42c03 100644 --- a/packages/db/src/queries/deck.ts +++ b/packages/db/src/queries/deck.ts @@ -105,7 +105,7 @@ export async function getDeck( WHERE j.id = ${args.jobId} AND p.verification_status = 'verified' AND p.is_accepting_jobs = true - AND u.banned_at IS NULL + AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now())) -- the pro must be willing to travel to this job, index-accelerated AND ST_DWithin(p.base_location, j.location, p.service_radius_m) -- never show a card the client has already decided on @@ -173,7 +173,7 @@ export async function getDeckCount(db: Db, jobId: string): Promise { WHERE j.id = ${jobId} AND p.verification_status = 'verified' AND p.is_accepting_jobs = true - AND u.banned_at IS NULL + AND (u.banned IS NOT TRUE OR (u.ban_expires IS NOT NULL AND u.ban_expires < now())) AND ST_DWithin(p.base_location, j.location, p.service_radius_m) AND NOT EXISTS (SELECT 1 FROM swipes s WHERE s.job_id = j.id AND s.pro_id = p.user_id) AND NOT EXISTS (SELECT 1 FROM requests r WHERE r.job_id = j.id AND r.pro_id = p.user_id) diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts index 4fed596..a857cde 100644 --- a/packages/db/src/schema/auth.ts +++ b/packages/db/src/schema/auth.ts @@ -1,83 +1,146 @@ import { relations } from 'drizzle-orm'; -import { boolean, index, integer, pgTable, primaryKey, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { + boolean, + index, + pgTable, + text, + timestamp, + unique, + uuid, +} from 'drizzle-orm/pg-core'; import { userRole } from './enums'; -/** Auth.js v5 compatible tables, plus the fields the marketplace needs. */ +/** + * Tables owned by better-auth, plus the marketplace columns we add on top. + * + * The shapes here are not a matter of taste — they were derived from + * better-auth 1.7.1's own `getSchema()` output for our exact plugin set + * (phoneNumber + admin + bearer). Two of them are easy to get wrong: + * + * - `emailVerified` and `phoneVerified` are BOOLEAN, not timestamps. + * better-auth injects `emailVerified: false` on every insert, so a + * timestamptz column fails 100% of signups with "value.toISOString is not a + * function". Where we want to know *when*, we keep a separate *_at column. + * - `accounts.issuer` is required in 1.7.x. + * + * Table and column names are mapped back to our conventions in + * apps/web/src/lib/auth.ts via `modelName` / `fields`. + */ export const users = pgTable( 'users', { id: uuid('id').primaryKey().defaultRandom(), - name: text('name'), - email: text('email').unique(), - emailVerified: timestamp('email_verified', { withTimezone: true }), - /** E.164. The identity that actually matters on both sides of a local marketplace. */ - phone: text('phone').unique(), - phoneVerified: timestamp('phone_verified', { withTimezone: true }), + name: text('name').notNull(), + + /** + * Required and unique by better-auth. Phone-first users get a synthetic + * address on a domain we control — ALWAYS gate outbound mail on + * `isSyntheticEmail()` from @linkder/shared. Pros must supply a real + * address during onboarding; clients may never have one. + */ + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').notNull().default(false), + + /** E.164. The identity that actually matters on both sides of the market. */ + phoneNumber: text('phone').unique(), + phoneNumberVerified: boolean('phone_verified').default(false), + /** When the number was confirmed, for support and dispute history. */ + phoneVerifiedAt: timestamp('phone_verified_at', { withTimezone: true }), + image: text('image'), role: userRole('role').notNull().default('client'), - /** Set when an admin bans someone; checked in the auth callback. */ - bannedAt: timestamp('banned_at', { withTimezone: true }), + + /** Ban state, owned by better-auth's admin plugin. One source of truth. */ + banned: boolean('banned').default(false), + banReason: text('ban_reason'), + banExpires: timestamp('ban_expires', { withTimezone: true }), + + /** Deck ranking penalises dormant pros, so this has to be maintained. */ lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (t) => [index('users_role_idx').on(t.role), index('users_phone_idx').on(t.phone)], + (t) => [index('users_role_idx').on(t.role), index('users_phone_idx').on(t.phoneNumber)], +); + +export const sessions = pgTable( + 'sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + /** The bearer credential itself. Treat as a secret. */ + token: text('token').notNull().unique(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + /** Set when an admin is impersonating this user for support. */ + impersonatedBy: uuid('impersonated_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [index('sessions_user_idx').on(t.userId)], ); export const accounts = pgTable( 'accounts', { + id: uuid('id').primaryKey().defaultRandom(), + /** Required by better-auth 1.7.x. */ + issuer: text('issuer').notNull(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), - type: text('type').notNull(), - provider: text('provider').notNull(), - providerAccountId: text('provider_account_id').notNull(), - refresh_token: text('refresh_token'), - access_token: text('access_token'), - expires_at: integer('expires_at'), - token_type: text('token_type'), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }), scope: text('scope'), - id_token: text('id_token'), - session_state: text('session_state'), + password: text('password'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (t) => [primaryKey({ columns: [t.provider, t.providerAccountId] })], + (t) => [ + unique('accounts_issuer_account_unique').on(t.issuer, t.accountId), + index('accounts_user_idx').on(t.userId), + ], ); -export const sessions = pgTable('sessions', { - sessionToken: text('session_token').primaryKey(), - userId: uuid('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - expires: timestamp('expires', { withTimezone: true }).notNull(), -}); - -export const verificationTokens = pgTable( - 'verification_tokens', - { - identifier: text('identifier').notNull(), - token: text('token').notNull(), - expires: timestamp('expires', { withTimezone: true }).notNull(), - }, - (t) => [primaryKey({ columns: [t.identifier, t.token] })], -); - -/** Short-lived SMS codes for phone login. Separate from Auth.js email tokens. */ -export const phoneOtps = pgTable( - 'phone_otps', +/** + * One-time codes and tokens, including phone OTPs. + * + * `value` holds the OTP as plaintext in the form "123456:0", the suffix being + * the attempt count. That is better-auth's design and we accept it — see the + * long note in apps/web/src/lib/auth.ts for the reasoning. It is a recorded + * decision, not an oversight. + */ +export const verifications = pgTable( + 'verifications', { id: uuid('id').primaryKey().defaultRandom(), - phone: text('phone').notNull(), - codeHash: text('code_hash').notNull(), - attempts: integer('attempts').notNull().default(0), - consumed: boolean('consumed').notNull().default(false), + identifier: text('identifier').notNull(), + value: text('value').notNull(), expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, - (t) => [index('phone_otps_phone_idx').on(t.phone, t.expiresAt)], + (t) => [index('verifications_identifier_idx').on(t.identifier, t.expiresAt)], ); export const usersRelations = relations(users, ({ many }) => ({ accounts: many(accounts), sessions: many(sessions), })); + +export const accountsRelations = relations(accounts, ({ one }) => ({ + user: one(users, { fields: [accounts.userId], references: [users.id] }), +})); + +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { fields: [sessions.userId], references: [users.id] }), +})); diff --git a/packages/db/src/seed.ts b/packages/db/src/seed.ts index c77cffb..0946ceb 100644 --- a/packages/db/src/seed.ts +++ b/packages/db/src/seed.ts @@ -107,7 +107,7 @@ async function main() { matches, requests, swipes, jobs, pro_availability, verification_sessions, credentials, pro_media, pro_categories, pro_profiles, - sessions, accounts, phone_otps, users, categories + sessions, accounts, verifications, users, categories RESTART IDENTITY CASCADE `); @@ -124,10 +124,11 @@ async function main() { CLIENTS.map((name, i) => ({ name, email: `client${i + 1}@linkder.test`, - phone: `+3460000${String(i + 1).padStart(4, '0')}`, + phoneNumber: `+3460000${String(i + 1).padStart(4, '0')}`, role: 'client' as const, - emailVerified: new Date(), - phoneVerified: new Date(), + emailVerified: true, + phoneNumberVerified: true, + phoneVerifiedAt: new Date(), })), ) .returning(); @@ -136,9 +137,9 @@ async function main() { await db.insert(schema.users).values({ name: 'Linkder Admin', email: 'admin@linkder.test', - phone: '+34600009999', + phoneNumber: '+34600009999', role: 'admin', - emailVerified: new Date(), + emailVerified: true, }); const now = Date.now(); @@ -151,10 +152,11 @@ async function main() { .values({ name: p.name, email: `pro${i + 1}@linkder.test`, - phone: `+3461000${String(i + 1).padStart(4, '0')}`, + phoneNumber: `+3461000${String(i + 1).padStart(4, '0')}`, role: 'pro' as const, - emailVerified: new Date(), - phoneVerified: new Date(), + emailVerified: true, + phoneNumberVerified: true, + phoneVerifiedAt: new Date(), lastActiveAt: new Date(now - (i % 5) * 86_400_000), }) .returning(); diff --git a/packages/shared/src/email.ts b/packages/shared/src/email.ts new file mode 100644 index 0000000..888e2d7 --- /dev/null +++ b/packages/shared/src/email.ts @@ -0,0 +1,39 @@ +/** + * Phone-first signup still has to put something in `users.email` — better-auth + * requires it to be present and unique. We mint a synthetic address on a domain + * we control and never deliver to. + * + * For a plumber-and-electrician marketplace this will be MOST client accounts, + * so every outbound-mail path must check `isSyntheticEmail` first. Sending to + * one is not merely useless: it is a bounce against our sending reputation, and + * at volume that costs us delivery to the addresses that are real. + * + * Pros are required to supply a genuine address during onboarding — they need + * payout statements, tax records and dispute notices. Clients may never have one + * and are served over SMS instead. + */ +export const SYNTHETIC_EMAIL_DOMAIN = 'phone.linkder.local'; + +export function syntheticEmailFor(phoneE164: string): string { + return `${phoneE164}@${SYNTHETIC_EMAIL_DOMAIN}`; +} + +export function isSyntheticEmail(email: string | null | undefined): boolean { + if (!email) return true; // nothing to send to is, for our purposes, the same thing + return email.toLowerCase().endsWith(`@${SYNTHETIC_EMAIL_DOMAIN}`); +} + +/** True when we can actually put a message in front of this person by email. */ +export function isContactableEmail(email: string | null | undefined): email is string { + return !isSyntheticEmail(email); +} + +/** + * Recover the phone number a synthetic address was minted from. + * Useful in support tooling; returns null for a real address. + */ +export function phoneFromSyntheticEmail(email: string): string | null { + if (!isSyntheticEmail(email)) return null; + const [local] = email.split('@'); + return local && local.startsWith('+') ? local : null; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 444fc2c..1194428 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './state-machines'; export * from './ranking'; export * from './cancellation'; export * from './schemas'; +export * from './email'; diff --git a/packages/shared/test/email.test.ts b/packages/shared/test/email.test.ts new file mode 100644 index 0000000..f82621c --- /dev/null +++ b/packages/shared/test/email.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + isContactableEmail, + isSyntheticEmail, + phoneFromSyntheticEmail, + syntheticEmailFor, +} from '../src/email'; + +describe('synthetic emails', () => { + it('mints an address from a phone number', () => { + expect(syntheticEmailFor('+34600123456')).toBe('+34600123456@phone.linkder.local'); + }); + + it('recognises its own output', () => { + expect(isSyntheticEmail(syntheticEmailFor('+34600123456'))).toBe(true); + }); + + it('treats a real address as contactable', () => { + expect(isSyntheticEmail('marc@gmail.com')).toBe(false); + expect(isContactableEmail('marc@gmail.com')).toBe(true); + }); + + it('treats null and empty as not contactable rather than throwing', () => { + expect(isSyntheticEmail(null)).toBe(true); + expect(isSyntheticEmail(undefined)).toBe(true); + expect(isSyntheticEmail('')).toBe(true); + expect(isContactableEmail(null)).toBe(false); + }); + + it('is case insensitive — a bounce is a bounce whatever the casing', () => { + expect(isSyntheticEmail('+34600123456@PHONE.LINKDER.LOCAL')).toBe(true); + }); + + it('does not match a lookalike domain', () => { + expect(isSyntheticEmail('someone@phone.linkder.local.evil.com')).toBe(false); + expect(isSyntheticEmail('someone@notphone.linkder.local')).toBe(false); + }); + + it('recovers the phone number for support tooling', () => { + expect(phoneFromSyntheticEmail('+34600123456@phone.linkder.local')).toBe('+34600123456'); + expect(phoneFromSyntheticEmail('marc@gmail.com')).toBeNull(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0bb7f5..47dbcb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@trpc/server': specifier: ^11.18.0 version: 11.18.0(typescript@5.9.3) + better-auth: + specifier: 1.7.1 + version: 1.7.1(drizzle-orm@0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8))(next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -55,7 +58,7 @@ importers: version: 2.1.1 drizzle-orm: specifier: 0.38.4 - version: 0.38.4(@types/react@19.2.18)(postgres@3.4.9)(react@19.2.8) + version: 0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8) lucide-react: specifier: ^0.469.0 version: 0.469.0(react@19.2.8) @@ -105,6 +108,9 @@ importers: typescript: specifier: ^5.7.3 version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0) packages/api: dependencies: @@ -122,7 +128,7 @@ importers: version: 11.18.0(typescript@5.9.3) drizzle-orm: specifier: 0.38.4 - version: 0.38.4(@types/react@19.2.18)(postgres@3.4.9)(react@19.2.8) + version: 0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8) superjson: specifier: ^2.2.6 version: 2.2.6 @@ -147,7 +153,7 @@ importers: version: link:../shared drizzle-orm: specifier: 0.38.4 - version: 0.38.4(@types/react@19.2.18)(postgres@3.4.9)(react@19.2.8) + version: 0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8) postgres: specifier: ^3.4.5 version: 3.4.9 @@ -282,6 +288,88 @@ packages: resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} + '@better-auth/core@1.7.1': + resolution: {integrity: sha512-eZ9lqcnVLMZ3QtUByRo4VZqkB1ESyRddd9NfWjBdDPgh+jcwLScoIUAqhtHLR8zaSUJZah8OLGlkzObyPdUH7A==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.1': + resolution: {integrity: sha512-qlqNyg5V9bXHSP68/vtlsiZayhR4hgvEGiS/E3SIj8bCpWWFGmyQkxJbQCqpBmC7vT30wE/kNtJMHIgnV3rkiw==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.1': + resolution: {integrity: sha512-yWCpE1cZpMUj37nD6JFDK+GDR8zS37L5WI73il3qbU9TXtWsxUQKc/5c3IHsHizWQsmcQI8uv2pAFKxsRDa+AQ==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.1': + resolution: {integrity: sha512-6NX1yv88DeqdoG7owYFqKwlrDGaIPhsC52JUGrUgeGVKyOq8a/6hHlHsqG1C2FwT23SHQiKVYCEAG9N6aH5OvQ==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.1': + resolution: {integrity: sha512-9ILTcNqhG37QK//qR4UhYLyKzNqq6w6zVTf5KX6xkiTjNcV7Oh1yS31lkIJEVTqRNcy9AoV6FZMW6Bbsm8IMDA==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.1': + resolution: {integrity: sha512-ZiUcafQ85InAofcUjyGgCPjKLfQjXr9SvDmMjuFUW8oEbreA6C6GaFAEA77VuV2doZUQlzvQQ4gCoTmS28W92A==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.1': + resolution: {integrity: sha512-kLKjMfFlTbyt49DGeI9okHAsn0MtBZcMoQYKaEdgR0H3BHzqqyzePcQz/hxAmRgjB4p/6inise3zJwhX0sgXrQ==} + peerDependencies: + '@better-auth/core': ^1.7.1 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} @@ -1151,6 +1239,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.3.0': + resolution: {integrity: sha512-Clu/xdfgVTf9o7ngLOURaxePwR0j8sjclKEtVij10/jGulwFsPWCvvRgG/XjUVf8Nei+jLG6uwyXzUTGY1DQrw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1167,6 +1263,10 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} @@ -1325,6 +1425,9 @@ packages: resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==} engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -1783,6 +1886,76 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + better-auth@1.7.1: + resolution: {integrity: sha512-g8WlTQijxXWJjPVZfFu1+EJg9cwwHrKDmIkcYMzx8CzYA+tDxl6NI7qQbKkbgw5UtHILsT5VH+RMzFzwnVJqAg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} @@ -1913,6 +2086,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2531,6 +2707,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + jose@6.2.9: + resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2558,6 +2737,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.29.5: + resolution: {integrity: sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==} + engines: {node: '>=22.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -2711,6 +2894,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.5.1: + resolution: {integrity: sha512-DNIX+HyFpo14fKGe0NsX9/aPzdKGiSZwX5xEMpDwQrDdo2iTiUYunOCCQLYwJrziKVe8ZOtVvcv0dEVI8lMx2g==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2901,6 +3088,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2928,6 +3118,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -3253,6 +3446,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3431,6 +3627,63 @@ snapshots: '@aws/lambda-invoke-store@0.3.0': {} + '@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.4.3) + jose: 6.2.9 + kysely: 0.29.5 + nanostores: 1.5.1 + zod: 4.4.3 + + '@better-auth/drizzle-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8))': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8) + + '@better-auth/kysely-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.5 + + '@better-auth/memory-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.3.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.3.0 + + '@better-fetch/fetch@1.3.1': {} + '@drizzle-team/brocli@0.10.2': {} '@emnapi/core@1.10.0': @@ -3978,6 +4231,10 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.23': optional: true + '@noble/ciphers@2.3.0': {} + + '@noble/hashes@2.3.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3992,6 +4249,8 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@opentelemetry/semantic-conventions@1.43.0': {} + '@petamoriken/float16@3.9.3': {} '@rollup/rollup-android-arm-eabi@4.62.5': @@ -4106,6 +4365,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -4551,6 +4812,44 @@ snapshots: balanced-match@4.0.4: {} + better-auth@1.7.1(drizzle-orm@0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8))(next@15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)): + dependencies: + '@better-auth/core': 1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1) + '@better-auth/drizzle-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8)) + '@better-auth/kysely-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.1(@better-auth/core@1.7.1(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.4.3))(jose@6.2.9)(kysely@0.29.5)(nanostores@1.5.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.3.0 + '@noble/hashes': 2.3.0 + better-call: 1.4.0(zod@4.4.3) + defu: 6.1.7 + jose: 6.2.9 + kysely: 0.29.5 + nanostores: 1.5.1 + zod: 4.4.3 + optionalDependencies: + drizzle-orm: 0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8) + next: 15.5.23(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + vitest: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.4.3): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.1 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.4.3 + bowser@2.14.1: {} brace-expansion@1.1.18: @@ -4676,6 +4975,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.7: {} + detect-libc@2.1.2: {} doctrine@2.1.0: @@ -4696,9 +4997,10 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@types/react@19.2.18)(postgres@3.4.9)(react@19.2.8): + drizzle-orm@0.38.4(@types/react@19.2.18)(kysely@0.29.5)(postgres@3.4.9)(react@19.2.8): optionalDependencies: '@types/react': 19.2.18 + kysely: 0.29.5 postgres: 3.4.9 react: 19.2.8 @@ -5455,6 +5757,8 @@ snapshots: jiti@2.7.0: {} + jose@6.2.9: {} + js-tokens@4.0.0: {} js-yaml@4.3.1: @@ -5482,6 +5786,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.29.5: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -5599,6 +5905,8 @@ snapshots: nanoid@3.3.18: {} + nanostores@1.5.1: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -5825,6 +6133,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.5 fsevents: 2.3.3 + rou3@0.9.2: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5854,6 +6164,8 @@ snapshots: semver@7.8.5: {} + set-cookie-parser@3.1.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6290,3 +6602,5 @@ snapshots: yocto-queue@0.1.0: {} zod@3.25.76: {} + + zod@4.4.3: {}