Files
sase.tr/apps/api/src/auth/auth.ts
Semih Yesilyurt 7a4fe51d09 feat(api): public API POC — API-key auth + /api/v1 decode & OEM xref
Kapalı-beta public API temeli (rapor: api-widget-analizi-2026-07-03):
- better-auth apiKey plugin (defaultPrefix sase_, enableMetadata, anahtar
  başına 120 istek/dk); enableSessionForAPIKeys KAPALI — anahtar dashboard
  oturumu yerine geçmez, cookie AuthGuard davranışı değişmez
- apikeys tablosu (migration 0023, plugin şemasının birebir karşılığı)
- ApiKeyGuard: Authorization Bearer / x-api-key → verifyApiKey → kullanıcı
  yüklenir (status kontrolü), request.user + request.apiKey doldurulur
- /api/v1/vin/decode: mevcut slim kontrat + aday akışı; günlük başarılı-decode
  kotası (Redis, TR günü, aynı VIN idempotent; limit: key metadata
  dailyDecodeLimit → PUBLIC_API_DAILY_DECODE_LIMIT → 100) ve
  X-Decode-Quota-* header'ları
- /api/v1/oem/:code: P çapraz-referans (kotasız)
- internal/admin/api-keys: x-internal-token ile anahtar üret/listele/kapat
  (düz metin yalnız create cevabında)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:15:17 +03:00

304 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { randomUUID } from "node:crypto";
import { generateReferralCode, normalizeName } from "@sase/shared";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { apiKey, captcha } from "better-auth/plugins";
import { eq } from "drizzle-orm";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "../database/schema/core";
import { EmailService } from "../email/email.service";
import type { NovuService } from "../notifications/novu.service";
/**
* Generates a referral code that doesn't collide with an existing one. The code
* lands in a unique-indexed column, so a raw collision would otherwise fail the
* whole signup. Retries a few times, then falls back to a longer code.
*/
async function generateUniqueReferralCode(
db: ReturnType<typeof drizzle<typeof schema>>,
): Promise<string> {
for (let i = 0; i < 5; i++) {
const code = generateReferralCode();
const existing = await db
.select({ id: schema.users.id })
.from(schema.users)
.where(eq(schema.users.referralCode, code))
.limit(1);
if (existing.length === 0) return code;
}
// Astronomically unlikely to reach here; widen the code to defuse collisions.
return `${generateReferralCode()}${generateReferralCode().slice(0, 4)}`;
}
let authInstance: ReturnType<typeof betterAuth> | null = null;
interface SocialCredentials {
googleClientId?: string;
googleClientSecret?: string;
}
interface AuthOptions {
social?: SocialCredentials;
emailService?: EmailService;
/** Lifecycle/transactional e-mail via Novu. Preferred over emailService. */
novu?: NovuService;
/** Called after a user's email is verified (referral qualification, etc.). */
onEmailVerified?: (userId: string) => Promise<void>;
/** Server-side Meta CAPI sender — reliable signup backstop for BOTH email and
* Google OAuth (the browser pixel misses OAuth and is ad-blocked on mobile). */
metaCapi?: {
sendCompleteRegistration: (input: { userId: string; email?: string | null }) => Promise<void>;
};
}
export function createAuth(
databaseUrl: string,
secret: string,
baseUrl: string,
options?: AuthOptions,
) {
if (authInstance) return authInstance;
const client = postgres(databaseUrl, { max: 5 });
const db = drizzle(client, { schema });
authInstance = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
usePlural: true,
}),
advanced: {
database: {
generateId: () => randomUUID(),
},
},
secret,
baseURL: baseUrl,
basePath: "/api/auth",
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
sendResetPassword: async (data) => {
if (options?.novu) {
await options.novu.passwordReset(
{ id: data.user.id, email: data.user.email, name: data.user.name },
data.url,
);
} else if (options?.emailService) {
await options.emailService.sendPasswordReset(data.user.email, data.url);
} else {
console.log(`[DEV] Password reset URL for ${data.user.email}: ${data.url}`);
}
},
},
emailVerification: {
sendVerificationEmail: async (data) => {
// better-auth bakes the sign-up `callbackURL` (the deep-linked search/VIN
// page) into data.url. Override it so verifying lands the user on a clear
// confirmation page instead of silently dumping them on a vehicle search.
let verificationUrl = data.url;
try {
const parsed = new URL(data.url);
parsed.searchParams.set("callbackURL", "/email-verified");
verificationUrl = parsed.toString();
} catch {
// Non-absolute URL (shouldn't happen) — fall back to the original.
}
if (options?.novu) {
await options.novu.emailVerification(
{ id: data.user.id, email: data.user.email, name: data.user.name },
verificationUrl,
);
} else if (options?.emailService) {
await options.emailService.sendEmailVerification(data.user.email, verificationUrl);
} else {
console.log(`[DEV] Verification URL for ${data.user.email}: ${verificationUrl}`);
}
},
afterEmailVerification: async (user) => {
// Unlock referral rewards once the referred user proves their email.
// Never let a failure here break the verification flow.
if (options?.onEmailVerified) {
try {
await options.onEmailVerified(user.id);
} catch (err) {
console.error("[auth] onEmailVerified hook failed:", err);
}
}
},
sendOnSignUp: true,
},
...(options?.social?.googleClientId && options?.social?.googleClientSecret
? {
socialProviders: {
google: {
clientId: options.social.googleClientId,
clientSecret: options.social.googleClientSecret,
},
},
}
: {}),
databaseHooks: {
user: {
create: {
before: async (userData) => {
// Postal logs show signup names arrive in every casing (`mehmet`,
// `MEHMET`, `İLKER`, `OTO`) and we render them straight into mail
// subjects — `Sase.tr'ye hoş geldin, mehmet` looks unprofessional.
// Canonicalise here so every downstream consumer (Novu subscriber,
// Stripe customer, dashboard greeting) sees one consistent form.
// Turkish-locale-aware (İ/ı handled).
const cleanedName = normalizeName(userData.name);
return {
data: {
...userData,
...(cleanedName ? { name: cleanedName } : {}),
referralCode: await generateUniqueReferralCode(db),
},
};
},
after: async (user) => {
// Server-side Meta CAPI signup event — reliable for BOTH email and
// Google OAuth (the browser pixel misses OAuth and is ad-blocked on
// mobile in-app). Deduped with the pixel/endpoint via
// event_id=signup_<id>. Fire-and-forget; never break signup.
void options?.metaCapi?.sendCompleteRegistration({
userId: user.id,
email: user.email,
});
// Fire-and-forget lifecycle e-mails on signup (covers password +
// OAuth). Never await / never let a notification failure surface
// into the signup response. `referral` is delay-stepped in Novu
// (sent 3 days later); `welcome` goes out immediately.
if (!options?.novu) return;
const nu = {
id: user.id,
email: user.email,
name: user.name,
};
const referralCode =
typeof user.referralCode === "string" ? user.referralCode : undefined;
void options.novu.welcome(nu);
void options.novu.referralInvite(nu, referralCode);
},
},
},
},
session: {
// "Beni hatırla" işaretliyse oturum 30 gün korunur; işaretli değilse
// better-auth çerezi oturum çerezi yapar (tarayıcı kapanınca silinir).
expiresIn: 60 * 60 * 24 * 30, // 30 gün
cookieCache: {
enabled: true,
maxAge: 60 * 5, // 5 minutes
},
},
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "user",
input: false,
},
status: {
type: "string",
defaultValue: "active",
input: false,
},
referralCode: {
type: "string",
required: false,
input: false,
},
referredBy: {
type: "string",
required: false,
input: false,
},
},
},
trustedOrigins: [
...(process.env.CORS_ORIGIN || "http://localhost:3000").split(","),
"http://localhost:4000",
],
plugins: [
// Public API anahtarları (api.sase.tr / /api/v1). Doğrulama ApiKeyGuard
// üzerinden auth.api.verifyApiKey ile yapılır; enableSessionForAPIKeys
// bilinçli olarak KAPALI — API anahtarı dashboard oturumu yerine geçmez,
// cookie tabanlı AuthGuard'ın davranışı değişmez.
apiKey({
defaultPrefix: "sase_",
enableMetadata: true,
// Anahtar başına istek tavanı (verify başına sayaç). Günlük decode
// kotasından ayrıdır — o, PublicApiQuotaService'te Redis ile tutulur.
rateLimit: {
enabled: true,
timeWindow: 60_000, // 1 dakika
maxRequests: 120,
},
}),
// Cloudflare Turnstile: yalnızca secret tanımlıysa aktif. sign-in/sign-up
// uçları "x-captcha-response" header'ındaki token ile doğrulanır.
...(process.env.TURNSTILE_SECRET_KEY
? [
captcha({
provider: "cloudflare-turnstile",
secretKey: process.env.TURNSTILE_SECRET_KEY,
endpoints: ["/sign-in/email", "/sign-up/email"],
}),
]
: []),
],
});
return authInstance;
}
export function getAuth() {
if (!authInstance) {
throw new Error("Auth not initialized. Call createAuth first.");
}
return authInstance;
}
/** apiKey plugin'inin döndürdüğü anahtar kaydı (verify/create cevapları). */
export interface ApiKeyRecord {
id: string;
userId: string;
name: string | null;
start: string | null;
prefix: string | null;
enabled: boolean;
expiresAt: Date | null;
metadata: Record<string, unknown> | null;
}
/**
* apiKey plugin uçları. `authInstance` geniş `ReturnType<typeof betterAuth>`
* ile tiplendiği için plugin uçları tipte görünmüyor (runtime'da mevcutlar);
* bu erişimci onları dar ve doğru imzalarla expose eder.
*/
export function getApiKeyApi() {
return getAuth().api as unknown as {
verifyApiKey: (input: { body: { key: string } }) => Promise<{
valid: boolean;
error: { message?: string; code: string } | null;
key: ApiKeyRecord | null;
}>;
createApiKey: (input: {
body: {
userId: string;
name?: string;
prefix?: string;
expiresIn?: number;
metadata?: Record<string, unknown>;
rateLimitEnabled?: boolean;
rateLimitTimeWindow?: number;
rateLimitMax?: number;
};
}) => Promise<ApiKeyRecord & { key: string }>;
};
}