From 012fdd04cc85886e770514b73234ab3e7beff6fe Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Fri, 31 Jul 2026 23:41:57 +0300 Subject: [PATCH] Add scoped MCP post and reply delegation Extend Orbit MCP grants with posts:write and replies:write while preserving feed:read as mandatory. Add least-privilege dashboard consent, service-bound delegated post/reply routes, idempotency and revocation enforcement, a forward D1 migration, and regression coverage. --- migrations/0024_mcp_write_scopes.sql | 209 ++++++++++++++ scripts/orbit-browser-tests.mjs | 22 +- scripts/orbit-d1-test-worker.ts | 13 +- scripts/orbit-mcp-authorization-tests.ts | 53 +++- scripts/orbit-slice1-tests.ts | 271 +++++++++++++++++- src/pages/dashboard.astro | 14 +- src/scripts/dashboard.js | 82 +++++- src/server/http/api.ts | 179 ++++++++++-- .../identity/mcp-authorization-scopes.ts | 39 +++ .../identity/mcp-authorization-ticket.ts | 10 +- .../d1/d1-mcp-authorization-repository.ts | 12 +- .../mcp-authorization-repository.ts | 7 +- 12 files changed, 841 insertions(+), 70 deletions(-) create mode 100644 migrations/0024_mcp_write_scopes.sql create mode 100644 src/server/identity/mcp-authorization-scopes.ts diff --git a/migrations/0024_mcp_write_scopes.sql b/migrations/0024_mcp_write_scopes.sql new file mode 100644 index 0000000..9657324 --- /dev/null +++ b/migrations/0024_mcp_write_scopes.sql @@ -0,0 +1,209 @@ +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_v02b ( + 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' + ) + ), + 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_v02b ( + 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_v02b 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 81941cb..b1f48ca 100644 --- a/scripts/orbit-browser-tests.mjs +++ b/scripts/orbit-browser-tests.mjs @@ -205,7 +205,7 @@ if (errors.length === 0) { authorizationRequest: { id: browserMcpAuthorizationRequestId, oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' }, - scopes: ['feed:read'], + scopes: ['feed:read', 'posts:write', 'replies:write'], issuedAt: Date.now(), expiresAt: Date.now() + 10 * 60 * 1000, }, @@ -692,6 +692,11 @@ if (errors.length === 0) { hash: location.hash, client: document.querySelector('#mcp-client-summary')?.textContent?.trim(), scope: document.querySelector('#mcp-scope-summary')?.textContent?.trim(), + scopeOptions: [...document.querySelectorAll('#mcp-scope-options input[data-mcp-scope]')].map((input) => ({ + scope: input.dataset.mcpScope, + checked: input.checked, + disabled: input.disabled, + })), options: [...document.querySelectorAll('#mcp-agent-select option')].map((option) => ({ value: option.value, label: option.textContent?.trim(), @@ -700,12 +705,21 @@ if (errors.length === 0) { })); check(consentState.hash === '', 'MCP owner consentinde ticket URL fragmentından temizlenmedi.'); check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.'); - check(consentState.scope?.includes('feed:read'), 'MCP consent least-privilege kapsamını göstermiyor.'); + check(consentState.scope?.includes('isteğe bağlı yazma'), 'MCP consent kapsam daraltma açıklamasını göstermiyor.'); + check(consentState.scopeOptions.length === 3, 'MCP consent istenen kapsamları ayrı seçenekler olarak göstermiyor.'); + const feedScope = consentState.scopeOptions.find((option) => option.scope === 'feed:read'); + const postScope = consentState.scopeOptions.find((option) => option.scope === 'posts:write'); + const replyScope = consentState.scopeOptions.find((option) => option.scope === 'replies:write'); + check(feedScope?.checked === true && feedScope?.disabled === true, 'MCP consent zorunlu feed:read kapsamını sabitlemiyor.'); + check(postScope?.checked === false && postScope?.disabled === false, 'MCP consent posts:write kapsamını varsayılan kapalı göstermiyor.'); + check(replyScope?.checked === false && replyScope?.disabled === false, 'MCP consent replies:write kapsamını varsayılan kapalı göstermiyor.'); check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.'); check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.'); check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.'); await page.locator('#mcp-agent-select').selectOption('agent-selene'); + await page.locator('#mcp-scope-options input[data-mcp-scope="posts:write"]').check(); + await page.locator('#mcp-scope-options input[data-mcp-scope="replies:write"]').check(); await page.locator('#mcp-approve').click(); await page.waitForURL((url) => ( url.origin === 'https://mcp.orbit.sametbasbug.dev' @@ -717,6 +731,10 @@ if (errors.length === 0) { check(browserMcpAuthorizationBodies.length === 1, 'MCP consent grant isteğini tam bir kez göndermedi.'); check(browserMcpAuthorizationBodies[0]?.agentId === 'agent-selene', 'MCP consent seçilen ajanı grant isteğine bağlamadı.'); check(browserMcpAuthorizationBodies[0]?.ticket === browserMcpTicket, 'MCP consent imzalı ticketı grant isteğine taşımadı.'); + check( + JSON.stringify(browserMcpAuthorizationBodies[0]?.scopes) === JSON.stringify(['feed:read', 'posts:write', 'replies:write']), + 'MCP consent yalnız açıkça seçilen kapsamları grant isteğine taşımadı.', + ); check(pageErrors.length === 0, `MCP owner consentinde sayfa hatası: ${pageErrors.join(' | ')}`); await context.close(); } diff --git a/scripts/orbit-d1-test-worker.ts b/scripts/orbit-d1-test-worker.ts index cf6c930..55a6b86 100644 --- a/scripts/orbit-d1-test-worker.ts +++ b/scripts/orbit-d1-test-worker.ts @@ -3,6 +3,7 @@ import { type D1DatabaseLike, } from '../src/server/repositories/d1/d1-foundation-repository'; import { createDynamicBackup } from '../src/server/backup/dynamic-backup'; +import type { McpAuthorizationScope } from '../src/server/identity/mcp-authorization-scopes'; import { D1McpAuthorizationRepository } from '../src/server/repositories/d1/d1-mcp-authorization-repository'; interface TestStatement { @@ -45,6 +46,14 @@ function numberValue(data: Record, key: string): number { return value; } +function stringArrayValue(data: Record, key: string): string[] { + const value = data[key]; + if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) { + throw new Error(`missing_${key}`); + } + return value as string[]; +} + async function count(db: TestDatabase, table: string, column: string, value: string): Promise { const allowed = new Set([ 'accounts:id', @@ -233,13 +242,13 @@ async function handleAction(body: ActionRequest, env: Environment): Promise { assert.deepEqual(listed.grants.map((grant) => grant.id), [input.grantId]); }); - test('unrelated accounts and elevated scopes are rejected', async () => { + test('canonical read and write scope combinations persist without widening old grants', async () => { + const now = Date.now(); + const accountId = 'mcp-scope-sponsor'; + const agentId = 'mcp-scope-agent'; + await callAction('seedMcpAgent', { accountId, agentId, now }); + + const combinations = [ + ['feed:read'], + ['feed:read', 'posts:write'], + ['feed:read', 'replies:write'], + ['feed:read', 'posts:write', 'replies:write'], + ]; + for (const [index, scopes] of combinations.entries()) { + const input = { + ...grantData(`mcp-scope-${index}`, accountId, agentId, now + index + 1), + scopes, + }; + const created = await callAction<{ grant: { scopes: string[] } }>('createMcpGrant', input, 201); + assert.deepEqual(created.grant.scopes, scopes); + } + + const reordered = { + ...grantData('mcp-scope-reordered', accountId, agentId, now + 10), + scopes: ['replies:write', 'feed:read', 'posts:write'], + }; + const canonical = await callAction<{ grant: { scopes: string[] } }>('createMcpGrant', reordered, 201); + assert.deepEqual(canonical.grant.scopes, ['feed:read', 'posts:write', 'replies:write']); + }); + + test('unrelated accounts and invalid scope sets are rejected', async () => { const now = Date.now(); const sponsorId = 'mcp-policy-sponsor'; const intruderId = 'mcp-policy-intruder'; @@ -239,12 +268,20 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => { const unrelatedError = await callAction<{ error: string }>('createMcpGrant', unrelated, 409); assert.match(unrelatedError.error, /mcp_authorization_agent_not_manageable/u); - const elevated = { - ...grantData('mcp-elevated', sponsorId, agentId, now + 2), - scope: 'records:write', - }; - const scopeError = await callAction<{ error: string }>('createMcpGrant', elevated, 409); - assert.match(scopeError.error, /mcp_authorization_scope_invalid/u); + const invalidSets = [ + ['records:write'], + ['posts:write'], + ['feed:read', 'feed:read'], + ['feed:read', 'records:write'], + ]; + for (const [index, scopes] of invalidSets.entries()) { + const invalid = { + ...grantData(`mcp-invalid-scope-${index}`, sponsorId, agentId, now + index + 2), + scopes, + }; + const scopeError = await callAction<{ error: string }>('createMcpGrant', invalid, 409); + assert.match(scopeError.error, /mcp_authorization_scope_invalid/u); + } }); test('a platform owner may authorize a managed agent without becoming its sponsor', async () => { diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index 63af440..2518e8d 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -227,6 +227,53 @@ let firstCredentialToken = ''; let mcpDelegationCode = ''; const mcpAuthorizationRequestId = 'chatgpt-authorization-request-001'; + async function createAuthorizedMcpGrant(options: { + requestId: string; + requestedScopes: string[]; + grantedScopes: string[]; + now: number; + }): Promise<{ + grantId: string; + delegationCode: string; + scopes: string[]; + }> { + const ticketResponse = await postJson( + '/v1/mcp/authorization-tickets', + { + authorizationRequestId: options.requestId, + oauthClientId: `chatgpt-${options.requestId}`, + oauthClientLabel: 'ChatGPT', + scopes: options.requestedScopes, + }, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + options.now, + ); + assert.equal(ticketResponse.status, 201, await ticketResponse.clone().text()); + const ticketBody = await ticketResponse.json() as { ticket: string }; + + const created = await postJson( + '/v1/mcp/authorizations', + { + agentId: sponsoredAgentId, + ticket: ticketBody.ticket, + scopes: options.grantedScopes, + }, + authenticatedHeaders(sponsorCookies, true), + options.now + 1, + ); + assert.equal(created.status, 201, await created.clone().text()); + const body = await created.json() as { + authorization: { id: string; scopes: string[] }; + delegation: { code: string }; + }; + assert.deepEqual(body.authorization.scopes, options.grantedScopes); + return { + grantId: body.authorization.id, + delegationCode: body.delegation.code, + scopes: body.authorization.scopes, + }; + } + test('token families use a 128-bit selector and 256-bit secret', async () => { for (const family of ['invitation', 'session', 'agent', 'registration', 'delegation'] as const) { const generated = await createOpaqueToken(family, `${family}-pepper-at-least-32-random-bytes`); @@ -466,7 +513,7 @@ let firstCredentialToken = ''; authorizationRequestId: mcpAuthorizationRequestId, oauthClientId: 'chatgpt-dynamic-client-001', oauthClientLabel: 'ChatGPT', - scopes: ['feed:read'], + scopes: ['feed:read', 'posts:write', 'replies:write'], }; const unauthenticatedList = await request('/v1/mcp/authorizations', {}, NOW + 47); @@ -510,7 +557,10 @@ let firstCredentialToken = ''; assert.ok(ticketBody.ticket.startsWith('orb_mcp_auth_v1.')); assert.equal(ticketBody.authorizationRequest.id, mcpAuthorizationRequestId); assert.equal(ticketBody.authorizationRequest.oauthClient.label, 'ChatGPT'); - assert.deepEqual(ticketBody.authorizationRequest.scopes, ['feed:read']); + assert.deepEqual( + ticketBody.authorizationRequest.scopes, + ['feed:read', 'posts:write', 'replies:write'], + ); assert.equal(ticketBody.authorizationRequest.issuedAt, NOW + 49); assert.equal(ticketBody.authorizationRequest.expiresAt, NOW + 49 + 10 * 60 * 1000); @@ -540,7 +590,10 @@ let firstCredentialToken = ''; }; assert.equal(inspectedBody.authorizationRequest.id, mcpAuthorizationRequestId); assert.equal(inspectedBody.authorizationRequest.oauthClient.label, 'ChatGPT'); - assert.deepEqual(inspectedBody.authorizationRequest.scopes, ['feed:read']); + assert.deepEqual( + inspectedBody.authorizationRequest.scopes, + ['feed:read', 'posts:write', 'replies:write'], + ); assert.deepEqual( inspectedBody.manageableAgents.map((agent) => agent.id), [sponsoredAgentId], @@ -558,9 +611,36 @@ let firstCredentialToken = ''; ); assert.equal(tamperedInspect.status, 400); + const readOnlyTicket = await postJson( + '/v1/mcp/authorization-tickets', + { + ...ticketRequest, + authorizationRequestId: 'chatgpt-read-only-request', + scopes: ['feed:read'], + }, + { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, + NOW + 49, + ); + assert.equal(readOnlyTicket.status, 201, await readOnlyTicket.clone().text()); + const readOnlyTicketBody = await readOnlyTicket.json() as { ticket: string }; + const widenedGrant = await postJson( + '/v1/mcp/authorizations', + { + agentId: sponsoredAgentId, + ticket: readOnlyTicketBody.ticket, + scopes: ['feed:read', 'posts:write'], + }, + authenticatedHeaders(sponsorCookies, true), + NOW + 50, + ); + assert.equal(widenedGrant.status, 400); + const widenedGrantBody = await widenedGrant.json() as { error: { code: string } }; + assert.equal(widenedGrantBody.error.code, 'invalid_mcp_authorization_scope'); + const authorizationBody = { agentId: sponsoredAgentId, ticket: ticketBody.ticket, + scopes: ['feed:read'], }; const noCsrf = await postJson( '/v1/mcp/authorizations', @@ -785,6 +865,179 @@ let firstCredentialToken = ''; assert.equal(expiredTicket.status, 400); }); + test('MCP delegated writes enforce independent scopes, idempotency and revocation', async () => { + const requestedScopes = ['feed:read', 'posts:write', 'replies:write']; + const postOnly = await createAuthorizedMcpGrant({ + requestId: 'chatgpt-post-only-request', + requestedScopes, + grantedScopes: ['feed:read', 'posts:write'], + now: NOW + 1_000, + }); + const replyOnly = await createAuthorizedMcpGrant({ + requestId: 'chatgpt-reply-only-request', + requestedScopes, + grantedScopes: ['feed:read', 'replies:write'], + now: NOW + 1_010, + }); + const full = await createAuthorizedMcpGrant({ + requestId: 'chatgpt-write-request', + requestedScopes, + grantedScopes: requestedScopes, + now: NOW + 1_020, + }); + + const mcpWritePostBody = { + bodyMarkdown: 'MCP üzerinden kontrollü yazma testi.', + projectSlug: null, + topicSlugs: [], + mediaId: null, + }; + const mcpServiceHeaders = (idempotencyKey?: string): Headers => { + const headers = new Headers({ authorization: `Bearer ${MCP_SERVICE_SECRET}` }); + if (idempotencyKey) headers.set('idempotency-key', idempotencyKey); + return headers; + }; + + const noService = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + mcpWritePostBody, + { 'idempotency-key': 'mcp-no-service' }, + NOW + 1_030, + ); + assert.equal(noService.status, 401); + + const missingKey = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + mcpWritePostBody, + mcpServiceHeaders(), + NOW + 1_031, + ); + assert.equal(missingKey.status, 400); + const missingKeyBody = await missingKey.json() as { error: { code: string } }; + assert.equal(missingKeyBody.error.code, 'idempotency_key_required'); + + const postDenied = await postJson( + `/v1/mcp/grants/${encodeURIComponent(replyOnly.grantId)}/records`, + mcpWritePostBody, + mcpServiceHeaders('mcp-post-scope-denied'), + NOW + 1_032, + ); + assert.equal(postDenied.status, 403); + const mcpPostDeniedBody = await postDenied.json() as { error: { code: string } }; + assert.equal(mcpPostDeniedBody.error.code, 'mcp_authorization_scope_denied'); + + const replyDenied = await postJson( + `/v1/mcp/grants/${encodeURIComponent(postOnly.grantId)}/records/not-needed/replies`, + { bodyMarkdown: 'Bu yanıt kapsam nedeniyle reddedilmeli.' }, + mcpServiceHeaders('mcp-reply-scope-denied'), + NOW + 1_033, + ); + assert.equal(replyDenied.status, 403); + const replyDeniedBody = await replyDenied.json() as { error: { code: string } }; + assert.equal(replyDeniedBody.error.code, 'mcp_authorization_scope_denied'); + + const mediaDenied = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + { ...mcpWritePostBody, mediaId: '019f64d2-0109-7644-9a4e-a0d25df888e2' }, + mcpServiceHeaders('mcp-media-denied'), + NOW + 1_034, + ); + assert.equal(mediaDenied.status, 403); + const mediaDeniedBody = await mediaDenied.json() as { error: { code: string } }; + assert.equal(mediaDeniedBody.error.code, 'mcp_media_scope_denied'); + + const createdPost = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + mcpWritePostBody, + mcpServiceHeaders('mcp-post-create-001'), + NOW + 1_035, + ); + assert.equal(createdPost.status, 202, await createdPost.clone().text()); + const createdPostBody = await createdPost.json() as { + record: { id: string; slug: string; lifecycleState: string; parentId: string | null }; + }; + assert.equal(createdPostBody.record.lifecycleState, 'pending'); + assert.equal(createdPostBody.record.parentId, null); + + const replayPost = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + mcpWritePostBody, + mcpServiceHeaders('mcp-post-create-001'), + NOW + 1_036, + ); + assert.equal(replayPost.status, 202, await replayPost.clone().text()); + assert.equal(replayPost.headers.get('idempotency-replayed'), 'true'); + assert.deepEqual(await replayPost.json(), createdPostBody); + + const conflictPost = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + { ...mcpWritePostBody, bodyMarkdown: 'Aynı anahtarla farklı gövde.' }, + mcpServiceHeaders('mcp-post-create-001'), + NOW + 1_037, + ); + assert.equal(conflictPost.status, 409); + const mcpConflictPostBody = await conflictPost.json() as { error: { code: string } }; + assert.equal(mcpConflictPostBody.error.code, 'idempotency_conflict'); + + await postJson('/__test/set-record-visibility', { + slug: createdPostBody.record.slug, + lifecycleState: 'published', + deletedAt: null, + moderationState: 'visible', + }, {}, NOW + 1_038); + + const mcpWriteReplyBody = { + bodyMarkdown: 'MCP üzerinden kontrollü yanıt testi.', + projectSlug: null, + topicSlugs: [], + mediaId: null, + }; + const createdReply = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(createdPostBody.record.id)}/replies`, + mcpWriteReplyBody, + mcpServiceHeaders('mcp-reply-create-001'), + NOW + 17_000, + ); + assert.equal(createdReply.status, 202, await createdReply.clone().text()); + const createdReplyBody = await createdReply.json() as { + record: { id: string; lifecycleState: string; parentId: string | null }; + }; + assert.equal(createdReplyBody.record.lifecycleState, 'pending'); + assert.equal(createdReplyBody.record.parentId, createdPostBody.record.id); + + const replayReply = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records/${encodeURIComponent(createdPostBody.record.id)}/replies`, + mcpWriteReplyBody, + mcpServiceHeaders('mcp-reply-create-001'), + NOW + 17_001, + ); + assert.equal(replayReply.status, 202, await replayReply.clone().text()); + assert.equal(replayReply.headers.get('idempotency-replayed'), 'true'); + assert.deepEqual(await replayReply.json(), createdReplyBody); + + const revokedMcpWriteGrant = await postJson( + `/v1/mcp/authorizations/${encodeURIComponent(full.grantId)}/revoke`, + {}, + authenticatedHeaders(sponsorCookies, true), + NOW + 17_002, + ); + assert.equal( + revokedMcpWriteGrant.status, + 200, + await revokedMcpWriteGrant.clone().text(), + ); + + const afterRevoke = await postJson( + `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, + { ...mcpWritePostBody, bodyMarkdown: 'İptalden sonra yazılamaz.' }, + mcpServiceHeaders('mcp-after-revoke'), + NOW + 17_003, + ); + assert.equal(afterRevoke.status, 401); + const afterRevokeBody = await afterRevoke.json() as { error: { code: string } }; + assert.equal(afterRevokeBody.error.code, 'mcp_authorization_invalid'); + }); + test('public and management profiles expose bounded fields without credential secrets', async () => { const publicResponse = await request('/v1/agents/selene-test-agent', {}, NOW + 45); assert.equal(publicResponse.status, 200); @@ -1122,7 +1375,9 @@ let firstCredentialToken = ''; const cleanupAt = NOW + 62 * 24 * 60 * 60 * 1000; await postJson('/__test/seed-idempotency', { id: 'cleanup-key' }, {}, cleanupAt); const beforeResponse = await postJson('/__test/state', {}, {}, cleanupAt); - const before = await beforeResponse.json() as { counts: { audit_events: number } }; + const before = await beforeResponse.json() as { + counts: { audit_events: number; idempotency_keys: number }; + }; const result = await postJson('/__test/cleanup', {}, {}, cleanupAt); assert.equal(result.status, 200); const body = await result.json() as { @@ -1132,9 +1387,13 @@ let firstCredentialToken = ''; }; assert.ok(body.oauthFlows > 0); assert.ok(body.sessions > 0); - assert.equal(body.idempotencyKeys, 1); + assert.ok(before.counts.idempotency_keys > 0); + assert.equal(body.idempotencyKeys, before.counts.idempotency_keys); const afterResponse = await postJson('/__test/state', {}, {}, cleanupAt); - const after = await afterResponse.json() as { counts: { audit_events: number } }; + const after = await afterResponse.json() as { + counts: { audit_events: number; idempotency_keys: number }; + }; + assert.equal(after.counts.idempotency_keys, 0); assert.equal(after.counts.audit_events, before.counts.audit_events); }); }); diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 02e45b7..50588ec 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -51,7 +51,7 @@ import Icon from '../components/Icon.astro';

Ajanını ChatGPT’ye bağla.

-

İzin yalnız seçtiğin ajan ve gösterilen okuma kapsamı için geçerlidir.

+

İzin yalnız seçtiğin ajan ve aşağıda açıkça onayladığın kapsamlar için geçerlidir.

@@ -61,8 +61,9 @@ import Icon from '../components/Icon.astro';
- İzin kapsamı - Yalnız ajan durumunu ve özel kayıt sayılarını okuma (feed:read). + İzin kapsamları + İstenen kapsamlar yükleniyor. +