feat: sunucu tarafı hesap altyapısı — Stripe webhook, kotalar, gerçek API anahtarı ve belge deposu

Denetlenen 5 teknik borç kalemi de kapatıldı:

1) Stripe webhook + gerçek yetkilendirme (ÖNCEDEN YOKTU)
   - POST /api/billing/webhook — SDK'sız HMAC-SHA256 imza doğrulaması,
     tekrar saldırısına karşı ±300sn zaman penceresi, timingSafeEqual
   - checkout.session.completed / subscription.created|updated|deleted işlenir
   - Ödeme → kullanıcının planı sunucuda güncellenir (önce ödeme hiçbir şeyi
     değiştirmiyordu)

2) Plan kotaları GERÇEKTEN uygulanıyor (önce sadece tanımlıydı)
   - server/entitlements.mjs: plan hakları + kotaKontrol()
   - ekip, belge adedi/boyutu, API anahtarı kotaları sunucuda zorlanır
   - Kota aşımında 402 + anlaşılır Türkçe mesaj; arayüzde KotaUyarisi bandı
   - /api/me/plan: plan, haklar ve gerçek kullanım

3) Kullanıcı verisi localStorage'dan sunucuya taşındı
   - server/user-store.mjs: kullanici_verisi tablosu (izinli anahtar listesi)
   - AppContext girişte sunucudan yükler, değişiklikleri sunucuya yazar;
     localStorage yalnızca çevrimdışı önbellek
   - Artık cihaz değişince veri kaybolmuyor

4) API anahtarları gerçek (önce Math.random ile tarayıcıda üretiliyordu)
   - Sunucuda crypto.randomBytes ile üretilir, YALNIZCA sha256 hash'i saklanır
   - Ham değer bir kez gösterilir; verifyApiKey ile doğrulanır, son kullanım
     izlenir, iptal anında geçerli olur

5) Belge deposu gerçek (önce boyut Math.random'dı, dosya hiç yüklenmiyordu)
   - Dosyalar kalıcı volume'e yazılır, boyut GERÇEK bayt
   - Auth'lu indirme (blob), silme diskten de siler, kota sunucuda uygulanır
This commit is contained in:
2026-08-29 10:40:05 +03:00
parent f73c7bd6cb
commit f04401031c
10 changed files with 1008 additions and 268 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* Plan hakları ve kota uygulaması.
* Önceden billing.mjs'te `sinirlar` tanımlıydı ama HİÇBİR yerde kontrol
* edilmiyordu — herkes her şeyi kullanabiliyordu. Burada gerçekten uygulanır.
*/
import { getUser, documentUsage, listApiKeys, getData } from './user-store.mjs';
export const PLAN_HAKLARI = {
ucretsiz: {
ad: 'Ücretsiz',
takip: 10, // takip edilebilecek ihale
ekip: 1, // ekip üyesi
bildirimKriteri: 1, // otomatik bildirim kriteri
belgeAdet: 5,
belgeBayt: 25 * 1024 * 1024,
apiAnahtari: 0,
ozellikler: ['arama', 'favori', 'push'],
},
pro: {
ad: 'Pro',
takip: null, // sınırsız
ekip: 5,
bildirimKriteri: 10,
belgeAdet: 200,
belgeBayt: 2 * 1024 * 1024 * 1024,
apiAnahtari: 0,
ozellikler: ['arama', 'favori', 'push', 'kanban', 'rakip', 'fiyat', 'ai', 'pdf', 'eposta'],
},
kurumsal: {
ad: 'Kurumsal',
takip: null,
ekip: null,
bildirimKriteri: null,
belgeAdet: null,
belgeBayt: 20 * 1024 * 1024 * 1024,
apiAnahtari: 10,
ozellikler: ['arama', 'favori', 'push', 'kanban', 'rakip', 'fiyat', 'ai', 'pdf', 'eposta', 'kirmizibayrak', 'kik', 'ted', 'api'],
},
};
/** Kullanıcının etkin planı (süresi dolmuşsa ücretsize düşer). */
export function planOf(email, { isAdmin = false } = {}) {
if (isAdmin) return 'kurumsal'; // yöneticiler tam erişimli
const u = getUser(email);
if (!u) return 'ucretsiz';
if (u.plan_bitis && new Date(u.plan_bitis) < new Date()) return 'ucretsiz';
return PLAN_HAKLARI[u.plan] ? u.plan : 'ucretsiz';
}
export function haklar(email, opts) {
return PLAN_HAKLARI[planOf(email, opts)] || PLAN_HAKLARI.ucretsiz;
}
export function ozellikVar(email, ozellik, opts) {
return haklar(email, opts).ozellikler.includes(ozellik);
}
/** Mevcut kullanım — arayüzde "3/10" göstermek için. */
export function kullanim(email, opts) {
const h = haklar(email, opts);
const belge = documentUsage(email);
const ekip = (getData(email, 'ekip') || []).length;
const takip = (getData(email, 'kanban') || []).filter?.((t) => t?.takipte)?.length ?? null;
return {
plan: planOf(email, opts),
planAdi: h.ad,
haklar: h,
kullanilan: {
belgeAdet: belge.adet,
belgeBayt: belge.bayt,
ekip,
apiAnahtari: listApiKeys(email).length,
takip,
},
};
}
/**
* Kota kontrolü. Aşılırsa açıklayıcı hata fırlatır.
* @param {string} tur 'ekip' | 'belgeAdet' | 'belgeBayt' | 'apiAnahtari' | 'bildirimKriteri' | 'takip'
*/
export function kotaKontrol(email, tur, mevcutSayi, ekleme = 1, opts) {
const h = haklar(email, opts);
const limit = h[tur];
if (limit === null || limit === undefined) return; // sınırsız
if (mevcutSayi + ekleme > limit) {
const isim = {
ekip: 'ekip üyesi', belgeAdet: 'belge', belgeBayt: 'belge alanı',
apiAnahtari: 'API anahtarı', bildirimKriteri: 'bildirim kriteri', takip: 'ihale takibi',
}[tur] || tur;
const e = new Error(`${h.ad} planında en fazla ${limit} ${isim} hakkınız var. Yükseltmek için Planlar sekmesine bakın.`);
e.kod = 'KOTA_ASILDI';
e.limit = limit;
e.plan = h.ad;
throw e;
}
}