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
+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}`);
}
}