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: {}