M1: authentication with better-auth, verified end to end
Switches from the planned Auth.js v5 to better-auth 1.7.1. The plan assumed the blocker would be schema fit; it is not. @auth/drizzle-adapter accepts our tables verbatim. What rules Auth.js out is that credentials providers hardcode JWT and never call adapter.createSession, and the config assertion that would catch it only fires when EVERY provider is credentials — so adding Google suppresses the warning and the app ships silently broken. Phone OTP with database sessions is not reachable there without hand-building the whole OTP security layer. Also corrects a premise: better-auth's drizzle-orm peer is declared OPTIONAL, so no 0.38 -> 0.45 upgrade is forced. Verified on 0.38.4. - auth schema rewritten to better-auth 1.7.1's own getSchema() output: sessions/accounts/verifications reshaped, emailVerified and phoneVerified are BOOLEAN (a timestamptz there fails 100% of signups), accounts.issuer added, phone_otps dropped. Ban state now comes from the admin plugin rather than a second bannedAt column. - Session resolution is one file. Everything downstream is written against our own Session type, so the provider stays swappable. - Ban enforcement lives in the resolver because Session carries no ban field and protectedProcedure promises a non-banned user. - Phone OTP sign-in, Google, role selection, tRPC user router. - Synthetic emails for phone-first users, with isSyntheticEmail() gating every future send. Pros must supply a real address; clients need not. - Duplicate-account detection, since both signup routes stay open and nothing correlates a phone to a Google identity. Detects only — merging accounts that carry reviews and payments needs its own tooling. - SMS sender refuses to fall back to console logging in production. - declaration:false for the app, which is the actual fix for the TS2742 wall from better-auth's transitive zod under pnpm. Verified against a live server: OTP sent, code verified, uuid PK honoured, database session written, and an authenticated tRPC call resolved. A signed-in stranger gets NOT_FOUND on another client's deck; anonymous gets UNAUTHORIZED. 124 tests passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+13
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { toNextJsHandler } from 'better-auth/next-js';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
export const { GET, POST } = toNextJsHandler(auth.handler);
|
||||
@@ -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<SwipeResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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}
|
||||
</div>
|
||||
)}
|
||||
<Deck cards={cards} onDecide={onDecide} />
|
||||
<Deck cards={initialCards} onDecide={onDecide} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ReturnType<typeof api.job.byId>>;
|
||||
let deck: Awaited<ReturnType<typeof api.deck.list>>;
|
||||
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 (
|
||||
<main className="mx-auto flex min-h-dvh max-w-2xl flex-col px-4 py-6">
|
||||
<header className="mb-6">
|
||||
<Link
|
||||
href="/"
|
||||
href="/jobs"
|
||||
className="mb-4 inline-flex items-center gap-1.5 text-sm text-[var(--muted)] hover:text-[var(--fg)]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden />
|
||||
Back
|
||||
My jobs
|
||||
</Link>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{job.category.name} · {job.addressText}
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold">{job.title}</h1>
|
||||
{job.pendingRequests > 0 && (
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
{job.pendingRequests} pro{job.pendingRequests === 1 ? '' : 's'} already considering this
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<DeckClient jobId={jobId} cards={cards} />
|
||||
<DeckClient jobId={jobId} initialCards={deck.cards} />
|
||||
|
||||
<p className="mt-8 text-center text-xs text-[var(--muted)]">
|
||||
Swipe right to send this job to a pro, left to pass. Drag the card or use the buttons.
|
||||
|
||||
@@ -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<ReturnType<typeof api.user.me>>;
|
||||
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 (
|
||||
<main className="mx-auto flex min-h-dvh max-w-md flex-col justify-center px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">What brings you here?</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
You can only pick once, so choose the one that fits.
|
||||
</p>
|
||||
<RoleChooser />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Choice
|
||||
icon={<Home className="h-6 w-6" aria-hidden />}
|
||||
title="I need something fixed"
|
||||
body="Post a job and swipe through verified local pros."
|
||||
disabled={setRole.isPending}
|
||||
onClick={() => setRole.mutate({ role: 'client' })}
|
||||
/>
|
||||
<Choice
|
||||
icon={<Hammer className="h-6 w-6" aria-hidden />}
|
||||
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 && (
|
||||
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
|
||||
{setRole.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
body: string;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="flex items-start gap-4 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 text-left transition hover:border-[var(--color-brand-500)] disabled:opacity-60"
|
||||
>
|
||||
<span className="mt-0.5 text-[var(--color-brand-500)]">{icon}</span>
|
||||
<span>
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-0.5 block text-sm text-[var(--muted)]">{body}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+19
-42
@@ -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 (
|
||||
<main className="mx-auto max-w-2xl px-6 py-16">
|
||||
@@ -26,6 +19,22 @@ export default async function Home() {
|
||||
pay in one place — your money is held until the work is done.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="rounded-xl bg-[var(--color-brand-500)] px-5 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)]"
|
||||
>
|
||||
Post a job
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-in?next=/pro/onboarding"
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-[var(--border)] px-5 py-3 font-medium transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
Work with us
|
||||
<ArrowRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-sm font-medium text-[var(--muted)]">Trades we cover</h2>
|
||||
<ul className="mt-3 flex flex-wrap gap-2">
|
||||
@@ -39,38 +48,6 @@ export default async function Home() {
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<h2 className="text-sm font-medium text-[var(--muted)]">Open jobs (seed data)</h2>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-[var(--muted)]">
|
||||
No jobs yet — run <code className="font-mono">pnpm db:seed</code>.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{jobs.map((job) => (
|
||||
<li key={job.id}>
|
||||
<Link
|
||||
href={`/deck/${job.id}`}
|
||||
className="group flex items-center justify-between gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
<span>
|
||||
<span className="block font-medium">{job.title}</span>
|
||||
<span className="block text-sm text-[var(--muted)]">{job.addressText}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-sm text-[var(--color-brand-500)]">
|
||||
Open deck
|
||||
<ArrowRight
|
||||
className="h-4 w-4 transition group-hover:translate-x-0.5"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="mx-auto flex min-h-dvh max-w-sm flex-col justify-center px-6 py-12">
|
||||
<p className="text-sm font-medium text-[var(--color-brand-500)]">Linkder</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold tracking-tight">Sign in</h1>
|
||||
<p className="mt-2 text-sm text-[var(--muted)]">
|
||||
We will text you a 6-digit code. No password to forget.
|
||||
</p>
|
||||
<Suspense fallback={null}>
|
||||
<SignInForm />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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<Step>('phone');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="mt-8 flex flex-col gap-6">
|
||||
{step === 'phone' ? (
|
||||
<form onSubmit={sendCode} className="flex flex-col gap-3">
|
||||
<label htmlFor="phone" className="text-sm font-medium">
|
||||
Mobile number
|
||||
</label>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
required
|
||||
placeholder="+34 600 123 456"
|
||||
value={phone}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<SubmitButton busy={busy}>Send code</SubmitButton>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={verifyCode} className="flex flex-col gap-3">
|
||||
<label htmlFor="code" className="text-sm font-medium">
|
||||
Enter the code we sent to {phone}
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
name="code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]{6}"
|
||||
maxLength={6}
|
||||
required
|
||||
autoFocus
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<SubmitButton busy={busy}>Sign in</SubmitButton>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('phone');
|
||||
setCode('');
|
||||
setError(null);
|
||||
}}
|
||||
className="text-sm text-[var(--muted)] underline underline-offset-4"
|
||||
>
|
||||
Use a different number
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-[var(--color-stop-500)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
or
|
||||
<span className="h-px flex-1 bg-[var(--border)]" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => authClient.signIn.social({ provider: 'google', callbackURL: next })}
|
||||
className="rounded-xl border border-[var(--border)] bg-[var(--card)] px-4 py-3 text-sm font-medium transition hover:border-[var(--color-brand-500)]"
|
||||
>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubmitButton({ busy, children }: { busy: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="flex items-center justify-center gap-2 rounded-xl bg-[var(--color-brand-500)] px-4 py-3 font-medium text-white transition hover:bg-[var(--color-brand-600)] disabled:opacity-60"
|
||||
>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 <token>` instead of a cookie.
|
||||
bearer(),
|
||||
|
||||
// Must be last — it wraps the handler to set cookies on Next responses.
|
||||
nextCookies(),
|
||||
],
|
||||
});
|
||||
|
||||
export type Auth = typeof auth;
|
||||
@@ -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<DuplicateSignal[]> {
|
||||
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<DuplicateSignal[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Session | null> => {
|
||||
return null;
|
||||
export const resolveSession: SessionResolver = async (req: Request): Promise<Session | null> => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
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(() => '<no body>');
|
||||
throw new Error(`Twilio rejected the message (${response.status}): ${detail}`);
|
||||
}
|
||||
}
|
||||
@@ -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<string> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user