Enforce API key quota within the database transaction

This commit is contained in:
2026-09-06 10:40:45 +03:00
parent 1c0b48655e
commit 1a62a876e6
5 changed files with 63 additions and 11 deletions
+8
View File
@@ -623,3 +623,11 @@ Taslak bölümleri istenen başlıklara göre, kaynak sırasıyla birleştirilir
Doğrulama: 50 bin karakteri aşan kaynakta tüm karakterlerin sağlayıcı yardımcı çağrılarına kayıpsız ulaşması, son koşulun birleşik bölümde korunması, ara parça hatasının görünürlüğü, iptalde sonraki grupların durması. Sağlayıcı yanıtları yerel test taklididir; gerçek uzun belge üretim kalitesi/tarayıcı kabulü değildir. Tüm 135 test ve üretim derlemesi geçti. Doğrulama: 50 bin karakteri aşan kaynakta tüm karakterlerin sağlayıcı yardımcı çağrılarına kayıpsız ulaşması, son koşulun birleşik bölümde korunması, ara parça hatasının görünürlüğü, iptalde sonraki grupların durması. Sağlayıcı yanıtları yerel test taklididir; gerçek uzun belge üretim kalitesi/tarayıcı kabulü değildir. Tüm 135 test ve üretim derlemesi geçti.
Sınırlar: Bir parçanın işlenmesi tüm şartlarının anlam bakımından taslağa doğru aktarıldığını kanıtlamaz. İki dakikalık istemci sınırı ile kalıcı iş/sürdürme ihtiyacı açık; gerçek firma ve beş resmi dosya kabulü yapılmadı. Büyük paket uyarıları sürüyor; canlı yayın yok. Sınırlar: Bir parçanın işlenmesi tüm şartlarının anlam bakımından taslağa doğru aktarıldığını kanıtlamaz. İki dakikalık istemci sınırı ile kalıcı iş/sürdürme ihtiyacı açık; gerçek firma ve beş resmi dosya kabulü yapılmadı. Büyük paket uyarıları sürüyor; canlı yayın yok.
### D09 — API anahtarı kotasının işlem bütünlüğü
Önceki uç nokta anahtar sayısını okuyup kotayı kontrol ettikten sonra istek gövdesini bekliyordu; bekleyen eşzamanlı istekler aynı boş kapasiteyi kullanabilirdi. Sayım, kota denetimi ve INSERT artık BEGIN IMMEDIATE işlemi içinde; başarısızlık geri alınır. İptal edilmiş anahtarlar aktif kotaya katılmaz. Sunucudaki güncel plan limiti kayıt işlevine aktarılır.
Bozuk JSON'un boş gövdeye çevrilip varsayılan anahtar oluşturması kaldırıldı. Anahtar adı metin ve en fazla 120 karakter olmalı; çevresindeki boşluklar temizlenir. GET/POST/DELETE dışındaki metotlar 405 döner.
Doğrulama: yerel izole HTTP testinde eşzamanlı 16 oluşturma isteğinden tam 10 başarı, 6 kota reddi; bir anahtar iptal edilince yeni kayıt mümkün. Bozuk JSON/geçersiz ad kayıt oluşturmuyor. Gerçek SQLite testinde kota reddi sonrası işlem kapanıyor, iptal ve yeni kayıt çalışıyor. Tüm 137 test geçti; ön yüz değişmediğinden derleme tekrarlanmadı. Farklı sunucu süreçlerinden yük testi yapılmadı. Planın diğer kabul işleri ve canlı yayın açık.
+3 -4
View File
@@ -1174,10 +1174,8 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST') { if (req.method === 'POST') {
try { try {
const mevcut = store.listApiKeys(user.email).length; const body = await readBody(req);
kotaKontrol(user.email, 'apiAnahtari', mevcut, 1, { isAdmin: user.isAdmin }); const k = store.createApiKey(user.email, body.ad, { limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
const body = await readBody(req).catch(() => ({}));
const k = store.createApiKey(user.email, body.ad);
// `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır // `anahtar` YALNIZCA burada döner; sunucuda yalnızca hash'i saklanır
return json(res, 200, { success: true, anahtar: k }); return json(res, 200, { success: true, anahtar: k });
} catch (e) { } catch (e) {
@@ -1188,6 +1186,7 @@ const server = http.createServer(async (req, res) => {
const ok = store.revokeApiKey(user.email, url.searchParams.get('id')); const ok = store.revokeApiKey(user.email, url.searchParams.get('id'));
return json(res, ok ? 200 : 404, { success: ok, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari }); return json(res, ok ? 200 : 404, { success: ok, error: ok ? undefined : 'Anahtar bulunamadı veya bu hesaba ait değil.', anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
} }
if (req.method !== 'GET') return json(res, 405, { success: false, error: 'Desteklenmeyen metot.' });
return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari }); return json(res, 200, { success: true, anahtarlar: store.listApiKeys(user.email), limit: haklar(user.email, { isAdmin: user.isAdmin }).apiAnahtari });
} }
+21 -6
View File
@@ -224,13 +224,28 @@ export function setData(email, anahtar, deger) {
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex'); const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
/** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */ /** Yeni anahtar üretir. Ham değer YALNIZCA bir kez döner, saklanmaz. */
export function createApiKey(email, ad) { export function createApiKey(email, ad, { limit = null } = {}) {
if (ad != null && typeof ad !== 'string') throw new Error('Anahtar adı metin olmalı.');
const name = ad?.trim() || 'API Anahtarı';
if (name.length > 120) throw new Error('Anahtar adı en fazla 120 karakter olabilir.');
if (limit !== null && (!Number.isInteger(limit) || limit < 0)) throw new Error('Geçersiz anahtar kotası.');
return withDb((db) => {
db.exec('BEGIN IMMEDIATE');
try {
const count = db.prepare('SELECT COUNT(*) n FROM api_anahtarlari WHERE email=? AND iptal=0').get(email).n;
if (limit !== null && count >= limit) {
const error = new Error(`Planınızda en fazla ${limit} API anahtarı oluşturabilirsiniz. Kullanılmayan bir anahtarı iptal edin veya Planlar sekmesini kontrol edin.`);
error.kod = 'KOTA_ASILDI';
throw error;
}
const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url'); const ham = 'ih_live_' + crypto.randomBytes(24).toString('base64url');
const id = crypto.randomUUID(); const id = crypto.randomUUID(), olusturma = now();
withDb((db) => db.prepare( db.prepare('INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)')
'INSERT INTO api_anahtarlari (id,email,ad,onek,hash,olusturma) VALUES (?,?,?,?,?,?)' .run(id, email, name, ham.slice(0, 12), sha256(ham), olusturma);
).run(id, email, ad || 'API Anahtarı', ham.slice(0, 12), sha256(ham), now())); db.exec('COMMIT');
return { id, ad: ad || 'API Anahtarı', onek: ham.slice(0, 12), anahtar: ham, olusturma: now() }; return { id, ad:name, onek:ham.slice(0,12), anahtar:ham, olusturma };
} catch (error) { db.exec('ROLLBACK'); throw error; }
});
} }
export function listApiKeys(email) { export function listApiKeys(email) {
+17
View File
@@ -155,3 +155,20 @@ test('HTTP API key creation, private listing, MCP use and revocation form a comp
assert.deepEqual(revoked.data.anahtarlar,[]); assert.deepEqual(revoked.data.anahtarlar,[]);
assert.equal((await request('/mcp','POST',rpc,anahtar)).status,401); assert.equal((await request('/mcp','POST',rpc,anahtar)).status,401);
}); });
test('concurrent API key requests never exceed quota and malformed input creates nothing',async()=>{
const auth=signToken({email:'quota-http@example.test',name:'Quota QA',isAdmin:true});
const pending=Array.from({length:16},(_,i)=>request('/api/me/apikeys','POST',{ad:`QA ${i}`},auth));
const results=await Promise.all(pending);
assert.equal(results.filter(r=>r.status===200).length,10);
assert.equal(results.filter(r=>r.status===402&&r.data.kod==='KOTA_ASILDI').length,6);
const keys=await request('/api/me/apikeys','GET',null,auth);
assert.equal(keys.data.anahtarlar.length,10);
await request('/api/me/apikeys?id='+keys.data.anahtarlar[0].id,'DELETE',null,auth);
const invalid=await fetch(base+'/api/me/apikeys',{method:'POST',headers:{Authorization:'Bearer '+auth,'Content-Type':'application/json'},body:'{broken'});
assert.equal(invalid.status,400);
assert.equal((await request('/api/me/apikeys','POST',{ad:{}},auth)).status,400);
assert.equal((await request('/api/me/apikeys','GET',null,auth)).data.anahtarlar.length,9);
assert.equal((await request('/api/me/apikeys','POST',{ad:'replacement'},auth)).status,200);
assert.equal((await request('/api/me/apikeys','PUT',{},auth)).status,405);
});
+13
View File
@@ -56,3 +56,16 @@ test('per-tender versions prevent stale updates; five tasks persist', () => {
assert.equal(Object.keys(store.getTenderStates('c@example.test')).length,5); assert.equal(Object.keys(store.getTenderStates('c@example.test')).length,5);
assert.deepEqual(store.getTenderStates('d@example.test'),{}); assert.deepEqual(store.getTenderStates('d@example.test'),{});
}); });
test('API key quota is transactional and revoked slots can be reused', () => {
const email='quota@example.test';
const key=store.createApiKey(email,' ERP ',{limit:1});
assert.equal(key.ad,'ERP');
assert.throws(()=>store.createApiKey(email,'excess',{limit:1}), {kod:'KOTA_ASILDI'});
assert.equal(store.listApiKeys(email).length,1);
assert.equal(store.revokeApiKey(email,key.id),true);
assert.ok(store.createApiKey(email,'replacement',{limit:1}).id);
assert.equal(store.listApiKeys(email).length,1);
for(const ad of [{},[],42,'x'.repeat(121)])assert.throws(()=>store.createApiKey('invalid-name@example.test',ad));
assert.deepEqual(store.listApiKeys('invalid-name@example.test'),[]);
});