diff --git a/server/app.ts b/server/app.ts index 102ac5d6..ca2a5634 100644 --- a/server/app.ts +++ b/server/app.ts @@ -1,6 +1,9 @@ import { release as osRelease } from 'node:os' import { OpenAPIHono } from '@hono/zod-openapi' import { Scalar } from '@scalar/hono-api-reference' +import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth' +import { AGENT_API_KEY_SHORTCUT_SCOPES, AgentApiKeyShortcut } from '@shared/api-key-templates' +import { AuthorizationScope } from '@shared/authorization' import type { Context } from 'hono' import { cors } from 'hono/cors' import type { Auth } from './auth' @@ -175,6 +178,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep const doc = app.getOpenAPIDocument({ openapi: '3.1.0', info: { title: 'ZPan API', version: '0.1.0' }, + servers: [{ url: '/', description: 'Current ZPan origin' }], // Top-level tag order + descriptions; Scalar groups operations by these. tags: [ { name: 'Objects', description: 'Files and folders, including S3 multipart upload sessions' }, @@ -209,15 +213,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep tokenUrl: '/api/auth/oauth2/token', refreshUrl: '/api/auth/oauth2/token', scopes: { - 'objects:read': 'List, inspect, and download objects', - 'objects:create': 'Create folders and upload objects', - 'objects:update': 'Rename, move, and copy objects', - 'objects:delete': 'Soft-delete objects', - 'shares:read': 'List and inspect shares', - 'shares:create': 'Create public shares', - 'shares:delete': 'Revoke shares', - 'quota:read': 'Inspect workspace quota', - 'storage-usage:read': 'Inspect workspace storage usage', + ...agentScopeDescriptions(), }, }, }, @@ -229,45 +225,10 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep ...doc.components.schemas, } Object.assign(doc, { - 'x-cli-config': { - auth: { - reader: { - type: 'oauth-authorization-code', - params: { - authorize_url: '/api/auth/oauth2/authorize', - token_url: '/api/auth/oauth2/token', - client_id: 'zpan-agent', - scopes: 'openid offline_access objects:read shares:read quota:read storage-usage:read', - redirect_path: '/callback', - }, - }, - 'file-manager': { - type: 'oauth-authorization-code', - params: { - authorize_url: '/api/auth/oauth2/authorize', - token_url: '/api/auth/oauth2/token', - client_id: 'zpan-agent', - scopes: - 'openid offline_access objects:read objects:create objects:update objects:delete shares:read quota:read storage-usage:read', - redirect_path: '/callback', - }, - }, - publisher: { - type: 'oauth-authorization-code', - params: { - authorize_url: '/api/auth/oauth2/authorize', - token_url: '/api/auth/oauth2/token', - client_id: 'zpan-agent', - scopes: - 'openid offline_access objects:read shares:read shares:create shares:delete quota:read storage-usage:read', - redirect_path: '/callback', - }, - }, - ci: { - type: 'http-bearer', - params: { token: 'env:ZPAN_AGENT_API_KEY' }, - }, - }, + 'x-cli-config': restishCliConfig(), + 'x-zpan-discovery': { + oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth', + oauthProtectedResource: '/.well-known/oauth-protected-resource/api', }, }) @@ -294,6 +255,15 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep } } + for (const [path, pathItem] of Object.entries(doc.paths)) { + if (!path.startsWith('/api/auth/') || !pathItem || typeof pathItem !== 'object') continue + for (const operation of Object.values(pathItem)) { + if (!operation || typeof operation !== 'object') continue + Object.assign(operation, { 'x-mcp-ignore': true }) + if (path.includes('/callback')) Object.assign(operation, { 'x-cli-ignore': true }) + } + } + return c.json(doc) }) @@ -460,6 +430,69 @@ function getCorsOrigins(platform: Platform): Set { return origins } +function agentScopeDescriptions(): Record { + return { + [AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects', + [AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects', + [AuthorizationScope.OBJECTS_UPDATE]: 'Rename, move, and copy objects', + [AuthorizationScope.OBJECTS_DELETE]: 'Soft-delete objects', + [AuthorizationScope.SHARES_READ]: 'List and inspect shares', + [AuthorizationScope.SHARES_CREATE]: 'Create public shares', + [AuthorizationScope.SHARES_DELETE]: 'Revoke shares', + [AuthorizationScope.QUOTA_READ]: 'Inspect workspace quota', + [AuthorizationScope.STORAGE_USAGE_READ]: 'Inspect workspace storage usage', + } +} + +function restishCliConfig() { + const oauthCredential = (scopes: readonly AuthorizationScope[]) => ({ + auth: { + type: 'oauth-authorization-code', + params: { + authorize_url: '/api/auth/oauth2/authorize', + token_url: '/api/auth/oauth2/token', + client_id: AGENT_OAUTH_CLIENT_ID, + scopes: ['openid', 'offline_access', ...scopes].join(' '), + redirect_path: '/callback', + }, + }, + satisfies: [...scopes], + }) + + return { + profiles: { + default: { + credentials: { + agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.READER]), + }, + }, + reader: { + credentials: { + agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.READER]), + }, + }, + 'file-manager': { + credentials: { + agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.FILE_MANAGER]), + }, + }, + publisher: { + credentials: { + agentOAuth2: oauthCredential(AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.PUBLISHER]), + }, + }, + ci: { + credentials: { + agentApiKey: { + auth: { type: 'bearer', params: { token: 'env:ZPAN_AGENT_API_KEY' } }, + satisfies: [...AGENT_API_KEY_SHORTCUT_SCOPES[AgentApiKeyShortcut.FILE_MANAGER]], + }, + }, + }, + }, + } +} + export type AppType = ReturnType // Sub-router types for RPC clients — avoids combined AppType OOM diff --git a/server/http/openapi.ts b/server/http/openapi.ts index 88fe9a28..2493b988 100644 --- a/server/http/openapi.ts +++ b/server/http/openapi.ts @@ -1,4 +1,5 @@ import { createRoute, type RouteConfig, type z } from '@hono/zod-openapi' +import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates' import { errorResponseSchema } from '@shared/schemas' import { authorize, type RouteAuthorizationDeclaration } from '../middleware/authz' @@ -23,6 +24,8 @@ export const jsonBody = (schema: T) => ({ // `jsonError`; this just documents the response shape in the OpenAPI document. export const errorResponse = (description: string) => jsonContent(errorResponseSchema, description) +const AGENT_GRANTABLE_SCOPE_SET = new Set(AGENT_GRANTABLE_API_KEY_SCOPES) + export function authRoute

& { path: P }>( auth: RouteAuthorizationDeclaration, config: T, @@ -34,6 +37,7 @@ export function authRoute

middleware, security: openApiSecurity(auth), 'x-zpan-auth': openApiAuthMetadata(auth), + ...openApiCliMetadata(auth), } as T) as T & { getRoutingPath(): string } } @@ -58,9 +62,19 @@ function openApiSecurity(auth: RouteAuthorizationDeclaration): Record AGENT_GRANTABLE_SCOPE_SET.has(scope)) + ? [{ agentOAuth2: [...auth.scopes] }, { agentApiKey: [...auth.scopes] }, { cookieAuth: [] }] + : [{ bearerAuth: [...auth.scopes] }, { cookieAuth: [] }] +} + +function openApiCliMetadata(auth: RouteAuthorizationDeclaration): Record { + if (auth.access === 'anyOf') { + return auth.policies.every((policy) => openApiCliMetadata(policy)['x-mcp-ignore']) ? { 'x-mcp-ignore': true } : {} + } + if (auth.access === 'public' || auth.access === 'protected') return {} + if (auth.access === 'internal') return { 'x-cli-ignore': true, 'x-mcp-ignore': true } + return { 'x-mcp-ignore': true } } function openApiAuthMetadata(auth: RouteAuthorizationDeclaration): Record { diff --git a/server/openapi.test.ts b/server/openapi.test.ts index f258334e..a72e524b 100644 --- a/server/openapi.test.ts +++ b/server/openapi.test.ts @@ -57,19 +57,139 @@ describe('global OpenAPI document', () => { const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) const res = await app.request('/api/openapi.json') const doc = (await res.json()) as { - components?: { securitySchemes?: Record } - 'x-cli-config'?: { auth?: Record } + components?: { + securitySchemes?: Record< + string, + { type?: string; scheme?: string; flows?: { authorizationCode?: { scopes?: Record } } } + > + } + 'x-cli-config'?: { + profiles?: Record< + string, + { + credentials?: Record< + string, + { + auth?: { type?: string; params?: Record } + satisfies?: string[] + } + > + } + > + } } expect(doc.components?.securitySchemes?.agentOAuth2).toMatchObject({ type: 'oauth2', - flows: { authorizationCode: { authorizationUrl: '/api/auth/oauth2/authorize' } }, + flows: { + authorizationCode: { + authorizationUrl: '/api/auth/oauth2/authorize', + tokenUrl: '/api/auth/oauth2/token', + refreshUrl: '/api/auth/oauth2/token', + scopes: expect.objectContaining({ + [AuthorizationScope.OBJECTS_READ]: 'List, inspect, and download objects', + [AuthorizationScope.OBJECTS_CREATE]: 'Create folders and upload objects', + [AuthorizationScope.SHARES_CREATE]: 'Create public shares', + }), + }, + }, }) expect(doc.components?.securitySchemes?.agentApiKey).toMatchObject({ type: 'http', scheme: 'bearer' }) - expect(doc['x-cli-config']?.auth?.reader?.params).toMatchObject({ + const profiles = doc['x-cli-config']?.profiles + expect(Object.keys(profiles ?? {})).toEqual(['default', 'reader', 'file-manager', 'publisher', 'ci']) + expect(profiles?.reader?.credentials?.agentOAuth2).toMatchObject({ + auth: { + type: 'oauth-authorization-code', + params: { + authorize_url: '/api/auth/oauth2/authorize', + token_url: '/api/auth/oauth2/token', + client_id: 'zpan-agent', + redirect_path: '/callback', + scopes: 'openid offline_access objects:read shares:read quota:read storage-usage:read', + }, + }, + satisfies: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.SHARES_READ, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], + }) + expect(profiles?.default?.credentials?.agentOAuth2).toEqual(profiles?.reader?.credentials?.agentOAuth2) + expect(profiles?.['file-manager']?.credentials?.agentOAuth2).toMatchObject({ + auth: { + type: 'oauth-authorization-code', + params: { + authorize_url: '/api/auth/oauth2/authorize', + token_url: '/api/auth/oauth2/token', + client_id: 'zpan-agent', + redirect_path: '/callback', + scopes: + 'openid offline_access objects:read objects:create objects:update objects:delete shares:read quota:read storage-usage:read', + }, + }, + satisfies: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.OBJECTS_CREATE, + AuthorizationScope.OBJECTS_UPDATE, + AuthorizationScope.OBJECTS_DELETE, + AuthorizationScope.SHARES_READ, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], + }) + expect(profiles?.publisher?.credentials?.agentOAuth2).toMatchObject({ + auth: { + type: 'oauth-authorization-code', + params: { + authorize_url: '/api/auth/oauth2/authorize', + token_url: '/api/auth/oauth2/token', + client_id: 'zpan-agent', + redirect_path: '/callback', + scopes: + 'openid offline_access objects:read shares:read shares:create shares:delete quota:read storage-usage:read', + }, + }, + satisfies: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.SHARES_READ, + AuthorizationScope.SHARES_CREATE, + AuthorizationScope.SHARES_DELETE, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], + }) + expect(profiles?.default?.credentials?.agentOAuth2?.auth?.params).toMatchObject({ client_id: 'zpan-agent', redirect_path: '/callback', }) + expect(profiles?.ci?.credentials?.agentApiKey).toMatchObject({ + auth: { type: 'bearer', params: { token: 'env:ZPAN_AGENT_API_KEY' } }, + satisfies: [ + AuthorizationScope.OBJECTS_READ, + AuthorizationScope.OBJECTS_CREATE, + AuthorizationScope.OBJECTS_UPDATE, + AuthorizationScope.OBJECTS_DELETE, + AuthorizationScope.SHARES_READ, + AuthorizationScope.QUOTA_READ, + AuthorizationScope.STORAGE_USAGE_READ, + ], + }) + }) + + it('publishes relative servers and discovery links for self-hosted origins', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { + servers?: { url?: string }[] + 'x-zpan-discovery'?: Record + } + + expect(doc.servers).toEqual([{ url: '/', description: 'Current ZPan origin' }]) + expect(doc['x-zpan-discovery']).toEqual({ + oauthAuthorizationServer: '/.well-known/oauth-authorization-server/api/auth', + oauthProtectedResource: '/.well-known/oauth-protected-resource/api', + }) }) it('publishes OAuth discovery and protected-resource metadata at root locations', async () => { @@ -183,6 +303,28 @@ describe('global OpenAPI document', () => { expect(route.middleware).toHaveLength(1) }) + it('emits Agent OAuth and API-key security only for Agent-grantable protected scopes', () => { + const route = authRoute( + { + access: 'protected', + scopes: [AuthorizationScope.OBJECTS_CREATE], + minTeamRole: 'editor', + }, + { + operationId: 'agentGrantableAuthzProbe', + method: 'post', + path: '/probe', + responses: { 200: { description: 'OK' } }, + }, + ) as { security?: unknown } + + expect(route.security).toEqual([ + { agentOAuth2: [AuthorizationScope.OBJECTS_CREATE] }, + { agentApiKey: [AuthorizationScope.OBJECTS_CREATE] }, + { cookieAuth: [] }, + ]) + }) + it('detects OpenAPI operations missing explicit authorization declarations without an allowlist', () => { expect( findOperationsMissingAuthContract({ @@ -208,7 +350,7 @@ describe('global OpenAPI document', () => { const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) const res = await app.request('/api/openapi.json') const doc = (await res.json()) as { - paths: Record> + paths: Record> } const operation = doc.paths['/api/downloads/downloaders']?.post @@ -217,6 +359,174 @@ describe('global OpenAPI document', () => { access: 'anyOf', policies: [{ access: 'admin' }, { access: 'downloader-bootstrap' }], }) + expect(operation?.['x-mcp-ignore']).toBe(true) + }) + + it('marks session, admin, and credential-management operations as ignored by MCP without hiding them from Restish', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { + paths: Record> + } + + const ignoredOperations = [ + doc.paths['/api/workspaces/{orgId}/agent-api-keys']?.get, + doc.paths['/api/workspaces/{orgId}/agent-api-keys']?.post, + doc.paths['/api/agent-oauth-grants']?.get, + doc.paths['/api/agent-oauth-grants/{grantId}']?.delete, + doc.paths['/api/site/storages']?.post, + doc.paths['/api/auth/sign-in/email']?.post, + doc.paths['/api/auth/sign-out']?.post, + ] + + for (const operation of ignoredOperations) { + expect(operation?.['x-mcp-ignore']).toBe(true) + expect(operation?.['x-cli-ignore']).toBeUndefined() + } + expect(doc.paths['/api/auth/callback/{id}']?.get?.['x-mcp-ignore']).toBe(true) + expect(doc.paths['/api/auth/callback/{id}']?.get?.['x-cli-ignore']).toBe(true) + expect(doc.paths['/api/objects']?.get?.['x-mcp-ignore']).toBeUndefined() + expect(Object.keys(doc.paths)).not.toContain('/api/openapi.agent.json') + expect(await app.request('/api/openapi.agent.json')).toMatchObject({ status: 404 }) + }) + + it('publishes stable upload operations for Restish plugin discovery', async () => { + const { app } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' }) + const res = await app.request('/api/openapi.json') + const doc = (await res.json()) as { + paths: Record< + string, + Record< + string, + { + operationId?: string + security?: Record[] + parameters?: { name?: string; in?: string; required?: boolean; schema?: unknown }[] + requestBody?: { + content?: { + 'application/json'?: { schema?: { properties?: Record; required?: string[] } } + } + } + responses?: Record< + string, + { + content?: { + 'application/json'?: { schema?: { $ref?: string; allOf?: unknown[]; properties?: unknown } } + } + } + > + } + > + > + } + + expect(doc.paths['/api/objects']?.post).toMatchObject({ + operationId: 'createObject', + security: [ + { agentOAuth2: [AuthorizationScope.OBJECTS_CREATE] }, + { agentApiKey: [AuthorizationScope.OBJECTS_CREATE] }, + { cookieAuth: [] }, + ], + }) + expect(doc.paths['/api/objects']?.post?.responses?.['201']).toBeDefined() + expect(doc.paths['/api/objects']?.post?.requestBody).toBeDefined() + expect(doc.paths['/api/objects']?.post?.requestBody?.content?.['application/json']?.schema).toMatchObject({ + required: ['name'], + properties: { + name: expect.any(Object), + type: expect.any(Object), + size: expect.any(Object), + parent: expect.any(Object), + onConflict: expect.any(Object), + storageId: expect.any(Object), + }, + }) + expect(doc.paths['/api/objects']?.post?.responses?.['201']?.content?.['application/json']?.schema).toMatchObject({ + allOf: [ + { $ref: '#/components/schemas/Matter' }, + { + type: 'object', + properties: { + upload: { + type: 'object', + required: [ + 'sessionId', + 'uploadId', + 'mode', + 'partSize', + 'partCount', + 'expiresAt', + 'presignedExpiresAt', + 'requiredHeaders', + 'urls', + 'parts', + ], + }, + }, + }, + ], + }) + expect(doc.paths['/api/objects/{id}/uploads/{uploadSessionId}/parts']?.post).toMatchObject({ + operationId: 'presignObjectUploadParts', + parameters: expect.arrayContaining([ + expect.objectContaining({ name: 'id', in: 'path', required: true }), + expect.objectContaining({ name: 'uploadSessionId', in: 'path', required: true }), + ]), + requestBody: { + content: { + 'application/json': { + schema: { required: ['partNumbers'], properties: { partNumbers: expect.any(Object) } }, + }, + }, + }, + responses: { + 200: { + content: { + 'application/json': { + schema: { + required: [ + 'uploadId', + 'mode', + 'partSize', + 'partCount', + 'presignedExpiresAt', + 'requiredHeaders', + 'parts', + ], + properties: { parts: expect.any(Object) }, + }, + }, + }, + }, + }, + }) + expect(doc.paths['/api/objects/{id}/uploads/{uploadSessionId}/completions']?.post).toMatchObject({ + operationId: 'completeObjectUpload', + parameters: expect.arrayContaining([ + expect.objectContaining({ name: 'id', in: 'path', required: true }), + expect.objectContaining({ name: 'uploadSessionId', in: 'path', required: true }), + ]), + requestBody: { + content: { + 'application/json': { + schema: { required: ['parts'], properties: { parts: expect.any(Object) } }, + }, + }, + }, + responses: { 200: { content: { 'application/json': { schema: { $ref: '#/components/schemas/Matter' } } } } }, + }) + expect(doc.paths['/api/objects/{id}/uploads/{uploadSessionId}']?.delete).toMatchObject({ + operationId: 'abortObjectUpload', + parameters: expect.arrayContaining([ + expect.objectContaining({ name: 'id', in: 'path', required: true }), + expect.objectContaining({ name: 'uploadSessionId', in: 'path', required: true }), + expect.objectContaining({ name: 'strictStorageCleanup', in: 'query', required: false }), + ]), + responses: { 204: { description: 'Aborted upload and discarded the draft' } }, + }) + expect( + doc.paths['/api/objects/{id}/uploads/{uploadSessionId}']?.delete?.responses?.['204']?.content, + ).toBeUndefined() }) it('documents owner role requirements for store operations that enforce owner team role', async () => {