fix: proxy MCP and expose EKAP verification gates

This commit is contained in:
2026-09-15 17:54:14 +03:00
parent 26b250735c
commit 6c32581952
7 changed files with 190 additions and 6 deletions
+17
View File
@@ -41,6 +41,23 @@ server {
proxy_connect_timeout 10s; proxy_connect_timeout 10s;
} }
# MCP JSON-RPC ucu /api öneki taşımaz. Ayrı proxy olmadan Nginx POST'u
# statik dosya isteği sayıp 405 döndürür ve Node'daki API anahtarı kontrolü
# hiç çalışmaz.
location = /mcp {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Referrer-Policy;
proxy_hide_header Permissions-Policy;
client_max_body_size 1m;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
# Statik varlıklar # Statik varlıklar
location /assets/ { location /assets/ {
root /usr/share/nginx/html; root /usr/share/nginx/html;
+2 -1
View File
@@ -12,7 +12,8 @@
"preview": "vite preview", "preview": "vite preview",
"server": "node server/index.js", "server": "node server/index.js",
"test": "node --test tests/*.test.mjs", "test": "node --test tests/*.test.mjs",
"qa:live-five": "node tests/live-five-probe.mjs" "qa:live-five": "node tests/live-five-probe.mjs",
"qa:live-integrations": "node tests/live-integrations-probe.mjs"
}, },
"dependencies": { "dependencies": {
"canvas-confetti": "^1.9.4", "canvas-confetti": "^1.9.4",
+8 -4
View File
@@ -942,7 +942,8 @@ const server = http.createServer(async (req, res) => {
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value); if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
return json(res, 200, { success: true, ...out }); return json(res, 200, { success: true, ...out });
} catch (e) { } catch (e) {
return json(res, 502, { success: false, error: e.message }); const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
} }
} }
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için // OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
@@ -957,7 +958,8 @@ const server = http.createServer(async (req, res) => {
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler }); urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
return json(res, 200, { success: true, kalemler }); return json(res, 200, { success: true, kalemler });
} catch (e) { } catch (e) {
return json(res, 502, { success: false, error: e.message }); const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
} }
} }
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) --- // --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
@@ -1053,7 +1055,8 @@ const server = http.createServer(async (req, res) => {
); );
return json(res, 200, { success: true, ...out }); return json(res, 200, { success: true, ...out });
} catch (e) { } catch (e) {
return json(res, 502, { success: false, error: e.message }); const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
} }
} }
if (url.pathname === '/api/kik/karar') { if (url.pathname === '/api/kik/karar') {
@@ -1063,7 +1066,8 @@ const server = http.createServer(async (req, res) => {
const metin = await kararMetni(id); const metin = await kararMetni(id);
return json(res, 200, { success: true, id, metin }); return json(res, 200, { success: true, id, metin });
} catch (e) { } catch (e) {
return json(res, 502, { success: false, error: e.message }); const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ const MANUAL_EKAP_URL = 'https://ekap.kik.gov.tr/EKAP/';
export function sourceFailure(error) { export function sourceFailure(error) {
const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.'; const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.';
const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED' const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED'
|| /HUMAN_VERIFICATION_REQUIRED|EKAP\s+428/i.test(rawMessage); || /HUMAN_VERIFICATION_REQUIRED|(?:EKAP|KİK)[^\n]{0,120}(?:HTTP\s*)?428|HTTP\s*428/i.test(rawMessage);
if (humanVerification) { if (humanVerification) {
return { return {
+145
View File
@@ -0,0 +1,145 @@
// D09 read-only production probe. It checks public configuration, source reads
// and unauthenticated guards. It never signs in, sends a message, creates a
// checkout, calls an AI model, changes data or submits a tender.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_INTEGRATIONS_EVIDENCE_PATH
|| 'docs/quality/evidence/qa-live-d09-integrations.json';
const timeoutMs = 45_000;
async function request(path, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${base}${path}`, {
...options,
signal: controller.signal,
headers: {
'User-Agent': 'ihaleist-live-integrations-probe/1.0',
...(options.headers || {}),
},
});
const contentType = response.headers.get('content-type') || '';
const body = await response.text();
let json = null;
if (contentType.includes('json')) {
try { json = JSON.parse(body); } catch { /* malformed JSON stays visible as a failed check */ }
}
return {
path,
method: options.method || 'GET',
status: response.status,
contentType,
json,
textChars: body.length,
error: response.ok ? null : String(json?.error?.message || json?.error || body || `HTTP ${response.status}`).slice(0, 300),
};
} catch (error) {
return { path, method: options.method || 'GET', status: 0, contentType: '', json: null, textChars: 0, error: error?.message || String(error) };
} finally {
clearTimeout(timer);
}
}
const raw = {};
for (const [name, path, options] of [
['google', '/api/auth/google/config'],
['tcmb', '/api/exchange-rates'],
['documents', '/api/documents/config'],
['ai', '/api/ai/config'],
['billing', '/api/billing/plans'],
['telegram', '/api/health/takip'],
['scheduler', '/api/health/scheduler'],
['items', '/api/health/kalem'],
['embedding', '/api/health/uyum'],
['bik', '/api/stats'],
['history', '/api/history/stats'],
['resultsRss', '/api/feeds/sonuc.rss?limit=1'],
['protectedRss', '/api/feeds/tenders.rss'],
['manifest', '/manifest.webmanifest'],
['serviceWorker', '/sw.js'],
['ted', '/api/ted?mod=turkiye&since=20990101&limit=1'],
['kik', '/api/kik/search?limit=1'],
['okas', '/api/okas/ara?q=hemogram'],
['mcpGuard', '/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) }],
]) {
raw[name] = await request(path, options);
}
const guardPaths = {
authSession: ['/api/auth/me'],
documents: ['/api/me/documents'],
documentText: ['/api/documents/text?ikn=2026%2F1634449'],
apiKeys: ['/api/me/apikeys'],
emailConfig: ['/api/notify/email/config'],
plan: ['/api/me/plan'],
subscriptions: ['/api/notify/subscriptions'],
sourceHistory: ['/api/tenders/source-history?ikn=2026%2F1634449'],
companyScan: ['/api/company/scan', { method: 'POST' }],
aiAnalyze: ['/api/ai/analyze', { method: 'POST' }],
checkout: ['/api/billing/checkout', { method: 'POST' }],
emailSend: ['/api/notify/email/test', { method: 'POST' }],
telegramMutation: ['/api/me/telegram', { method: 'POST' }],
notificationRun: ['/api/notify/run', { method: 'POST' }],
};
const guards = {};
for (const [name, [path, options]] of Object.entries(guardPaths)) {
const probe = await request(path, options);
guards[name] = { status: probe.status, passed: [401, 403].includes(probe.status) };
}
function row(id, label, status, evidence, remaining) {
return { id, label, status, evidence, remaining };
}
const googleConfigured = raw.google.status === 200 && Boolean(raw.google.json?.clientId);
const tcmbReady = raw.tcmb.status === 200 && raw.tcmb.json?.success === true
&& Number(raw.tcmb.json?.rates?.USD) > 0 && Number(raw.tcmb.json?.rates?.EUR) > 0;
const aiConfigured = raw.ai.status === 200 && raw.ai.json?.configured === true;
const billingConfigured = raw.billing.status === 200 && raw.billing.json?.configured === true;
const billingWebhookReady = raw.billing.status === 200 && raw.billing.json?.webhookHazir === true;
const telegramConfigured = raw.telegram.status === 200 && raw.telegram.json?.telegram === true;
const kikManualGate = raw.kik.status === 428 && raw.kik.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
const ekapManualGate = raw.okas.status === 428 && raw.okas.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
const integrations = [
row('BIK', 'ilan.gov.tr ihale havuzu', raw.bik.status === 200 && raw.bik.json?.success ? 'passed' : 'blocked', { status: raw.bik.status, source: raw.bik.json?.kaynak || null, loaded: raw.bik.json?.yuklenenIlan ?? null }, 'Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir.'),
row('TCMB', 'TCMB döviz satış kurları', tcmbReady ? 'passed' : 'blocked', { status: raw.tcmb.status, bulletinDate: raw.tcmb.json?.bulletinDate || null, source: raw.tcmb.json?.source || null, cached: raw.tcmb.json?.cached ?? null }, tcmbReady ? 'Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor.' : raw.tcmb.error),
row('GOOGLE', 'Google ile giriş', googleConfigured ? 'configured' : 'blocked', { status: raw.google.status, configured: googleConfigured }, 'Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli.'),
row('EKAP', 'EKAP arama ve resmi doküman', raw.okas.status === 200 ? 'partial' : ekapManualGate ? 'manual-gate' : 'blocked', { browserAvailable: raw.documents.json?.tarayiciVar ?? null, okasStatus: raw.okas.status, okasCode: raw.okas.json?.code || null, d10DocumentCode: 'HUMAN_VERIFICATION_REQUIRED' }, 'Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli.'),
row('KIK', 'KİK kurul kararları', raw.kik.status === 200 && raw.kik.json?.success ? 'passed' : kikManualGate ? 'manual-gate' : 'blocked', { status: raw.kik.status, code: raw.kik.json?.code || null, resultCount: raw.kik.json?.results?.length ?? null, error: raw.kik.error }, raw.kik.status === 200 ? 'Bir kararın ayrıntı bağlantısı ayrıca kullanıcı akışında açılmalı.' : kikManualGate ? 'EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli.' : 'Kaynak erişimi düzeltildikten sonra arama ve karar metni tekrar denenmeli.'),
row('TED', 'TED Avrupa ihaleleri', raw.ted.status === 200 && raw.ted.json?.success ? 'passed' : 'blocked', { status: raw.ted.status, totalCount: raw.ted.json?.totalCount ?? null, timedOut: raw.ted.json?.timedOut ?? null, error: raw.ted.error }, raw.ted.status === 200 ? 'Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı.' : 'TED kaynağı tekrar denenmeli.'),
row('AI', 'AI analiz ve teklif yardımcıları', aiConfigured ? 'configured' : 'blocked', { status: raw.ai.status, configured: aiConfigured, providerCount: Array.isArray(raw.ai.json?.providers) ? raw.ai.json.providers.length : 0 }, 'Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli.'),
row('STRIPE', 'Stripe plan, ödeme ve webhook', billingConfigured && billingWebhookReady ? 'configured' : 'blocked', { status: raw.billing.status, checkoutConfigured: billingConfigured, webhookConfigured: billingWebhookReady, planCount: Array.isArray(raw.billing.json?.plans) ? raw.billing.json.plans.length : 0 }, 'Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli.'),
row('EMAIL', 'E-posta bildirimleri', guards.emailConfig?.passed ? 'guarded' : 'blocked', { unauthenticatedStatus: guards.emailConfig?.status ?? null }, 'Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı.'),
row('TELEGRAM', 'Telegram bağlantısı ve bildirim', telegramConfigured ? 'configured' : 'blocked', { status: raw.telegram.status, configured: telegramConfigured }, 'Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli.'),
row('PWA', 'PWA manifest ve offline worker', raw.manifest.status === 200 && raw.serviceWorker.status === 200 ? 'passed' : 'blocked', { manifestStatus: raw.manifest.status, serviceWorkerStatus: raw.serviceWorker.status }, 'Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir.'),
row('API', 'API anahtarı, RSS ve MCP', raw.protectedRss.status === 401 && raw.mcpGuard.status === 401 && guards.apiKeys?.passed ? 'guarded' : 'blocked', { apiKeyGuard: guards.apiKeys?.status ?? null, rssGuard: raw.protectedRss.status, mcpGuard: raw.mcpGuard.status, publicResultsRssStatus: raw.resultsRss.status }, 'Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli.'),
row('SCHEDULER', 'Zeyilname/sonuç zamanlayıcısı', raw.scheduler.status === 200 && raw.scheduler.json?.success ? 'observable' : 'blocked', { status: raw.scheduler.status, browserAvailable: raw.scheduler.json?.tarayiciVar ?? null, resultScanActive: raw.scheduler.json?.sonucTaramasi?.aktif ?? null, resultScanRunning: raw.scheduler.json?.sonucTaramasi?.calisiyor ?? null, directProcurementActive: raw.scheduler.json?.dogrudanTemin?.aktif ?? null, directProcurementRunning: raw.scheduler.json?.dogrudanTemin?.calisiyor ?? null }, 'Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı.'),
];
const result = {
checkedAt: new Date().toISOString(),
base,
readOnly: true,
scope: 'Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.',
integrations,
guards,
summary: {
total: integrations.length,
passed: integrations.filter(({ status }) => status === 'passed').length,
configured: integrations.filter(({ status }) => status === 'configured').length,
guarded: integrations.filter(({ status }) => status === 'guarded').length,
observable: integrations.filter(({ status }) => status === 'observable').length,
manualGate: integrations.filter(({ status }) => status === 'manual-gate').length,
blocked: integrations.filter(({ status }) => status === 'blocked').length,
protectedGuardsPassed: Object.values(guards).filter(({ passed }) => passed).length,
protectedGuardsTotal: Object.keys(guards).length,
},
};
await mkdir(dirname(evidencePath), { recursive: true });
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({ checkedAt: result.checkedAt, ...result.summary, statuses: Object.fromEntries(integrations.map(({ id, status }) => [id, status])) }));
+10
View File
@@ -0,0 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
test('Nginx proxies MCP JSON-RPC requests to the authenticated Node endpoint', () => {
const config = readFileSync(new URL('../nginx.conf', import.meta.url), 'utf8');
const block = /location\s*=\s*\/mcp\s*\{([\s\S]*?)\n\s*\}/.exec(config)?.[1] || '';
assert.match(block, /proxy_pass\s+http:\/\/127\.0\.0\.1:3001;/);
assert.match(block, /client_max_body_size\s+1m;/);
});
+7
View File
@@ -22,3 +22,10 @@ test('ordinary upstream failures stay retryable', () => {
retryable: true, retryable: true,
}); });
}); });
test('KIK HTTP 428 is also exposed as the same manual verification gate', () => {
const result = sourceFailure(new Error('KİK sunucusu isteği reddetti (HTTP 428).'));
assert.equal(result.status, 428);
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
assert.equal(result.body.manualAction, true);
});