From afb02360ee947445aa4ae97dcc32a57dcdc5caa1 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Thu, 30 Jul 2026 00:03:43 +0300 Subject: [PATCH 1/3] Add MCP dashboard consent flow Add the human OAuth consent surface for Orbit Remote MCP, including signed-ticket inspection, active agent selection, one-time delegation callback, URL-fragment cleanup, session-only ticket storage, active-agent revalidation, connected-app listing and immediate grant revocation. Verified by the full D1, site and Chromium regression suite. --- scripts/orbit-browser-tests.mjs | 154 ++++++++++++++++++++++++++- scripts/orbit-site-tests.mjs | 15 +++ scripts/orbit-slice1-tests.ts | 29 +++++ src/pages/dashboard.astro | 55 +++++++++- src/scripts/dashboard.js | 182 +++++++++++++++++++++++++++++++- src/server/http/api.ts | 31 +++++- 6 files changed, 457 insertions(+), 9 deletions(-) diff --git a/scripts/orbit-browser-tests.mjs b/scripts/orbit-browser-tests.mjs index a01343f..81941cb 100644 --- a/scripts/orbit-browser-tests.mjs +++ b/scripts/orbit-browser-tests.mjs @@ -119,6 +119,19 @@ const seleneEditorialCount = searchIndex.items.filter((item) => ( normalizeSearchText(item.searchText).includes('selene') && item.topics.includes('editoryal') )).length; const agentTopicRecordCount = searchIndex.items.filter((item) => item.entity === 'record' && item.topics.includes('ajanlar')).length; +const browserMcpTicket = 'orb_mcp_auth_v1.browser-payload.browser-signature'; +const browserMcpAuthorizationRequestId = '550e8400-e29b-41d4-a716-446655440000'; +const browserMcpDelegationCode = 'orb_mcp_v1_browser_selector_browser_secret'; +const browserMcpAuthorizationBodies = []; + +const readRequestJson = (request) => new Promise((resolve, reject) => { + let value = ''; + request.on('data', (chunk) => { value += chunk; }); + request.on('end', () => { + try { resolve(value ? JSON.parse(value) : {}); } catch (error) { reject(error); } + }); + request.on('error', reject); +}); if (errors.length === 0) { const server = http.createServer((request, response) => { @@ -167,10 +180,70 @@ if (errors.length === 0) { 'content-type': 'application/json; charset=utf-8', }); response.end(JSON.stringify(owner - ? { account: { roles: ['platform_owner'] } } + ? { + account: { + roles: ['platform_owner'], + agentQuota: -1, + displayName: 'Orbit Owner', + handle: 'orbit-owner', + avatarUrl: null, + }, + sponsoredAgents: browserAgents, + } : { error: { code: 'authentication_required', message: 'A valid session is required.' } })); return; } + if (request.method === 'POST' && pathname === '/v1/mcp/authorization-tickets/inspect') { + readRequestJson(request).then((body) => { + const valid = request.headers.cookie?.includes('orbit-owner-test=1') + && body.ticket === browserMcpTicket; + response.writeHead(valid ? 200 : 401, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify(valid ? { + authorizationRequest: { + id: browserMcpAuthorizationRequestId, + oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' }, + scopes: ['feed:read'], + issuedAt: Date.now(), + expiresAt: Date.now() + 10 * 60 * 1000, + }, + manageableAgents: browserAgents.map((agent) => ({ + ...agent, + displayName: agent.handle[0].toUpperCase() + agent.handle.slice(1), + publicationMode: 'direct_publish', + status: 'active', + onboardingState: 'active', + })), + } : { error: { code: 'authentication_required', message: 'A valid session is required.' } })); + }).catch(() => { + response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: { code: 'invalid_json', message: 'Invalid JSON.' } })); + }); + return; + } + if (request.method === 'POST' && pathname === '/v1/mcp/authorizations') { + readRequestJson(request).then((body) => { + browserMcpAuthorizationBodies.push(body); + response.writeHead(201, { + 'cache-control': 'no-store', + 'content-type': 'application/json; charset=utf-8', + }); + response.end(JSON.stringify({ + authorization: { id: 'grant-browser', status: 'active' }, + delegation: { + code: browserMcpDelegationCode, + authorizationRequestId: browserMcpAuthorizationRequestId, + expiresAt: Date.now() + 5 * 60 * 1000, + }, + })); + }).catch(() => { + response.writeHead(400, { 'content-type': 'application/json; charset=utf-8' }); + response.end(JSON.stringify({ error: { code: 'invalid_json', message: 'Invalid JSON.' } })); + }); + return; + } if (request.method === 'POST' && /^\/v1\/manage\/records\/[^/]+\/delete$/u.test(pathname)) { response.writeHead(200, { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify({ @@ -569,6 +642,85 @@ if (errors.length === 0) { await context.close(); })); + { + const context = await browser.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'light' }); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto( + `${baseUrl}/dashboard#mcp_authorization=${encodeURIComponent(browserMcpTicket)}`, + { waitUntil: 'load' }, + ); + await page.waitForSelector('#login:not(.hidden)'); + const anonymousConsent = await page.evaluate((storageKey) => ({ + hash: location.hash, + ticket: sessionStorage.getItem(storageKey), + title: document.querySelector('#login-title')?.textContent?.trim(), + consentVisible: !document.querySelector('#mcp-consent')?.classList.contains('hidden'), + }), 'orbit_mcp_authorization_ticket_v1'); + check(anonymousConsent.hash === '', 'MCP anonim girişinde ticket URL fragmentından temizlenmedi.'); + check(anonymousConsent.ticket === browserMcpTicket, 'MCP anonim girişinde ticket sekme oturumunda korunmadı.'); + check(anonymousConsent.title === 'Bağlantıyı onaylamak için giriş yap.', 'MCP anonim giriş açıklaması gösterilmedi.'); + check(anonymousConsent.consentVisible === false, 'MCP consent oturum açılmadan görünür oldu.'); + check(pageErrors.length === 0, `MCP anonim girişinde sayfa hatası: ${pageErrors.join(' | ')}`); + await context.close(); + } + + { + const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'light' }); + await context.addCookies([{ + name: 'orbit-owner-test', + value: '1', + url: baseUrl, + }]); + await context.route('https://mcp.orbit.sametbasbug.dev/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'text/html; charset=utf-8', + body: 'Orbit MCP callback', + }); + }); + const page = await context.newPage(); + const pageErrors = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto( + `${baseUrl}/dashboard#mcp_authorization=${encodeURIComponent(browserMcpTicket)}`, + { waitUntil: 'load' }, + ); + await page.waitForSelector('#mcp-consent:not(.hidden)'); + const consentState = await page.evaluate(() => ({ + hash: location.hash, + client: document.querySelector('#mcp-client-summary')?.textContent?.trim(), + scope: document.querySelector('#mcp-scope-summary')?.textContent?.trim(), + options: [...document.querySelectorAll('#mcp-agent-select option')].map((option) => ({ + value: option.value, + label: option.textContent?.trim(), + })), + approveDisabled: document.querySelector('#mcp-approve')?.disabled, + })); + check(consentState.hash === '', 'MCP owner consentinde ticket URL fragmentından temizlenmedi.'); + check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.'); + check(consentState.scope?.includes('feed:read'), 'MCP consent least-privilege kapsamını göstermiyor.'); + check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.'); + check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.'); + check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.'); + + await page.locator('#mcp-agent-select').selectOption('agent-selene'); + await page.locator('#mcp-approve').click(); + await page.waitForURL((url) => ( + url.origin === 'https://mcp.orbit.sametbasbug.dev' + && url.pathname === '/oauth/orbit/callback' + )); + const callback = new URL(page.url()); + check(callback.searchParams.get('code') === browserMcpDelegationCode, 'MCP consent callback tek kullanımlık kodu taşımıyor.'); + check(callback.searchParams.get('authorization_request_id') === browserMcpAuthorizationRequestId, 'MCP consent callback istek kimliğini taşımıyor.'); + check(browserMcpAuthorizationBodies.length === 1, 'MCP consent grant isteğini tam bir kez göndermedi.'); + check(browserMcpAuthorizationBodies[0]?.agentId === 'agent-selene', 'MCP consent seçilen ajanı grant isteğine bağlamadı.'); + check(browserMcpAuthorizationBodies[0]?.ticket === browserMcpTicket, 'MCP consent imzalı ticketı grant isteğine taşımadı.'); + check(pageErrors.length === 0, `MCP owner consentinde sayfa hatası: ${pageErrors.join(' | ')}`); + await context.close(); + } + for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) { const label = `${viewport.width}x${viewport.height} owner`; const context = await browser.newContext({ viewport, colorScheme: 'light' }); diff --git a/scripts/orbit-site-tests.mjs b/scripts/orbit-site-tests.mjs index 1c30bcd..7562f8d 100644 --- a/scripts/orbit-site-tests.mjs +++ b/scripts/orbit-site-tests.mjs @@ -152,6 +152,11 @@ if (fs.existsSync(dashboardFile)) { check(dashboardHtml.includes('public profilinde “İnsanı” olarak görünür'), 'Dashboard GitHub insan bağlantısının public olacağını açıklamıyor.'); check(dashboardHtml.includes('Yayın incelemeleri'), 'Dashboard moderator yayın kuyruğunu taşımıyor.'); check(dashboardHtml.includes('Metin değiştirilemez'), 'Dashboard moderatorün içeriği düzenleyemeyeceğini açıklamıyor.'); + check(dashboardHtml.includes('Bağlantıyı onayla'), 'Dashboard MCP yetkilendirme ekranını taşımıyor.'); + check(dashboardHtml.includes('id="mcp-agent-select"'), 'Dashboard MCP ajan seçimini taşımıyor.'); + check(dashboardHtml.includes('uzun ömürlü API anahtarı'), 'Dashboard MCP credential güvenlik sınırını açıklamıyor.'); + check(dashboardHtml.includes('Bağlı uygulamalar'), 'Dashboard MCP grant yönetim kartını taşımıyor.'); + check(dashboardHtml.includes('id="mcp-authorizations"'), 'Dashboard MCP grant listesini taşımıyor.'); check(!dashboardHtml.includes('orb_agent_v1_'), 'Dashboard build çıktısı ajan credential kalıbı içeriyor.'); } const dashboardScript = fs.readFileSync(path.join(ROOT, 'src', 'scripts', 'dashboard.js'), 'utf8'); @@ -159,6 +164,16 @@ check(dashboardScript.includes("roles.includes('moderator')"), 'Dashboard modera check(dashboardScript.includes("loadApprovals()"), 'Dashboard moderator yayın kuyruğunu yüklemiyor.'); check(dashboardScript.includes("review-approve').addEventListener"), 'Dashboard yayın onay düğmesini bağlamıyor.'); check(dashboardScript.includes("review-reject').addEventListener"), 'Dashboard yayın ret düğmesini bağlamıyor.'); +check(dashboardScript.includes("history.replaceState"), 'Dashboard MCP ticket fragmentını adres çubuğundan temizlemiyor.'); +check(dashboardScript.includes("sessionStorage.setItem(MCP_TICKET_STORAGE_KEY"), 'Dashboard MCP ticketını yalnız sekme oturumunda korumuyor.'); +check(!dashboardScript.includes("localStorage.setItem(MCP_TICKET_STORAGE_KEY"), 'Dashboard MCP ticketını kalıcı depolamaya yazıyor.'); +check(dashboardScript.includes("/v1/mcp/authorization-tickets/inspect"), 'Dashboard imzalı MCP ticketını doğrulamıyor.'); +check(dashboardScript.includes("/v1/mcp/authorizations"), 'Dashboard MCP grant oluşturma ucuna bağlanmıyor.'); +check(dashboardScript.includes("https://mcp.orbit.sametbasbug.dev/oauth/orbit/callback"), 'Dashboard sabit MCP callback hedefine bağlı değil.'); +check(dashboardScript.includes("mcp-approve').addEventListener"), 'Dashboard MCP onay düğmesini bağlamıyor.'); +check(dashboardScript.includes("mcp-deny').addEventListener"), 'Dashboard MCP ret düğmesini bağlamıyor.'); +check(dashboardScript.includes("loadMcpAuthorizations()"), 'Dashboard MCP grant listesini yüklemiyor.'); +check(dashboardScript.includes("/v1/mcp/authorizations/${encodeURIComponent(authorization.id)}/revoke"), 'Dashboard MCP grant iptal ucuna bağlanmıyor.'); check(!fs.existsSync(path.join(DIST_DIR, 'projects', 'index.html')), 'Kaldırılan Projeler rotası build çıktısında kaldı.'); check(fs.existsSync(path.join(DIST_DIR, 'topics', 'index.html')), 'Konular rotası build çıktısında yok.'); for (const topic of ['orbit', 'ajanlar', 'editoryal', 'sistemler']) { diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index ea6c3bb..63af440 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -531,10 +531,23 @@ let firstCredentialToken = ''; assert.equal(inspected.status, 200, await inspected.clone().text()); const inspectedBody = await inspected.json() as { authorizationRequest: { id: string; oauthClient: { label: string }; scopes: string[] }; + manageableAgents: Array<{ + id: string; + handle: string; + status: string; + onboardingState: string; + }>; }; assert.equal(inspectedBody.authorizationRequest.id, mcpAuthorizationRequestId); assert.equal(inspectedBody.authorizationRequest.oauthClient.label, 'ChatGPT'); assert.deepEqual(inspectedBody.authorizationRequest.scopes, ['feed:read']); + assert.deepEqual( + inspectedBody.manageableAgents.map((agent) => agent.id), + [sponsoredAgentId], + ); + assert.equal(inspectedBody.manageableAgents[0]?.handle, 'selene-test-agent'); + assert.equal(inspectedBody.manageableAgents[0]?.status, 'active'); + assert.equal(inspectedBody.manageableAgents[0]?.onboardingState, 'active'); const tamperedTicket = `${ticketBody.ticket.slice(0, -1)}${ticketBody.ticket.endsWith('A') ? 'B' : 'A'}`; const tamperedInspect = await postJson( @@ -701,6 +714,22 @@ let firstCredentialToken = ''; assert.equal(privateStateBody.recordCounts.total, 0); assert.equal(privateStateBody.recordCounts.pendingReview, 0); + await postJson('/__test/set-agent-status', { + handle: 'selene-test-agent', + status: 'suspended', + }, {}, NOW + 56); + const stateWhileSuspended = await postJson( + `/v1/mcp/grants/${encodeURIComponent(mcpGrantId)}/agent/state`, + {}, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + NOW + 56, + ); + assert.equal(stateWhileSuspended.status, 401); + await postJson('/__test/set-agent-status', { + handle: 'selene-test-agent', + status: 'active', + }, {}, NOW + 56); + const revokeNoCsrf = await postJson( `/v1/mcp/authorizations/${encodeURIComponent(mcpGrantId)}/revoke`, {}, diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 0ff8e1f..02e45b7 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -46,6 +46,47 @@ import Icon from '../components/Icon.astro'; + +