diff --git a/migrations/0025_mcp_inbox_scope_bundle.sql b/migrations/0025_mcp_inbox_scope_bundle.sql new file mode 100644 index 0000000..676e988 --- /dev/null +++ b/migrations/0025_mcp_inbox_scope_bundle.sql @@ -0,0 +1,210 @@ +PRAGMA defer_foreign_keys = ON; + +DROP TRIGGER IF EXISTS mcp_delegation_codes_validate; +DROP TRIGGER IF EXISTS mcp_delegation_redemptions_validate; +DROP TRIGGER IF EXISTS mcp_authorization_revocations_validate; +DROP TRIGGER IF EXISTS mcp_authorization_revocations_apply; + +CREATE TABLE mcp_authorization_grants_v04 ( + id TEXT PRIMARY KEY, + account_id TEXT NOT NULL REFERENCES accounts(id), + agent_id TEXT NOT NULL REFERENCES agents(id), + scopes TEXT NOT NULL CHECK ( + scopes IN ( + 'feed:read', + 'feed:read posts:write', + 'feed:read replies:write', + 'feed:read posts:write replies:write', + 'feed:read posts:write replies:write messages:read messages:write' + ) + ), + oauth_client_id TEXT NOT NULL CHECK ( + length(trim(oauth_client_id)) BETWEEN 1 AND 255 + ), + oauth_client_label TEXT NOT NULL CHECK ( + length(trim(oauth_client_label)) BETWEEN 1 AND 120 + ), + created_at INTEGER NOT NULL, + last_used_at INTEGER, + expires_at INTEGER, + revoked_at INTEGER, + revoked_reason TEXT, + CHECK (last_used_at IS NULL OR last_used_at >= created_at), + CHECK (expires_at IS NULL OR expires_at > created_at), + CHECK (revoked_at IS NULL OR revoked_at >= created_at), + CHECK ( + (revoked_at IS NULL AND revoked_reason IS NULL) + OR + ( + revoked_at IS NOT NULL + AND revoked_reason IS NOT NULL + AND length(trim(revoked_reason)) BETWEEN 1 AND 120 + ) + ) +); + +INSERT INTO mcp_authorization_grants_v04 ( + id, account_id, agent_id, scopes, oauth_client_id, + oauth_client_label, created_at, last_used_at, expires_at, + revoked_at, revoked_reason +) +SELECT + id, account_id, agent_id, scopes, oauth_client_id, + oauth_client_label, created_at, last_used_at, expires_at, + revoked_at, revoked_reason +FROM mcp_authorization_grants; + +DROP TABLE mcp_authorization_grants; +ALTER TABLE mcp_authorization_grants_v04 RENAME TO mcp_authorization_grants; + +CREATE INDEX mcp_authorization_grants_account_state_idx + ON mcp_authorization_grants (account_id, revoked_at, expires_at, created_at DESC); + +CREATE INDEX mcp_authorization_grants_agent_state_idx + ON mcp_authorization_grants (agent_id, revoked_at, expires_at, created_at DESC); + +CREATE INDEX mcp_authorization_grants_client_idx + ON mcp_authorization_grants (oauth_client_id, created_at DESC); + +CREATE TRIGGER mcp_authorization_grants_validate +BEFORE INSERT ON mcp_authorization_grants +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_account_inactive') + WHERE NOT EXISTS ( + SELECT 1 + FROM accounts account + WHERE account.id = NEW.account_id + AND account.status = 'active' + ); + + SELECT RAISE(ABORT, 'mcp_authorization_agent_not_manageable') + WHERE NOT EXISTS ( + SELECT 1 + FROM agents agent + WHERE agent.id = NEW.agent_id + AND ( + EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = agent.id + AND membership.account_id = NEW.account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = NEW.account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) + ); +END; + +CREATE TRIGGER mcp_authorization_grants_identity_immutable +BEFORE UPDATE OF account_id, agent_id, scopes, oauth_client_id, oauth_client_label, created_at, expires_at +ON mcp_authorization_grants +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_grant_identity_immutable'); +END; + +CREATE TRIGGER mcp_delegation_codes_validate +BEFORE INSERT ON mcp_delegation_codes +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_grant_unavailable') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_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_delegation_redemptions_validate +BEFORE INSERT ON mcp_delegation_redemptions +BEGIN + SELECT RAISE(ABORT, 'invalid_mcp_delegation_code') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_delegation_codes code + JOIN mcp_authorization_grants grant_row + ON grant_row.id = code.grant_id + JOIN accounts account + ON account.id = grant_row.account_id + WHERE code.id = NEW.code_id + AND code.grant_id = NEW.grant_id + AND code.authorization_request_id = NEW.authorization_request_id + AND code.consumed_at IS NULL + AND code.expires_at > NEW.redeemed_at + AND grant_row.revoked_at IS NULL + AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.redeemed_at) + AND account.status = 'active' + AND ( + EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = grant_row.agent_id + AND membership.account_id = grant_row.account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = grant_row.account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) + ); +END; + +CREATE TRIGGER mcp_authorization_revocations_validate +BEFORE INSERT ON mcp_authorization_revocations +BEGIN + SELECT RAISE(ABORT, 'mcp_authorization_grant_unavailable') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_id + AND grant_row.revoked_at IS NULL + ); + + SELECT RAISE(ABORT, 'mcp_authorization_revoke_forbidden') + WHERE NOT EXISTS ( + SELECT 1 + FROM mcp_authorization_grants grant_row + WHERE grant_row.id = NEW.grant_id + AND ( + grant_row.account_id = NEW.actor_account_id + OR EXISTS ( + SELECT 1 + FROM agent_memberships membership + WHERE membership.agent_id = grant_row.agent_id + AND membership.account_id = NEW.actor_account_id + AND membership.role = 'primary_sponsor' + AND membership.revoked_at IS NULL + ) + OR EXISTS ( + SELECT 1 + FROM account_roles role + WHERE role.account_id = NEW.actor_account_id + AND role.role = 'platform_owner' + AND role.revoked_at IS NULL + ) + ) + ); +END; + +CREATE TRIGGER mcp_authorization_revocations_apply +AFTER INSERT ON mcp_authorization_revocations +BEGIN + UPDATE mcp_authorization_grants + SET revoked_at = NEW.revoked_at, + revoked_reason = trim(NEW.reason) + WHERE id = NEW.grant_id; +END; + +PRAGMA defer_foreign_keys = OFF; diff --git a/scripts/orbit-browser-tests.mjs b/scripts/orbit-browser-tests.mjs index 247060e..0c2687f 100644 --- a/scripts/orbit-browser-tests.mjs +++ b/scripts/orbit-browser-tests.mjs @@ -205,8 +205,8 @@ if (errors.length === 0) { authorizationRequest: { id: browserMcpAuthorizationRequestId, oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' }, - scopes: ['feed:read', 'posts:write', 'replies:write'], - scopeBundleVersion: 1, + scopes: ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], + scopeBundleVersion: 2, issuedAt: Date.now(), expiresAt: Date.now() + 10 * 60 * 1000, }, @@ -707,7 +707,9 @@ if (errors.length === 0) { check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.'); check(consentState.scope?.includes('tek izin paketi'), 'MCP consent tek paket politikasını açıklamıyor.'); check(consentState.scope?.includes('yeniden onay'), 'MCP consent gelecek yeteneklerde yeniden onayı açıklamıyor.'); - check(consentState.scopeOptions.length === 3, 'MCP consent zorunlu kapsamları ayrı kartlarda göstermiyor.'); + check(consentState.scopeOptions.length === 5, 'MCP consent zorunlu kapsamları ayrı kartlarda göstermiyor.'); + check(consentState.scopeOptions.some((option) => option.text?.includes('messages:read')), 'MCP consent mesaj okuma kapsamını göstermiyor.'); + check(consentState.scopeOptions.some((option) => option.text?.includes('messages:write')), 'MCP consent mesaj yazma kapsamını göstermiyor.'); check(consentState.scopeInputs === 0, 'MCP consent isteğe bağlı checkbox göstermeye devam ediyor.'); check(consentState.scopeOptions.every((option) => option.text?.includes('zorunlu')), 'MCP consent tüm mevcut kapsamları zorunlu göstermiyor.'); check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.'); diff --git a/scripts/orbit-mcp-authorization-tests.ts b/scripts/orbit-mcp-authorization-tests.ts index 6d9c95d..87039c4 100644 --- a/scripts/orbit-mcp-authorization-tests.ts +++ b/scripts/orbit-mcp-authorization-tests.ts @@ -227,7 +227,7 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => { assert.deepEqual(listed.grants.map((grant) => grant.id), [input.grantId]); }); - test('canonical read and write scope combinations persist without widening old grants', async () => { + test('legacy scope combinations persist and the current inbox bundle is canonical', async () => { const now = Date.now(); const accountId = 'mcp-scope-sponsor'; const agentId = 'mcp-scope-agent'; @@ -238,6 +238,7 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => { ['feed:read', 'posts:write'], ['feed:read', 'replies:write'], ['feed:read', 'posts:write', 'replies:write'], + ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], ]; for (const [index, scopes] of combinations.entries()) { const input = { @@ -250,10 +251,16 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => { const reordered = { ...grantData('mcp-scope-reordered', accountId, agentId, now + 10), - scopes: ['replies:write', 'feed:read', 'posts:write'], + scopes: ['messages:write', 'replies:write', 'feed:read', 'messages:read', 'posts:write'], }; const canonical = await callAction<{ grant: { scopes: string[] } }>('createMcpGrant', reordered, 201); - assert.deepEqual(canonical.grant.scopes, ['feed:read', 'posts:write', 'replies:write']); + assert.deepEqual(canonical.grant.scopes, [ + 'feed:read', + 'posts:write', + 'replies:write', + 'messages:read', + 'messages:write', + ]); }); test('unrelated accounts and invalid scope sets are rejected', async () => { @@ -273,6 +280,8 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => { ['posts:write'], ['feed:read', 'feed:read'], ['feed:read', 'records:write'], + ['feed:read', 'posts:write', 'replies:write', 'messages:read'], + ['feed:read', 'messages:write'], ]; for (const [index, scopes] of invalidSets.entries()) { const invalid = { diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index 29f093a..3602746 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -230,6 +230,8 @@ let firstCredentialToken = ''; async function createAuthorizedMcpGrant(options: { requestId: string; now: number; + agentId?: string; + cookies?: Map; }): Promise<{ grantId: string; delegationCode: string; @@ -241,8 +243,8 @@ let firstCredentialToken = ''; authorizationRequestId: options.requestId, oauthClientId: `chatgpt-${options.requestId}`, oauthClientLabel: 'ChatGPT', - scopes: ['feed:read', 'posts:write', 'replies:write'], - scopeBundleVersion: 1, + scopes: ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], + scopeBundleVersion: 2, }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, options.now, @@ -253,10 +255,10 @@ let firstCredentialToken = ''; const created = await postJson( '/v1/mcp/authorizations', { - agentId: sponsoredAgentId, + agentId: options.agentId ?? sponsoredAgentId, ticket: ticketBody.ticket, }, - authenticatedHeaders(sponsorCookies, true), + authenticatedHeaders(options.cookies ?? sponsorCookies, true), options.now + 1, ); assert.equal(created.status, 201, await created.clone().text()); @@ -266,7 +268,7 @@ let firstCredentialToken = ''; }; assert.deepEqual( body.authorization.scopes, - ['feed:read', 'posts:write', 'replies:write'], + ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], ); return { grantId: body.authorization.id, @@ -514,8 +516,8 @@ let firstCredentialToken = ''; authorizationRequestId: mcpAuthorizationRequestId, oauthClientId: 'chatgpt-dynamic-client-001', oauthClientLabel: 'ChatGPT', - scopes: ['feed:read', 'posts:write', 'replies:write'], - scopeBundleVersion: 1, + scopes: ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], + scopeBundleVersion: 2, }; const unauthenticatedList = await request('/v1/mcp/authorizations', {}, NOW + 47); @@ -562,9 +564,9 @@ let firstCredentialToken = ''; assert.equal(ticketBody.authorizationRequest.oauthClient.label, 'ChatGPT'); assert.deepEqual( ticketBody.authorizationRequest.scopes, - ['feed:read', 'posts:write', 'replies:write'], + ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], ); - assert.equal(ticketBody.authorizationRequest.scopeBundleVersion, 1); + assert.equal(ticketBody.authorizationRequest.scopeBundleVersion, 2); assert.equal(ticketBody.authorizationRequest.issuedAt, NOW + 49); assert.equal(ticketBody.authorizationRequest.expiresAt, NOW + 49 + 10 * 60 * 1000); @@ -601,9 +603,9 @@ let firstCredentialToken = ''; assert.equal(inspectedBody.authorizationRequest.oauthClient.label, 'ChatGPT'); assert.deepEqual( inspectedBody.authorizationRequest.scopes, - ['feed:read', 'posts:write', 'replies:write'], + ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], ); - assert.equal(inspectedBody.authorizationRequest.scopeBundleVersion, 1); + assert.equal(inspectedBody.authorizationRequest.scopeBundleVersion, 2); assert.deepEqual( inspectedBody.manageableAgents.map((agent) => agent.id), [sponsoredAgentId], @@ -683,7 +685,7 @@ let firstCredentialToken = ''; mcpDelegationCode = createdBody.delegation.code; assert.ok(mcpGrantId); assert.ok(mcpDelegationCode.startsWith('orb_mcp_v1_')); - assert.deepEqual(createdBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write']); + assert.deepEqual(createdBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write']); assert.equal(createdBody.authorization.agent.id, sponsoredAgentId); assert.equal(createdBody.authorization.oauthClient.label, 'ChatGPT'); assert.equal(createdBody.authorization.status, 'active'); @@ -727,7 +729,7 @@ let firstCredentialToken = ''; authorization: { id: string; scopes: string[]; status: string }; }; assert.equal(redeemedBody.authorization.id, mcpGrantId); - assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write']); + assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write']); assert.equal(redeemedBody.authorization.status, 'active'); const replay = await postJson('/v1/mcp/delegations/redeem', { @@ -785,7 +787,7 @@ let firstCredentialToken = ''; assert.equal(privateStateBody.authorization.id, mcpGrantId); assert.deepEqual( privateStateBody.authorization.scopes, - ['feed:read', 'posts:write', 'replies:write'], + ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], ); assert.equal(privateStateBody.authorization.lastUsedAt, NOW + 56); assert.equal(privateStateBody.agent.id, sponsoredAgentId); @@ -980,6 +982,172 @@ let firstCredentialToken = ''; assert.equal(replayReply.headers.get('idempotency-replayed'), 'true'); assert.deepEqual(await replayReply.json(), createdReplyBody); + const recipientAgentId = '019fba91-1000-7000-8000-000000000001'; + await postJson('/__test/seed-publication-agent', { + agentId: recipientAgentId, + handle: 'mcp-inbox-recipient', + publicationMode: 'direct_publish', + status: 'active', + onboardingState: 'active', + role: '', + membershipId: '019fba91-1000-7000-8000-000000000002', + credentialId: '019fba91-1000-7000-8000-000000000003', + secretDigest: 'recipient-secret-digest-for-mcp-inbox-tests', + now: NOW + 17_100, + }, {}, NOW + 17_100); + const recipientGrant = await createAuthorizedMcpGrant({ + requestId: 'chatgpt-inbox-recipient-request', + now: NOW + 17_110, + agentId: recipientAgentId, + cookies: ownerCookies, + }); + + const initialUnread = await postJson( + `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/unread-count`, + {}, + mcpServiceHeaders(), + NOW + 17_120, + ); + assert.equal(initialUnread.status, 200, await initialUnread.clone().text()); + assert.deepEqual(await initialUnread.json(), { unreadCount: 0 }); + + const directMessageBody = { + recipientHandle: 'mcp-inbox-recipient', + bodyMarkdown: 'MCP inbox üzerinden özel mesaj testi.', + }; + const sentMessage = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/direct-messages/send`, + directMessageBody, + mcpServiceHeaders('mcp-direct-message-001'), + NOW + 17_130, + ); + assert.equal(sentMessage.status, 201, await sentMessage.clone().text()); + const sentMessageBody = await sentMessage.json() as { + directMessage: { + id: string; + sender: { handle: string }; + recipient: { handle: string }; + bodyMarkdown: string; + readAt: number | null; + }; + }; + assert.equal(sentMessageBody.directMessage.sender.handle, 'selene-test-agent'); + assert.equal(sentMessageBody.directMessage.recipient.handle, 'mcp-inbox-recipient'); + assert.equal(sentMessageBody.directMessage.readAt, null); + + const replayMessage = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/direct-messages/send`, + directMessageBody, + mcpServiceHeaders('mcp-direct-message-001'), + NOW + 17_131, + ); + assert.equal(replayMessage.status, 201, await replayMessage.clone().text()); + assert.equal(replayMessage.headers.get('idempotency-replayed'), 'true'); + assert.deepEqual(await replayMessage.json(), sentMessageBody); + + const conflictingMessage = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/direct-messages/send`, + { ...directMessageBody, bodyMarkdown: 'Aynı anahtarla farklı özel mesaj.' }, + mcpServiceHeaders('mcp-direct-message-001'), + NOW + 17_132, + ); + assert.equal(conflictingMessage.status, 409, await conflictingMessage.clone().text()); + const conflictingMessageBody = await conflictingMessage.json() as { error: { code: string } }; + assert.equal(conflictingMessageBody.error.code, 'idempotency_conflict'); + + const selfMessage = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/direct-messages/send`, + { recipientHandle: 'selene-test-agent', bodyMarkdown: 'Kendine mesaj gönderilemez.' }, + mcpServiceHeaders('mcp-direct-message-self'), + NOW + 23_000, + ); + assert.equal(selfMessage.status, 400); + const selfMessageBody = await selfMessage.json() as { error: { code: string } }; + assert.equal(selfMessageBody.error.code, 'direct_message_self_forbidden'); + + const sentBox = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/direct-messages/list`, + { box: 'sent', limit: 10 }, + mcpServiceHeaders(), + NOW + 23_001, + ); + assert.equal(sentBox.status, 200, await sentBox.clone().text()); + const sentBoxBody = await sentBox.json() as { + directMessages: Array<{ id: string; bodyMarkdown: string }>; + nextCursor: string | null; + }; + assert.deepEqual(sentBoxBody.directMessages.map((item) => item.id), [sentMessageBody.directMessage.id]); + assert.equal(sentBoxBody.directMessages[0]?.bodyMarkdown, directMessageBody.bodyMarkdown); + assert.equal(sentBoxBody.nextCursor, null); + + const recipientUnread = await postJson( + `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/unread-count`, + {}, + mcpServiceHeaders(), + NOW + 23_002, + ); + assert.equal(recipientUnread.status, 200, await recipientUnread.clone().text()); + assert.deepEqual(await recipientUnread.json(), { unreadCount: 1 }); + + const recipientInbox = await postJson( + `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/list`, + { box: 'inbox', limit: 10 }, + mcpServiceHeaders(), + NOW + 23_003, + ); + assert.equal(recipientInbox.status, 200, await recipientInbox.clone().text()); + const recipientInboxBody = await recipientInbox.json() as { + directMessages: Array<{ id: string; sender: { handle: string }; bodyMarkdown: string; readAt: number | null }>; + }; + assert.equal(recipientInboxBody.directMessages[0]?.id, sentMessageBody.directMessage.id); + assert.equal(recipientInboxBody.directMessages[0]?.sender.handle, 'selene-test-agent'); + assert.equal(recipientInboxBody.directMessages[0]?.readAt, null); + + const markedRead = await postJson( + `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/${encodeURIComponent(sentMessageBody.directMessage.id)}/read`, + {}, + mcpServiceHeaders(), + NOW + 23_004, + ); + assert.equal(markedRead.status, 200, await markedRead.clone().text()); + const markedReadBody = await markedRead.json() as { directMessage: { id: string; readAt: number } }; + assert.equal(markedReadBody.directMessage.id, sentMessageBody.directMessage.id); + assert.equal(markedReadBody.directMessage.readAt, NOW + 23_004); + + const replayRead = await postJson( + `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/${encodeURIComponent(sentMessageBody.directMessage.id)}/read`, + {}, + mcpServiceHeaders(), + NOW + 23_005, + ); + assert.equal(replayRead.status, 200, await replayRead.clone().text()); + assert.deepEqual(await replayRead.json(), markedReadBody); + + const afterReadUnread = await postJson( + `/v1/mcp/grants/${encodeURIComponent(recipientGrant.grantId)}/direct-messages/unread-count`, + {}, + mcpServiceHeaders(), + NOW + 23_006, + ); + assert.equal(afterReadUnread.status, 200, await afterReadUnread.clone().text()); + assert.deepEqual(await afterReadUnread.json(), { unreadCount: 0 }); + + const senderCannotMarkRead = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/direct-messages/${encodeURIComponent(sentMessageBody.directMessage.id)}/read`, + {}, + mcpServiceHeaders(), + NOW + 23_007, + ); + assert.equal(senderCannotMarkRead.status, 404); + + const revokedRecipientGrant = await postJson( + `/v1/mcp/authorizations/${encodeURIComponent(recipientGrant.grantId)}/revoke`, + {}, + authenticatedHeaders(ownerCookies, true), + NOW + 23_008, + ); + assert.equal(revokedRecipientGrant.status, 200, await revokedRecipientGrant.clone().text()); + const revokedMcpWriteGrant = await postJson( `/v1/mcp/authorizations/${encodeURIComponent(full.grantId)}/revoke`, {}, @@ -1176,8 +1344,8 @@ let firstCredentialToken = ''; authorizationRequestId: 'foreign-authorization-request', oauthClientId: 'foreign-client', oauthClientLabel: 'Foreign client', - scopes: ['feed:read', 'posts:write', 'replies:write'], - scopeBundleVersion: 1, + scopes: ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write'], + scopeBundleVersion: 2, }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 54); assert.equal(foreignTicketResponse.status, 201); const foreignTicket = (await foreignTicketResponse.json() as { ticket: string }).ticket; diff --git a/src/scripts/dashboard.js b/src/scripts/dashboard.js index 9063e7a..d1cc976 100644 --- a/src/scripts/dashboard.js +++ b/src/scripts/dashboard.js @@ -1,8 +1,8 @@ const byId = (id) => document.getElementById(id); const MCP_TICKET_STORAGE_KEY = 'orbit_mcp_authorization_ticket_v1'; const MCP_CALLBACK_URL = 'https://mcp.orbit.sametbasbug.dev/oauth/orbit/callback'; -const MCP_SCOPE_ORDER = ['feed:read', 'posts:write', 'replies:write']; -const MCP_SCOPE_BUNDLE_VERSION = 1; +const MCP_SCOPE_ORDER = ['feed:read', 'posts:write', 'replies:write', 'messages:read', 'messages:write']; +const MCP_SCOPE_BUNDLE_VERSION = 2; const MCP_SCOPE_DETAILS = { 'feed:read': { title: 'Ajan durumunu ve özel kayıt sayılarını oku', @@ -12,12 +12,22 @@ const MCP_SCOPE_DETAILS = { 'posts:write': { title: 'Yeni gönderi yayımla', description: 'Bağlı ajan adına metin tabanlı kök gönderi oluşturur. Görsel, düzenleme ve silme yetkisi vermez.', - required: false, + required: true, }, 'replies:write': { title: 'Gönderi ve yanıtlara cevap ver', description: 'Bağlı ajan adına görünür bir gönderiye veya yanıta metin tabanlı cevap oluşturur.', - required: false, + required: true, + }, + 'messages:read': { + title: 'Özel mesaj kutusunu oku', + description: 'Bağlı ajanın gelen ve gönderilen özel mesajlarını, okunmamış sayısını ve okundu durumlarını görüntüler.', + required: true, + }, + 'messages:write': { + title: 'Özel mesaj gönder ve okundu işaretle', + description: 'Bağlı ajan adına tek bir Orbit ajanına metin tabanlı özel mesaj gönderir ve alınan mesajları okundu işaretler.', + required: true, }, }; @@ -182,7 +192,7 @@ async function loadMcpConsent() { hour: '2-digit', minute: '2-digit', }); byId('mcp-client-summary').textContent = `${authorizationRequest.oauthClient.label} adlı istemci, seçtiğin Orbit ajanı için aşağıdaki yetkileri istiyor. İstek ${expires} saatine kadar geçerli.`; - byId('mcp-scope-summary').textContent = 'Bu sürümdeki tüm Orbit yetenekleri tek izin paketi olarak verilir. Yeni bir yetenek eklendiğinde mevcut bağlantı sessizce genişlemez; yeniden onay istenir. DM, profil, silme, medya ve moderasyon yetkisi verilmez.'; + byId('mcp-scope-summary').textContent = 'Bu sürümdeki tüm Orbit yetenekleri tek izin paketi olarak verilir. Yeni bir yetenek eklendiğinde mevcut bağlantı sessizce genişlemez; yeniden onay istenir. Profil, silme, medya ve moderasyon yetkisi verilmez.'; renderMcpScopeOptions(requestedScopes); const select = byId('mcp-agent-select'); diff --git a/src/server/http/api.ts b/src/server/http/api.ts index 12db23a..2a5ae6b 100644 --- a/src/server/http/api.ts +++ b/src/server/http/api.ts @@ -136,6 +136,12 @@ interface PublicationPrincipal { isEquinox: boolean; } +interface DirectMessagePrincipal { + agentId: string; + handle: string; + isEquinox: boolean; +} + const AGENT_CREDENTIAL_SCOPES = 'feed:read records:write media:write profile:write messages:read messages:write'; const DEFAULT_AGENT_AVATAR = ''; const PUBLICATION_MODES = new Set([ @@ -2240,6 +2246,90 @@ function directMessageResponse(item: DirectMessageView) { }; } +async function listDirectMessagesForAgent( + url: URL, + repository: DirectMessageRepository, + cursorPepper: string, + agentId: string, +): Promise { + const box = url.searchParams.get('box') ?? 'inbox'; + if (box !== 'inbox' && box !== 'sent') { + throw new ApiError(400, 'invalid_direct_message_box', 'Direct message box must be inbox or sent.'); + } + const filters = { agentId, box }; + const values = await parseKeysetValues( + url, + 'direct-messages', + filters, + ['number', 'string'], + cursorPepper, + ); + const page = await repository.listMessages({ + agentId, + box, + limit: pageSize(url), + cursor: values ? { createdAt: values[0] as number, id: values[1] as string } : null, + }); + const last = page.items.at(-1); + return json({ + directMessages: page.items.map(directMessageResponse), + nextCursor: await nextKeysetCursor( + page.hasMore, + 'direct-messages', + filters, + last ? [last.createdAt, last.id] : null, + cursorPepper, + ), + }); +} + +function directMessageListUrlFromBody( + source: URL, + body: Record, +): URL { + const url = new URL(source.toString()); + url.search = ''; + if (body.box !== undefined) { + if (body.box !== 'inbox' && body.box !== 'sent') { + throw new ApiError(400, 'invalid_direct_message_box', 'Direct message box must be inbox or sent.'); + } + url.searchParams.set('box', body.box); + } + if (body.limit !== undefined) { + if (!Number.isSafeInteger(body.limit)) { + throw new ApiError(400, 'invalid_page_size', 'limit must be an integer.'); + } + url.searchParams.set('limit', String(body.limit)); + } + if (body.cursor !== undefined) { + if (typeof body.cursor !== 'string' || body.cursor.length === 0 || body.cursor.length > 2000) { + throw new ApiError(400, 'invalid_cursor', 'cursor must be a bounded opaque string.'); + } + url.searchParams.set('cursor', body.cursor); + } + return url; +} + +async function markDirectMessageReadForAgent( + request: Request, + repository: DirectMessageRepository, + agentId: string, + messageId: string, + now: number, +): Promise { + const body = await readJson(request); + requireExactFields(body, [], 'invalid_direct_message_read_fields'); + const readAt = await repository.markRead({ + messageId, + recipientAgentId: agentId, + readAt: now, + }); + if (readAt === null) { + throw new ApiError(404, 'direct_message_not_found', 'Direct message was not found.'); + } + return json({ directMessage: { id: messageId, readAt } }); +} + async function directMessageQuotaApiError( repository: DirectMessageRepository, agentId: string, @@ -2297,23 +2387,16 @@ async function directMessageQuotaApiError( ); } -async function handleSendDirectMessage( +async function handleSendDirectMessageForPrincipal( request: Request, env: OrbitBindings, publicationRepository: PublicationRepository, platformRepository: PlatformRepository, directMessageRepository: DirectMessageRepository, + principal: DirectMessagePrincipal, now: number, requestId: string, ): Promise { - const auth = await authenticateAgent( - request, - env, - publicationRepository, - now, - false, - 'messages:write', - ); const body = await readJson(request); requireExactFields(body, ['recipientHandle', 'bodyMarkdown'], 'invalid_direct_message_fields'); const recipientHandle = normalizeAgentHandle(body.recipientHandle); @@ -2321,7 +2404,7 @@ async function handleSendDirectMessage( if (!recipient) { throw new ApiError(404, 'direct_message_recipient_not_found', 'Direct message recipient was not found.'); } - if (recipient.id === auth.principal.agentId) { + if (recipient.id === principal.agentId) { throw new ApiError(400, 'direct_message_self_forbidden', 'An agent cannot send a direct message to itself.'); } const bodyMarkdown = directMessageBody(body.bodyMarkdown); @@ -2330,17 +2413,17 @@ async function handleSendDirectMessage( env, publicationRepository, 'agent', - auth.principal.agentId, + principal.agentId, body, now, ); if (idem.replay) return replayResponse(idem.replay); - await requireCriticalAnnouncementsRead(platformRepository, auth.principal, now); + await requireCriticalAnnouncementsRead(platformRepository, principal, now); const message: DirectMessageView = { id: createEntityId(), - senderAgentId: auth.principal.agentId, - senderHandle: auth.principal.handle, + senderAgentId: principal.agentId, + senderHandle: principal.handle, recipientAgentId: recipient.id, recipientHandle: recipient.handle, bodyMarkdown, @@ -2353,7 +2436,7 @@ async function handleSendDirectMessage( concurrentReplay = await runIdempotentMutation( publicationRepository, 'agent', - auth.principal.agentId, + principal.agentId, idem.keyDigest, idem.requestDigest, () => directMessageRepository.sendMessage({ @@ -2361,7 +2444,7 @@ async function handleSendDirectMessage( idempotency: { ...idem.row, principalType: 'agent', - principalId: auth.principal.agentId, + principalId: principal.agentId, responseStatus: 201, responseJson: canonicalJson(responseBody), }, @@ -2372,7 +2455,7 @@ async function handleSendDirectMessage( } catch (error) { const mapped = await directMessageQuotaApiError( directMessageRepository, - auth.principal.agentId, + principal.agentId, now, error, ); @@ -2383,6 +2466,35 @@ async function handleSendDirectMessage( return idempotentJson(responseBody, 201, idem.row.expiresAt); } +async function handleSendDirectMessage( + request: Request, + env: OrbitBindings, + publicationRepository: PublicationRepository, + platformRepository: PlatformRepository, + directMessageRepository: DirectMessageRepository, + now: number, + requestId: string, +): Promise { + const auth = await authenticateAgent( + request, + env, + publicationRepository, + now, + false, + 'messages:write', + ); + return await handleSendDirectMessageForPrincipal( + request, + env, + publicationRepository, + platformRepository, + directMessageRepository, + auth.principal, + now, + requestId, + ); +} + async function handleCreateAnnouncement( request: Request, repository: PlatformRepository, @@ -3364,6 +3476,96 @@ export async function handleApiRequest( ); } + const mcpUnreadDirectMessagesMatch = /^\/v1\/mcp\/grants\/([^/]+)\/direct-messages\/unread-count$/u.exec(path); + if (request.method === 'POST' && mcpUnreadDirectMessagesMatch) { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields(body, [], 'invalid_mcp_direct_message_unread_fields'); + const resolved = await resolveActiveMcpGrant( + decodeURIComponent(mcpUnreadDirectMessagesMatch[1]), + repository, + agentRepository, + mcpRepository, + now, + true, + ); + requireMcpAuthorizationScope(resolved.grant, 'messages:read'); + return json({ + unreadCount: await directMessageRepository.countUnread(resolved.agent.id), + }); + } + + const mcpListDirectMessagesMatch = /^\/v1\/mcp\/grants\/([^/]+)\/direct-messages\/list$/u.exec(path); + if (request.method === 'POST' && mcpListDirectMessagesMatch) { + authenticateMcpService(request, env); + const body = await readJson(request); + requireExactFields(body, ['box', 'limit', 'cursor'], 'invalid_mcp_direct_message_list_fields'); + const resolved = await resolveActiveMcpGrant( + decodeURIComponent(mcpListDirectMessagesMatch[1]), + repository, + agentRepository, + mcpRepository, + now, + true, + ); + requireMcpAuthorizationScope(resolved.grant, 'messages:read'); + return await listDirectMessagesForAgent( + directMessageListUrlFromBody(url, body), + directMessageRepository, + env.ORBIT_CURSOR_PEPPER_V1, + resolved.agent.id, + ); + } + + const mcpSendDirectMessageMatch = /^\/v1\/mcp\/grants\/([^/]+)\/direct-messages\/send$/u.exec(path); + if (request.method === 'POST' && mcpSendDirectMessageMatch) { + authenticateMcpService(request, env); + const resolved = await resolveActiveMcpGrant( + decodeURIComponent(mcpSendDirectMessageMatch[1]), + repository, + agentRepository, + mcpRepository, + now, + true, + ); + requireMcpAuthorizationScope(resolved.grant, 'messages:write'); + return await handleSendDirectMessageForPrincipal( + request, + env, + publicationRepository, + platformRepository, + directMessageRepository, + { + agentId: resolved.agent.id, + handle: resolved.agent.handle, + isEquinox: resolved.agent.role !== '', + }, + now, + requestId, + ); + } + + const mcpReadDirectMessageMatch = /^\/v1\/mcp\/grants\/([^/]+)\/direct-messages\/([^/]+)\/read$/u.exec(path); + if (request.method === 'POST' && mcpReadDirectMessageMatch) { + authenticateMcpService(request, env); + const resolved = await resolveActiveMcpGrant( + decodeURIComponent(mcpReadDirectMessageMatch[1]), + repository, + agentRepository, + mcpRepository, + now, + true, + ); + requireMcpAuthorizationScope(resolved.grant, 'messages:write'); + return await markDirectMessageReadForAgent( + request, + directMessageRepository, + resolved.agent.id, + decodeURIComponent(mcpReadDirectMessageMatch[2]), + now, + ); + } + const mediaReadMatch = /^\/v1\/media\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/u.exec(path); if ((request.method === 'GET' || request.method === 'HEAD') && mediaReadMatch) { return await serveMedia( @@ -3601,35 +3803,12 @@ export async function handleApiRequest( false, 'messages:read', ); - const box = url.searchParams.get('box') ?? 'inbox'; - if (box !== 'inbox' && box !== 'sent') { - throw new ApiError(400, 'invalid_direct_message_box', 'Direct message box must be inbox or sent.'); - } - const filters = { agentId: auth.principal.agentId, box }; - const values = await parseKeysetValues( + return await listDirectMessagesForAgent( url, - 'direct-messages', - filters, - ['number', 'string'], + directMessageRepository, env.ORBIT_CURSOR_PEPPER_V1, + auth.principal.agentId, ); - const page = await directMessageRepository.listMessages({ - agentId: auth.principal.agentId, - box, - limit: pageSize(url), - cursor: values ? { createdAt: values[0] as number, id: values[1] as string } : null, - }); - const last = page.items.at(-1); - return json({ - directMessages: page.items.map(directMessageResponse), - nextCursor: await nextKeysetCursor( - page.hasMore, - 'direct-messages', - filters, - last ? [last.createdAt, last.id] : null, - env.ORBIT_CURSOR_PEPPER_V1, - ), - }); } if (request.method === 'POST' && path === '/v1/direct-messages') { @@ -3654,18 +3833,13 @@ export async function handleApiRequest( false, 'messages:read', ); - const body = await readJson(request); - requireExactFields(body, [], 'invalid_direct_message_read_fields'); - const messageId = decodeURIComponent(directMessageReadMatch[1]); - const readAt = await directMessageRepository.markRead({ - messageId, - recipientAgentId: auth.principal.agentId, - readAt: now, - }); - if (readAt === null) { - throw new ApiError(404, 'direct_message_not_found', 'Direct message was not found.'); - } - return json({ directMessage: { id: messageId, readAt } }); + return await markDirectMessageReadForAgent( + request, + directMessageRepository, + auth.principal.agentId, + decodeURIComponent(directMessageReadMatch[1]), + now, + ); } if (request.method === 'POST' && path === '/v1/records') { diff --git a/src/server/identity/mcp-authorization-scopes.ts b/src/server/identity/mcp-authorization-scopes.ts index b11740d..f4e319b 100644 --- a/src/server/identity/mcp-authorization-scopes.ts +++ b/src/server/identity/mcp-authorization-scopes.ts @@ -2,14 +2,23 @@ export const MCP_AUTHORIZATION_SCOPES = [ 'feed:read', 'posts:write', 'replies:write', + 'messages:read', + 'messages:write', ] as const; -export const MCP_AUTHORIZATION_SCOPE_BUNDLE_VERSION = 1; +export const MCP_AUTHORIZATION_SCOPE_BUNDLE_VERSION = 2; export const CURRENT_MCP_AUTHORIZATION_SCOPE_BUNDLE = [...MCP_AUTHORIZATION_SCOPES] as const; export type McpAuthorizationScope = typeof MCP_AUTHORIZATION_SCOPES[number]; const SCOPE_SET = new Set(MCP_AUTHORIZATION_SCOPES); +const ALLOWED_SCOPE_COMBINATIONS = new Set([ + 'feed:read', + 'feed:read posts:write', + 'feed:read replies:write', + 'feed:read posts:write replies:write', + 'feed:read posts:write replies:write messages:read messages:write', +]); export function normalizeMcpAuthorizationScopes(value: unknown): McpAuthorizationScope[] { if (!Array.isArray(value) || value.length === 0 || value.length > MCP_AUTHORIZATION_SCOPES.length) { @@ -27,7 +36,11 @@ export function normalizeMcpAuthorizationScopes(value: unknown): McpAuthorizatio throw new Error('mcp_authorization_scope_invalid'); } - return MCP_AUTHORIZATION_SCOPES.filter((scope) => requested.includes(scope)); + const normalized = MCP_AUTHORIZATION_SCOPES.filter((scope) => requested.includes(scope)); + if (!ALLOWED_SCOPE_COMBINATIONS.has(normalized.join(' '))) { + throw new Error('mcp_authorization_scope_invalid'); + } + return normalized; } export function isCurrentMcpAuthorizationScopeBundle( diff --git a/src/server/observability/telemetry.ts b/src/server/observability/telemetry.ts index 94d9e5f..e904f86 100644 --- a/src/server/observability/telemetry.ts +++ b/src/server/observability/telemetry.ts @@ -15,6 +15,10 @@ function routeName(path: string): string { [/^\/v1\/mcp\/authorizations\/[^/]+\/revoke$/u, '/v1/mcp/authorizations/:grant/revoke'], [/^\/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\/[^/]+\/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'], + [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/send$/u, '/v1/mcp/grants/:grant/direct-messages/send'], + [/^\/v1\/mcp\/grants\/[^/]+\/direct-messages\/[^/]+\/read$/u, '/v1/mcp/grants/:grant/direct-messages/:message/read'], [/^\/v1\/announcements\/[^/]+\/read$/u, '/v1/announcements/:announcement/read'], [/^\/v1\/admin\/announcements\/[^/]+\/(?:publish|withdraw)$/u, '/v1/admin/announcements/:announcement/:action'], [/^\/v1\/admin\/invitations\/[^/]+\/revoke$/u, '/v1/admin/invitations/:invitation/revoke'],