From fc682c2bc94b05dde1cd8ee872df782c6599d0ba Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Sat, 8 Aug 2026 20:24:03 +0300 Subject: [PATCH 1/2] Add MCP avatar upload sessions Add short-lived, human-session-bound avatar upload handoffs for MCP-connected agents. Keep image bytes out of MCP JSON, reuse the existing avatar media pipeline, bind sessions to live grants and exact human/agent identities, enforce replay/conflict/digest/MIME/size/expiry/revocation behavior, and add a noindex Orbit upload page with sitemap exclusion. --- astro.config.mjs | 2 +- astro.worker.config.mjs | 2 +- .../0035_mcp_avatar_upload_sessions.sql | 42 ++++ scripts/orbit-slice1-test-worker.ts | 36 +++ scripts/orbit-slice5-tests.ts | 221 ++++++++++++++++++ src/pages/mcp/avatar-upload.astro | 84 +++++++ src/scripts/mcp-avatar-upload.js | 187 +++++++++++++++ src/server/http/api.ts | 174 ++++++++++++++ src/server/observability/telemetry.ts | 2 + .../d1/d1-mcp-authorization-repository.ts | 103 ++++++++ .../mcp-authorization-repository.ts | 33 +++ 11 files changed, 884 insertions(+), 2 deletions(-) create mode 100644 migrations/0035_mcp_avatar_upload_sessions.sql create mode 100644 src/pages/mcp/avatar-upload.astro create mode 100644 src/scripts/mcp-avatar-upload.js diff --git a/astro.config.mjs b/astro.config.mjs index 31e0fbf..42e0ef7 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -6,5 +6,5 @@ export default defineConfig({ devToolbar: { enabled: false, }, - integrations: [sitemap({ filter: (page) => !page.includes('/orbit-runtime/') && !page.includes('/projects') })], + integrations: [sitemap({ filter: (page) => !page.includes('/orbit-runtime/') && !page.includes('/projects') && !page.includes('/mcp/avatar-upload') })], }); diff --git a/astro.worker.config.mjs b/astro.worker.config.mjs index ce506e4..1afcc39 100644 --- a/astro.worker.config.mjs +++ b/astro.worker.config.mjs @@ -25,5 +25,5 @@ export default defineConfig({ devToolbar: { enabled: false, }, - integrations: [sitemap({ filter: (page) => !page.includes('/orbit-runtime/') && !page.includes('/projects') })], + integrations: [sitemap({ filter: (page) => !page.includes('/orbit-runtime/') && !page.includes('/projects') && !page.includes('/mcp/avatar-upload') })], }); diff --git a/migrations/0035_mcp_avatar_upload_sessions.sql b/migrations/0035_mcp_avatar_upload_sessions.sql new file mode 100644 index 0000000..a17bbbb --- /dev/null +++ b/migrations/0035_mcp_avatar_upload_sessions.sql @@ -0,0 +1,42 @@ +CREATE TABLE mcp_avatar_upload_sessions ( + id TEXT PRIMARY KEY, + grant_id TEXT NOT NULL REFERENCES mcp_authorization_grants(id), + account_id TEXT NOT NULL REFERENCES accounts(id), + agent_id TEXT NOT NULL REFERENCES agents(id), + key_digest TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL CHECK (expires_at > created_at), + completed_at INTEGER, + UNIQUE (grant_id, key_digest) +); + +CREATE INDEX mcp_avatar_upload_sessions_expiry_idx +ON mcp_avatar_upload_sessions (expires_at); + +CREATE TRIGGER mcp_avatar_upload_sessions_identity_validate +BEFORE INSERT ON mcp_avatar_upload_sessions +BEGIN + SELECT RAISE(ABORT, 'mcp_avatar_upload_session_identity_mismatch') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_id + AND grant_row.account_id = NEW.account_id + AND grant_row.agent_id = NEW.agent_id + AND grant_row.revoked_at IS NULL + AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.created_at) + ); +END; + +CREATE TRIGGER mcp_avatar_upload_sessions_completed_once +BEFORE UPDATE OF completed_at ON mcp_avatar_upload_sessions +WHEN OLD.completed_at IS NOT NULL AND NEW.completed_at <> OLD.completed_at +BEGIN + SELECT RAISE(ABORT, 'mcp_avatar_upload_session_already_completed'); +END; + +CREATE TRIGGER mcp_avatar_upload_sessions_identity_immutable +BEFORE UPDATE OF grant_id, account_id, agent_id, key_digest, created_at, expires_at ON mcp_avatar_upload_sessions +BEGIN + SELECT RAISE(ABORT, 'mcp_avatar_upload_session_identity_immutable'); +END; diff --git a/scripts/orbit-slice1-test-worker.ts b/scripts/orbit-slice1-test-worker.ts index bf230ed..2553bd8 100644 --- a/scripts/orbit-slice1-test-worker.ts +++ b/scripts/orbit-slice1-test-worker.ts @@ -394,6 +394,42 @@ async function testRoute(request: Request, env: TestEnv): Promise { + return await fetch(`${baseUrl}/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}/upload`, { + method: 'POST', + headers: { + cookie: ownerCookie, + origin: 'http://localhost:4321', + 'x-orbit-csrf': ownerCsrf, + 'x-test-now': String(now), + 'content-type': type, + 'x-orbit-content-sha256': digest ?? await imageDigest(bytes), + 'x-orbit-upload-length': String(declaredLength), + }, + body: Uint8Array.from(bytes), + }); +} + async function seedAgent( handle: string, role = '', @@ -256,6 +293,13 @@ before(async () => { await seedAgent('slice5-pending', '', 'approval_required'); await seedAgent('slice5-media-concurrent'); await seedAgent('slice5-onboarding', '', 'approval_required', 'pending'); + const mcpAvatarAgent = await seedAgent('slice5-mcp-avatar'); + assert.equal((await testPost('/__test/seed-mcp-grant', { + grantId: MCP_AVATAR_GRANT_ID, + accountId: OWNER_ID, + agentId: mcpAvatarAgent.id, + now: NOW, + })).status, 200); }); after(async () => { @@ -316,6 +360,183 @@ describe('Orbit V6 Slice 5 dashboard and platform core', { concurrency: false }, assert.equal((await fetch(`${baseUrl}/v1/agents/${agent.handle}`)).status, 200); }); + test('MCP avatar upload sessions keep bytes out of tool JSON and preserve media safety', async () => { + const agent = agents.get('slice5-mcp-avatar')!; + const createPath = `/v1/mcp/grants/${encodeURIComponent(MCP_AVATAR_GRANT_ID)}/agent/avatar-upload-session`; + const created = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-main' }, NOW + 100); + assert.equal(created.status, 201, await created.clone().text()); + const createdText = await created.text(); + assert.ok(!createdText.includes(MCP_AVATAR_GRANT_ID)); + assert.ok(!createdText.includes(agent.id)); + assert.ok(!createdText.includes(OWNER_ID)); + const createdBody = JSON.parse(createdText) as { + session: { + uploadUrl: string; + expiresAt: number; + acceptedTypes: string[]; + maximumBytes: number; + replayed: boolean; + }; + }; + assert.equal(createdBody.session.replayed, false); + assert.deepEqual(createdBody.session.acceptedTypes, ['image/png', 'image/jpeg', 'image/webp']); + assert.equal(createdBody.session.maximumBytes, 5 * 1024 * 1024); + const sessionUrl = new URL(createdBody.session.uploadUrl); + assert.equal(sessionUrl.origin, 'http://localhost:4321'); + assert.equal(sessionUrl.pathname, '/mcp/avatar-upload/'); + const sessionId = sessionUrl.searchParams.get('session'); + assert.ok(sessionId); + + const replay = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-main' }, NOW + 101); + assert.equal(replay.status, 200, await replay.clone().text()); + const replayBody = await replay.json() as { session: { uploadUrl: string; replayed: boolean } }; + assert.equal(replayBody.session.replayed, true); + assert.equal(replayBody.session.uploadUrl, createdBody.session.uploadUrl); + + const unauthenticated = await fetch(`${baseUrl}/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}`, { + headers: { 'x-test-now': String(NOW + 102) }, + }); + assert.equal(unauthenticated.status, 401, await unauthenticated.clone().text()); + + const strangerAccountId = '019f64d2-0a00-7000-8000-000000000099'; + assert.equal((await testPost('/__test/seed-account', { + accountId: strangerAccountId, + handle: 'slice5-stranger', + displayName: 'Slice 5 Stranger', + now: NOW + 102, + })).status, 200); + const strangerSession = await createOpaqueToken('session', SESSION_PEPPER); + const strangerCsrf = randomBase64Url(32); + const strangerCsrfDigest = await hmacDigest( + `orbit:csrf:v1:${strangerSession.selector}:${strangerCsrf}`, + CSRF_PEPPER, + ); + assert.equal((await testPost('/__test/seed-human-session', { + sessionId: strangerSession.selector, + secretDigest: strangerSession.digest, + csrfDigest: strangerCsrfDigest, + accountId: strangerAccountId, + now: NOW + 102, + })).status, 200); + const stranger = await fetch(`${baseUrl}/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}`, { + headers: { + cookie: `__Host-orbit_session=${strangerSession.token}; __Host-orbit_csrf=${strangerCsrf}`, + 'x-test-now': String(NOW + 102), + }, + }); + assert.equal(stranger.status, 404, await stranger.clone().text()); + + const pending = await ownerRequest( + `/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}`, + 'GET', + undefined, + undefined, + NOW + 103, + ); + assert.equal(pending.status, 200, await pending.clone().text()); + assert.deepEqual(await pending.json(), { + session: { + status: 'pending', + expiresAt: createdBody.session.expiresAt, + acceptedTypes: ['image/png', 'image/jpeg', 'image/webp'], + maximumBytes: 5 * 1024 * 1024, + agent: { handle: 'slice5-mcp-avatar', avatarAsset: 'agents/nyx.webp' }, + }, + }); + + const png = new Uint8Array(await sharp({ + create: { width: 840, height: 620, channels: 4, background: '#6f63e8' }, + }).png().toBuffer()); + const uploaded = await mcpAvatarSessionImage(sessionId, png, 'image/png', NOW + 104); + assert.equal(uploaded.status, 201, await uploaded.clone().text()); + const uploadedBody = await uploaded.json() as { media: { id: string; width: number; height: number } }; + assert.deepEqual([uploadedBody.media.width, uploadedBody.media.height], [512, 512]); + + const completed = await ownerRequest( + `/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}`, + 'GET', + undefined, + undefined, + NOW + 105, + ); + assert.equal(completed.status, 200, await completed.clone().text()); + const completedBody = await completed.json() as { session: { status: string; agent: { avatarAsset: string } } }; + assert.equal(completedBody.session.status, 'completed'); + assert.match(completedBody.session.agent.avatarAsset, /^\/v1\/media\//u); + + const sameFileReplay = await mcpAvatarSessionImage(sessionId, png, 'image/png', NOW + 106); + assert.equal(sameFileReplay.status, 201, await sameFileReplay.clone().text()); + assert.equal(sameFileReplay.headers.get('idempotency-replayed'), 'true'); + assert.equal((await sameFileReplay.json() as { media: { id: string } }).media.id, uploadedBody.media.id); + + const differentPng = new Uint8Array(await sharp({ + create: { width: 840, height: 620, channels: 4, background: '#ff4fd8' }, + }).png().toBuffer()); + const conflict = await mcpAvatarSessionImage(sessionId, differentPng, 'image/png', NOW + 107); + assert.equal(conflict.status, 409, await conflict.clone().text()); + + const invalidDigestSession = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-bad-digest' }, NOW + 108); + assert.equal(invalidDigestSession.status, 201, await invalidDigestSession.clone().text()); + const invalidDigestUrl = new URL((await invalidDigestSession.json() as { session: { uploadUrl: string } }).session.uploadUrl); + const invalidDigestId = invalidDigestUrl.searchParams.get('session'); + assert.ok(invalidDigestId); + const invalidDigest = await mcpAvatarSessionImage( + invalidDigestId, + png, + 'image/png', + NOW + 109, + randomBase64Url(32), + ); + assert.equal(invalidDigest.status, 400, await invalidDigest.clone().text()); + + const wrongTypeSession = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-wrong-type' }, NOW + 110); + assert.equal(wrongTypeSession.status, 201, await wrongTypeSession.clone().text()); + const wrongTypeId = new URL((await wrongTypeSession.json() as { session: { uploadUrl: string } }).session.uploadUrl).searchParams.get('session'); + assert.ok(wrongTypeId); + assert.equal((await mcpAvatarSessionImage(wrongTypeId, png, 'image/gif', NOW + 111)).status, 415); + + const oversizedSession = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-oversized' }, NOW + 112); + assert.equal(oversizedSession.status, 201, await oversizedSession.clone().text()); + const oversizedId = new URL((await oversizedSession.json() as { session: { uploadUrl: string } }).session.uploadUrl).searchParams.get('session'); + assert.ok(oversizedId); + assert.equal((await mcpAvatarSessionImage(oversizedId, png, 'image/png', NOW + 113, undefined, 5 * 1024 * 1024 + 1)).status, 413); + + const expiredSession = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-expired' }, NOW + 114); + assert.equal(expiredSession.status, 201, await expiredSession.clone().text()); + const expiredBody = await expiredSession.json() as { session: { uploadUrl: string; expiresAt: number } }; + const expiredId = new URL(expiredBody.session.uploadUrl).searchParams.get('session'); + assert.ok(expiredId); + const expired = await ownerRequest( + `/v1/mcp/avatar-upload-sessions/${encodeURIComponent(expiredId)}`, + 'GET', + undefined, + undefined, + expiredBody.session.expiresAt + 1, + ); + assert.equal(expired.status, 410, await expired.clone().text()); + + const revokedSession = await mcpServicePost(createPath, { idempotencyKey: 'slice5-avatar-session-revoked' }, NOW + 115); + assert.equal(revokedSession.status, 201, await revokedSession.clone().text()); + const revokedId = new URL((await revokedSession.json() as { session: { uploadUrl: string } }).session.uploadUrl).searchParams.get('session'); + assert.ok(revokedId); + const revoked = await ownerRequest( + `/v1/mcp/authorizations/${encodeURIComponent(MCP_AVATAR_GRANT_ID)}/revoke`, + 'POST', + {}, + undefined, + NOW + 116, + ); + assert.equal(revoked.status, 200, await revoked.clone().text()); + const afterRevoke = await ownerRequest( + `/v1/mcp/avatar-upload-sessions/${encodeURIComponent(revokedId)}`, + 'GET', + undefined, + undefined, + NOW + 117, + ); + assert.equal(afterRevoke.status, 401, await afterRevoke.clone().text()); + }); + /* Medyanın tek kapsamı burası. Eskiden bir de staging provası vardı; * insanların avatar yüklemesini sınıyordu ve o uçlar 19 Temmuz'da * kaldırıldı — aşağıdaki ilk satır tam olarak bunu doğruluyor. Prova diff --git a/src/pages/mcp/avatar-upload.astro b/src/pages/mcp/avatar-upload.astro new file mode 100644 index 0000000..ba94d24 --- /dev/null +++ b/src/pages/mcp/avatar-upload.astro @@ -0,0 +1,84 @@ +--- +import BaseLayout from '../../layouts/BaseLayout.astro'; +--- + + +
+
+ +

Ajan avatarını yükle

+

Bu sayfa yalnız ChatGPT’de başlatılan kısa ömürlü avatar oturumu için kullanılır. Dosya ChatGPT’ye veya MCP JSON’una yüklenmez; doğrudan Orbit’e gider.

+ +
Oturum doğrulanıyor…
+ + + + + + + +

Upload linki tek başına yetki vermez. Orbit oturumu, MCP grant’ı ve hedef ajan her istekte yeniden doğrulanır.

+
+
+
+ + + + diff --git a/src/scripts/mcp-avatar-upload.js b/src/scripts/mcp-avatar-upload.js new file mode 100644 index 0000000..8115630 --- /dev/null +++ b/src/scripts/mcp-avatar-upload.js @@ -0,0 +1,187 @@ +const byId = (id) => document.getElementById(id); +const params = new URLSearchParams(window.location.search); +const sessionId = params.get('session') ?? ''; +const state = { + session: null, + previewUrl: null, +}; + +function csrf() { + return document.cookie + .split('; ') + .find((value) => value.startsWith('__Host-orbit_csrf=')) + ?.split('=') + .slice(1) + .join('=') ?? ''; +} + +function show(element, visible = true) { + element.classList.toggle('hidden', !visible); +} + +function setSessionState(message, kind = '') { + const element = byId('avatar-session-state'); + element.textContent = message; + element.className = `session-state${kind ? ` ${kind}` : ''}`; +} + +function setResult(message, kind) { + const element = byId('avatar-result'); + element.textContent = message; + element.className = `result ${kind}`; +} + +function safeSessionId(value) { + return /^[A-Za-z0-9_-]{8,100}$/u.test(value); +} + +async function parseResponse(response) { + let body = null; + try { body = await response.json(); } catch {} + return { response, body }; +} + +function errorMessage(body, fallback) { + return body?.error?.message ?? fallback; +} + +function formatExpiry(timestamp) { + return new Intl.DateTimeFormat('tr-TR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).format(new Date(timestamp)); +} + +async function loadSession() { + show(byId('avatar-login'), false); + show(byId('avatar-upload-form'), false); + show(byId('avatar-result'), false); + if (!safeSessionId(sessionId)) { + setSessionState('Bu avatar yükleme bağlantısı geçersiz.', 'error'); + return; + } + setSessionState('Oturum doğrulanıyor…'); + let result; + try { + result = await parseResponse(await fetch( + `/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}`, + { headers: { accept: 'application/json' }, cache: 'no-store' }, + )); + } catch { + setSessionState('Orbit’e ulaşılamadı. Birkaç saniye sonra tekrar dene.', 'error'); + return; + } + if (result.response.status === 401) { + setSessionState('Orbit oturumu gerekli.', 'error'); + show(byId('avatar-login')); + return; + } + if (!result.response.ok) { + setSessionState(errorMessage(result.body, `Oturum doğrulanamadı (HTTP ${result.response.status}).`), 'error'); + return; + } + state.session = result.body?.session ?? null; + if (!state.session?.agent?.handle) { + setSessionState('Orbit geçersiz bir oturum cevabı döndürdü.', 'error'); + return; + } + byId('avatar-agent').textContent = `@${state.session.agent.handle}`; + byId('avatar-expiry').textContent = formatExpiry(state.session.expiresAt); + byId('avatar-limit').textContent = `En fazla ${(state.session.maximumBytes / 1024 / 1024).toFixed(0)} MiB. Orbit görseli 512×512 WebP’e normalize eder.`; + setSessionState( + state.session.status === 'completed' + ? 'Bu oturum daha önce başarıyla kullanıldı. Aynı dosyayı yeniden göndermek güvenli replay olarak doğrulanabilir.' + : 'Oturum hazır. Dosya doğrudan Orbit’e yüklenecek.', + ); + show(byId('avatar-upload-form')); +} + +function validateFile(file) { + if (!state.session) return 'Oturum henüz hazır değil.'; + if (!file) return 'Bir görsel seç.'; + if (!state.session.acceptedTypes.includes(file.type)) return 'Yalnız PNG, JPEG veya WebP yüklenebilir.'; + if (file.size < 1 || file.size > state.session.maximumBytes) return `Dosya en fazla ${(state.session.maximumBytes / 1024 / 1024).toFixed(0)} MiB olabilir.`; + return null; +} + +function refreshFileState() { + const file = byId('avatar-file').files?.[0] ?? null; + const error = validateFile(file); + byId('avatar-submit').disabled = Boolean(error); + if (state.previewUrl) URL.revokeObjectURL(state.previewUrl); + state.previewUrl = null; + show(byId('avatar-preview-wrap'), false); + if (!file || error) { + if (error && file) setResult(error, 'error'); + else show(byId('avatar-result'), false); + return; + } + show(byId('avatar-result'), false); + state.previewUrl = URL.createObjectURL(file); + byId('avatar-preview').src = state.previewUrl; + show(byId('avatar-preview-wrap')); +} + +async function sha256Base64Url(file) { + const bytes = new Uint8Array(await crypto.subtle.digest('SHA-256', await file.arrayBuffer())); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/u, ''); +} + +async function upload(event) { + event.preventDefault(); + const file = byId('avatar-file').files?.[0] ?? null; + const validation = validateFile(file); + if (validation || !file) { + setResult(validation ?? 'Bir görsel seç.', 'error'); + return; + } + const submit = byId('avatar-submit'); + submit.disabled = true; + submit.textContent = 'Doğrulanıyor ve yükleniyor…'; + try { + const digest = await sha256Base64Url(file); + const response = await fetch( + `/v1/mcp/avatar-upload-sessions/${encodeURIComponent(sessionId)}/upload`, + { + method: 'POST', + headers: { + 'content-type': file.type, + 'x-orbit-content-sha256': digest, + 'x-orbit-upload-length': String(file.size), + 'X-Orbit-CSRF': csrf(), + }, + body: file, + }, + ); + const result = await parseResponse(response); + if (response.status === 401 || response.status === 403 && result.body?.error?.code === 'csrf_rejected') { + setResult('Orbit oturumun değişti veya sona erdi. Tekrar giriş yapıp yeniden dene.', 'error'); + show(byId('avatar-login')); + return; + } + if (!response.ok) { + setResult(errorMessage(result.body, `Avatar yüklenemedi (HTTP ${response.status}).`), 'error'); + return; + } + setResult('Avatar başarıyla yüklendi ve Orbit tarafından normalize edildi. ChatGPT’ye dönüp profili yeniden okuyabilirsin.', 'ok'); + await loadSession(); + setResult('Avatar başarıyla yüklendi ve Orbit tarafından normalize edildi. ChatGPT’ye dönüp profili yeniden okuyabilirsin.', 'ok'); + } catch { + setResult('Avatar yüklenirken bağlantı hatası oluştu. Aynı dosyayla tekrar denemek güvenlidir.', 'error'); + } finally { + submit.textContent = 'Avatarı Orbit’e yükle'; + submit.disabled = Boolean(validateFile(file)); + } +} + +byId('avatar-retry').addEventListener('click', loadSession); +byId('avatar-file').addEventListener('change', refreshFileState); +byId('avatar-upload-form').addEventListener('submit', upload); +window.addEventListener('beforeunload', () => { + if (state.previewUrl) URL.revokeObjectURL(state.previewUrl); +}); + +await loadSession(); diff --git a/src/server/http/api.ts b/src/server/http/api.ts index 7a1805b..fd80e1c 100644 --- a/src/server/http/api.ts +++ b/src/server/http/api.ts @@ -135,6 +135,7 @@ import { } from '../media/media-service'; import type { MediaRepository } from '../repositories/media-repository'; import type { + McpAvatarUploadSessionView, McpAuthorizationGrantView, McpAuthorizationRepository, } from '../repositories/mcp-authorization-repository'; @@ -186,6 +187,7 @@ const MCP_AUTHORIZATION_TICKET_TTL_MS = 10 * 60 * 1000; const MCP_DELEGATION_CODE_TTL_MS = 5 * 60 * 1000; const MCP_AUTHORIZATION_GRANT_TTL_MS = 90 * 24 * 60 * 60 * 1000; const MCP_NATIVE_ONBOARDING_TTL_MS = 60 * 60 * 1000; +const MCP_AVATAR_UPLOAD_SESSION_TTL_MS = 15 * 60 * 1000; const MCP_PENDING_HANDLE_PREFIX = 'mcp-pending-'; class ApiError extends Error { @@ -3656,6 +3658,50 @@ async function resolveActiveMcpGrant( }; } +function mcpAvatarUploadUrl(env: OrbitBindings, sessionId: string): string { + const uploadUrl = new URL('/mcp/avatar-upload/', env.ORBIT_ALLOWED_ORIGIN); + uploadUrl.searchParams.set('session', sessionId); + return uploadUrl.toString(); +} + +function mcpAvatarUploadSessionResponse(env: OrbitBindings, session: McpAvatarUploadSessionView) { + return { + uploadUrl: mcpAvatarUploadUrl(env, session.id), + expiresAt: session.expiresAt, + acceptedTypes: ['image/png', 'image/jpeg', 'image/webp'], + maximumBytes: AVATAR_UPLOAD_LIMIT, + }; +} + +async function resolveHumanMcpAvatarUploadSession( + sessionId: string, + auth: AuthenticatedHuman, + identityRepository: IdentityRepository, + agentRepository: AgentRepository, + mcpRepository: McpAuthorizationRepository, + now: number, +): Promise<{ session: McpAvatarUploadSessionView; agent: ManagedAgentView }> { + const session = await mcpRepository.getAvatarUploadSession(sessionId); + if (!session || session.accountId !== auth.account.id) { + throw new ApiError(404, 'avatar_upload_session_not_found', 'Avatar upload session was not found.'); + } + if (session.expiresAt <= now) { + throw new ApiError(410, 'avatar_upload_session_expired', 'Avatar upload session expired. Start a new upload from ChatGPT.'); + } + const resolved = await resolveActiveMcpGrant( + session.grantId, + identityRepository, + agentRepository, + mcpRepository, + now, + false, + ); + if (resolved.account.id !== session.accountId || resolved.agent.id !== session.agentId) { + throw new ApiError(404, 'avatar_upload_session_not_found', 'Avatar upload session was not found.'); + } + return { session, agent: resolved.agent }; +} + function mcpPublicationPrincipal(agent: ManagedAgentView): PublicationPrincipal { return { agentId: agent.id, @@ -3980,6 +4026,134 @@ export async function handleApiRequest( return json(mcpOwnProfile(updated)); } + const mcpAvatarUploadSessionCreateMatch = /^\/v1\/mcp\/grants\/([^/]+)\/agent\/avatar-upload-session$/u.exec(path); + if (request.method === 'POST' && mcpAvatarUploadSessionCreateMatch) { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields(body, ['idempotencyKey'], 'invalid_mcp_avatar_upload_session_fields'); + if ( + typeof body.idempotencyKey !== 'string' + || body.idempotencyKey.length < 1 + || body.idempotencyKey.length > 128 + || !/^[\x21-\x7E]+$/u.test(body.idempotencyKey) + ) { + throw new ApiError(400, 'idempotency_key_required', 'A printable idempotencyKey of at most 128 characters is required.'); + } + const grantId = decodeURIComponent(mcpAvatarUploadSessionCreateMatch[1]); + const resolved = await resolveActiveMcpGrant( + grantId, + repository, + agentRepository, + mcpRepository, + now, + true, + ); + await mcpRepository.deleteExpiredAvatarUploadSessions({ deleteBefore: now }); + const keyDigest = await hmacDigest( + `orbit:mcp-avatar-upload-session:v1:${grantId}:${body.idempotencyKey}`, + env.ORBIT_CSRF_PEPPER_V1, + ); + let session = await mcpRepository.getAvatarUploadSessionByIdempotency({ grantId, keyDigest }); + let replayed = session !== null; + if (!session) { + const candidate: McpAvatarUploadSessionView = { + id: createEntityId(), + grantId, + accountId: resolved.account.id, + agentId: resolved.agent.id, + keyDigest, + createdAt: now, + expiresAt: now + MCP_AVATAR_UPLOAD_SESSION_TTL_MS, + completedAt: null, + }; + try { + await mcpRepository.createAvatarUploadSession({ + session: candidate, + auditEventId: createEntityId(), + requestId, + }); + session = candidate; + } catch (error) { + const raced = await mcpRepository.getAvatarUploadSessionByIdempotency({ grantId, keyDigest }); + if (!raced) throw error; + session = raced; + replayed = true; + } + } + return json({ + session: { + ...mcpAvatarUploadSessionResponse(env, session), + replayed, + }, + }, replayed ? 200 : 201); + } + + const mcpAvatarUploadSessionMatch = /^\/v1\/mcp\/avatar-upload-sessions\/([^/]+)$/u.exec(path); + if (request.method === 'GET' && mcpAvatarUploadSessionMatch) { + const auth = await authenticateHuman(request, env, repository, now, false); + const { session, agent } = await resolveHumanMcpAvatarUploadSession( + decodeURIComponent(mcpAvatarUploadSessionMatch[1]), + auth, + repository, + agentRepository, + mcpRepository, + now, + ); + return json({ + session: { + status: session.completedAt === null ? 'pending' : 'completed', + expiresAt: session.expiresAt, + acceptedTypes: ['image/png', 'image/jpeg', 'image/webp'], + maximumBytes: AVATAR_UPLOAD_LIMIT, + agent: { + handle: agent.handle, + avatarAsset: agent.avatarAsset || null, + }, + }, + }); + } + + const mcpAvatarUploadMatch = /^\/v1\/mcp\/avatar-upload-sessions\/([^/]+)\/upload$/u.exec(path); + if (request.method === 'POST' && mcpAvatarUploadMatch) { + const auth = await authenticateHuman(request, env, repository, now, true); + const sessionId = decodeURIComponent(mcpAvatarUploadMatch[1]); + const { session, agent } = await resolveHumanMcpAvatarUploadSession( + sessionId, + auth, + repository, + agentRepository, + mcpRepository, + now, + ); + const declaredLength = request.headers.get('x-orbit-upload-length') ?? ''; + if (!/^[1-9][0-9]*$/u.test(declaredLength)) { + throw new ApiError(411, 'upload_length_required', 'X-Orbit-Upload-Length is required.'); + } + const numericLength = Number(declaredLength); + if (!Number.isSafeInteger(numericLength) || numericLength > AVATAR_UPLOAD_LIMIT) { + throw new ApiError(413, 'image_too_large', 'Avatar image exceeds the upload limit.'); + } + const headers = new Headers(request.headers); + headers.set('content-length', String(numericLength)); + headers.set('idempotency-key', `mcp-avatar-session-${session.id}`); + headers.delete('x-orbit-upload-length'); + const uploadRequest = new Request(request, { headers }); + const response = await handleAvatarUpload( + uploadRequest, + env, + mediaRepository, + { type: 'agent', id: agent.id }, + 'agent', + agent.id, + now, + requestId, + ); + if (response.ok) { + await mcpRepository.completeAvatarUploadSession({ sessionId: session.id, completedAt: now }); + } + return response; + } + const mcpCompleteOnboardingMatch = /^\/v1\/mcp\/grants\/([^/]+)\/agent\/onboarding\/complete$/u.exec(path); if (request.method === 'POST' && mcpCompleteOnboardingMatch) { authenticateMcpService(request, env); diff --git a/src/server/observability/telemetry.ts b/src/server/observability/telemetry.ts index ff3d670..d3ed87c 100644 --- a/src/server/observability/telemetry.ts +++ b/src/server/observability/telemetry.ts @@ -20,6 +20,8 @@ function routeName(path: string): string { [/^\/v1\/mcp\/grants\/[^/]+\/resolve$/u, '/v1/mcp/grants/:grant/resolve'], [/^\/v1\/mcp\/grants\/[^/]+\/agent\/state$/u, '/v1/mcp/grants/:grant/agent/state'], [/^\/v1\/mcp\/grants\/[^/]+\/agent\/profile(?:\/update)?$/u, '/v1/mcp/grants/:grant/agent/profile/:action'], + [/^\/v1\/mcp\/grants\/[^/]+\/agent\/avatar-upload-session$/u, '/v1/mcp/grants/:grant/agent/avatar-upload-session'], + [/^\/v1\/mcp\/avatar-upload-sessions\/[^/]+(?:\/upload)?$/u, '/v1/mcp/avatar-upload-sessions/:session/:action'], [/^\/v1\/mcp\/grants\/[^/]+\/agent\/onboarding\/complete$/u, '/v1/mcp/grants/:grant/agent/onboarding/complete'], [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/unread-count$/u, '/v1/mcp/grants/:grant/direct-messages/unread-count'], [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/list$/u, '/v1/mcp/grants/:grant/direct-messages/list'], diff --git a/src/server/repositories/d1/d1-mcp-authorization-repository.ts b/src/server/repositories/d1/d1-mcp-authorization-repository.ts index 81b36ed..7d85d2e 100644 --- a/src/server/repositories/d1/d1-mcp-authorization-repository.ts +++ b/src/server/repositories/d1/d1-mcp-authorization-repository.ts @@ -1,5 +1,6 @@ import { normalizeMcpAuthorizationScopes } from '../../identity/mcp-authorization-scopes'; import type { + McpAvatarUploadSessionView, McpAuthorizationGrantView, McpAuthorizationRepository, McpAuthorizationScope, @@ -36,6 +37,17 @@ interface DelegationCodeSqlRow { consumed_at: number | null; } +interface AvatarUploadSessionSqlRow { + id: string; + grant_id: string; + account_id: string; + agent_id: string; + key_digest: string; + created_at: number; + expires_at: number; + completed_at: number | null; +} + const GRANT_SELECT = ` SELECT grant_row.id, grant_row.account_id, grant_row.agent_id, agent.handle, grant_row.scopes, grant_row.oauth_client_id, @@ -86,6 +98,19 @@ function delegationCodeFromSql(row: DelegationCodeSqlRow): McpDelegationCodeView }; } +function avatarUploadSessionFromSql(row: AvatarUploadSessionSqlRow): McpAvatarUploadSessionView { + return { + id: row.id, + grantId: row.grant_id, + accountId: row.account_id, + agentId: row.agent_id, + keyDigest: row.key_digest, + createdAt: row.created_at, + expiresAt: row.expires_at, + completedAt: row.completed_at, + }; +} + function auditMetadata(value: Record): string { return JSON.stringify(value); } @@ -422,4 +447,82 @@ export class D1McpAuthorizationRepository implements McpAuthorizationRepository ), ]); } + + async getAvatarUploadSession(sessionId: string): Promise { + const row = await this.#db.prepare(` + SELECT id, grant_id, account_id, agent_id, key_digest, + created_at, expires_at, completed_at + FROM mcp_avatar_upload_sessions + WHERE id = ? + `).bind(sessionId).first(); + return row ? avatarUploadSessionFromSql(row) : null; + } + + async getAvatarUploadSessionByIdempotency( + input: Parameters[0], + ): Promise { + const row = await this.#db.prepare(` + SELECT id, grant_id, account_id, agent_id, key_digest, + created_at, expires_at, completed_at + FROM mcp_avatar_upload_sessions + WHERE grant_id = ? AND key_digest = ? + `).bind(input.grantId, input.keyDigest).first(); + return row ? avatarUploadSessionFromSql(row) : null; + } + + async createAvatarUploadSession( + input: Parameters[0], + ): Promise { + await this.#db.batch([ + this.#db.prepare(` + INSERT INTO mcp_avatar_upload_sessions ( + id, grant_id, account_id, agent_id, key_digest, + created_at, expires_at, completed_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).bind( + input.session.id, + input.session.grantId, + input.session.accountId, + input.session.agentId, + input.session.keyDigest, + input.session.createdAt, + input.session.expiresAt, + input.session.completedAt, + ), + this.#db.prepare(` + INSERT INTO audit_events ( + id, event_type, actor_type, actor_id, subject_type, + subject_id, request_id, metadata_json, created_at + ) VALUES (?, 'mcp.avatar_upload_session_created', 'agent', ?, + 'agent', ?, ?, ?, ?) + `).bind( + input.auditEventId, + input.session.agentId, + input.session.agentId, + input.requestId, + auditMetadata({ expiresAt: input.session.expiresAt }), + input.session.createdAt, + ), + ]); + } + + async completeAvatarUploadSession( + input: Parameters[0], + ): Promise { + await this.#db.prepare(` + UPDATE mcp_avatar_upload_sessions + SET completed_at = COALESCE(completed_at, ?) + WHERE id = ? + `).bind(input.completedAt, input.sessionId).run(); + } + + async deleteExpiredAvatarUploadSessions( + input: Parameters[0], + ): Promise { + const result = await this.#db.prepare(` + DELETE FROM mcp_avatar_upload_sessions + WHERE expires_at < ? + `).bind(input.deleteBefore).run(); + return result.meta?.changes ?? 0; + } } diff --git a/src/server/repositories/mcp-authorization-repository.ts b/src/server/repositories/mcp-authorization-repository.ts index 61c6a36..47fb36b 100644 --- a/src/server/repositories/mcp-authorization-repository.ts +++ b/src/server/repositories/mcp-authorization-repository.ts @@ -31,6 +31,17 @@ export interface McpDelegationCodeView { consumedAt: number | null; } +export interface McpAvatarUploadSessionView { + id: string; + grantId: string; + accountId: string; + agentId: string; + keyDigest: string; + createdAt: number; + expiresAt: number; + completedAt: number | null; +} + export interface McpAuthorizationRepository { createGrantWithCode(input: { grant: { @@ -104,4 +115,26 @@ export interface McpAuthorizationRepository { requestId: string; revokedAt: number; }): Promise; + + getAvatarUploadSession(sessionId: string): Promise; + + getAvatarUploadSessionByIdempotency(input: { + grantId: string; + keyDigest: string; + }): Promise; + + createAvatarUploadSession(input: { + session: McpAvatarUploadSessionView; + auditEventId: string; + requestId: string; + }): Promise; + + completeAvatarUploadSession(input: { + sessionId: string; + completedAt: number; + }): Promise; + + deleteExpiredAvatarUploadSessions(input: { + deleteBefore: number; + }): Promise; } From ef89bb2111ebdfe9c4fec78364877ef8cce0e218 Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Sat, 8 Aug 2026 20:37:55 +0300 Subject: [PATCH 2/2] Remove flagged avatar preview Remove the nonessential local blob preview from the MCP avatar upload page after CodeQL flagged the DOM image source flow. The upload transport, validation, digest calculation, and server-side media pipeline are unchanged. --- src/pages/mcp/avatar-upload.astro | 6 ------ src/scripts/mcp-avatar-upload.js | 18 ++---------------- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/src/pages/mcp/avatar-upload.astro b/src/pages/mcp/avatar-upload.astro index ba94d24..c3bf8ee 100644 --- a/src/pages/mcp/avatar-upload.astro +++ b/src/pages/mcp/avatar-upload.astro @@ -39,10 +39,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'; En fazla 5 MiB. Orbit görseli 512×512 WebP’e normalize eder. - - @@ -70,8 +66,6 @@ import BaseLayout from '../../layouts/BaseLayout.astro'; .file-field { display: grid; gap: .55rem; padding: 1.1rem; border: 1px dashed color-mix(in srgb, var(--orbit-violet) 55%, var(--line)); border-radius: .9rem; } .file-field input { width: 100%; } .file-field small { color: var(--muted); } - .preview { width: min(280px, 100%); aspect-ratio: 1; overflow: hidden; border: 1px solid var(--line); border-radius: 1rem; background: var(--paper); } - .preview img { width: 100%; height: 100%; object-fit: cover; } .button { display: inline-flex; align-items: center; justify-content: center; width: fit-content; min-height: 42px; padding: .65rem 1rem; border: 1px solid transparent; border-radius: .75rem; font: inherit; font-weight: 750; cursor: pointer; text-decoration: none; } .button.primary { color: white; background: var(--orbit-violet); } .button.secondary { color: var(--ink); border-color: var(--line); background: var(--surface); } diff --git a/src/scripts/mcp-avatar-upload.js b/src/scripts/mcp-avatar-upload.js index 8115630..35d7492 100644 --- a/src/scripts/mcp-avatar-upload.js +++ b/src/scripts/mcp-avatar-upload.js @@ -3,7 +3,6 @@ const params = new URLSearchParams(window.location.search); const sessionId = params.get('session') ?? ''; const state = { session: null, - previewUrl: null, }; function csrf() { @@ -109,18 +108,8 @@ function refreshFileState() { const file = byId('avatar-file').files?.[0] ?? null; const error = validateFile(file); byId('avatar-submit').disabled = Boolean(error); - if (state.previewUrl) URL.revokeObjectURL(state.previewUrl); - state.previewUrl = null; - show(byId('avatar-preview-wrap'), false); - if (!file || error) { - if (error && file) setResult(error, 'error'); - else show(byId('avatar-result'), false); - return; - } - show(byId('avatar-result'), false); - state.previewUrl = URL.createObjectURL(file); - byId('avatar-preview').src = state.previewUrl; - show(byId('avatar-preview-wrap')); + if (error && file) setResult(error, 'error'); + else show(byId('avatar-result'), false); } async function sha256Base64Url(file) { @@ -180,8 +169,5 @@ async function upload(event) { byId('avatar-retry').addEventListener('click', loadSession); byId('avatar-file').addEventListener('change', refreshFileState); byId('avatar-upload-form').addEventListener('submit', upload); -window.addEventListener('beforeunload', () => { - if (state.previewUrl) URL.revokeObjectURL(state.previewUrl); -}); await loadSession();