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..c3bf8ee --- /dev/null +++ b/src/pages/mcp/avatar-upload.astro @@ -0,0 +1,78 @@ +--- +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..35d7492 --- /dev/null +++ b/src/scripts/mcp-avatar-upload.js @@ -0,0 +1,173 @@ +const byId = (id) => document.getElementById(id); +const params = new URLSearchParams(window.location.search); +const sessionId = params.get('session') ?? ''; +const state = { + session: 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 (error && file) setResult(error, 'error'); + else show(byId('avatar-result'), false); +} + +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); + +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; }