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:
serfowi
2026-08-20 14:40:23 -04:00
co-authored by Claude Opus 5
parent 66dd4ac942
commit cebeda7f4c
32 changed files with 1873 additions and 397 deletions
+1
View File
@@ -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=
+3
View File
@@ -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
+13 -10
View File
@@ -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);
-82
View File
@@ -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 };
}
+26 -18
View File
@@ -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>
);
}
+25 -13
View File
@@ -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.
+38
View File
@@ -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
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+11
View File
@@ -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;
+130
View File
@@ -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;
+138
View File
@@ -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;
}
+53 -5
View File
@@ -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,
};
};
+48
View File
@@ -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}`);
}
}
+170
View File
@@ -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();
});
});
+6
View File
@@ -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" }],
+15
View File
@@ -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,
},
});
+2
View File
@@ -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;
+143
View File
@@ -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 };
}),
});
@@ -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
+219 -130
View File
@@ -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": {},
+2 -2
View File
@@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1787246593084,
"tag": "0000_old_gorilla_man",
"when": 1787250714989,
"tag": "0000_colossal_masked_marvel",
"breakpoints": true
}
]
+2 -2
View File
@@ -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<number> {
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)
+110 -47
View File
@@ -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] }),
}));
+11 -9
View File
@@ -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();
+39
View File
@@ -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;
}
+1
View File
@@ -4,3 +4,4 @@ export * from './state-machines';
export * from './ranking';
export * from './cancellation';
export * from './schemas';
export * from './email';
+43
View File
@@ -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();
});
});
+318 -4
View File
@@ -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: {}