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>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
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 }>;
|
||
};
|
||
}
|