feat: app deployment v2 (#41444)

Co-authored-by: zhangx1n <zhangxin@dify.ai>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Wu Tianwei 2026-09-04 02:01:35 +00:00 committed by GitHub
parent 8e2058a962
commit 550196e3b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
256 changed files with 13028 additions and 3709 deletions

View File

@ -423,6 +423,22 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:file-grant-service-boundary]
name = File grant application service is framework and persistence neutral
type = forbidden
source_modules =
services.file_grant_service
services.entities.file_grant_entities
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:account-activation-service-boundary]
name = Account activation application service is framework and persistence neutral
type = forbidden

View File

@ -93,12 +93,10 @@ def handle_collaboration_event(sid, data):
1. mouse_move
2. vars_and_features_update
3. sync_request (ask leader to update graph)
4. app_state_update
5. mcp_server_update
6. workflow_update
7. comments_update
8. node_panel_presence
9. graph_view_state (session reports tab visibility; drives leader election)
4. workflow_update
5. comments_update
6. node_panel_presence
7. graph_view_state (session reports tab visibility; drives leader election)
"""
return collaboration_service.relay_collaboration_event(sid, data)

View File

@ -14,12 +14,13 @@ api = ExternalApi(
files_ns = Namespace("files", description="File operations", path="/")
from . import image_preview, tool_files, upload
from . import appdeploy_files, image_preview, tool_files, upload
api.add_namespace(files_ns)
__all__ = [
"api",
"appdeploy_files",
"bp",
"files_ns",
"image_preview",

View File

@ -0,0 +1,401 @@
"""File endpoints reached with an AppDeploy file grant.
Upload, remote-upload, produce, and resolve authenticate with a Bearer grant;
content authenticates with a per-file token in the query string, because an
``<img src>`` cannot carry a header.
They stay on the public ``files`` blueprint rather than moving under
``inner_api``, whose contract is a fully trusted caller holding the master key.
None of the five meets it: ``content`` is a browser-facing surface, ``upload``
carries an end user's payload, and ``produced`` and ``resolve`` are called by
workers executing third-party plugin code. The one genuinely server-to-server
step, minting the grant, already lives in ``inner_api``.
"""
from __future__ import annotations
from typing import IO
from urllib.parse import quote
from uuid import UUID
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field, HttpUrl, ValidationError
from werkzeug.datastructures import FileStorage
import services
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
RemoteFileUploadError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.files import files_ns
from controllers.files.wraps import FileGrantInvalidError, GrantedFileNotFoundError, file_grant_required
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from fields.file_fields import FileResponse
from fields.file_grant_fields import ResolvedFileResponse
from libs.exception import BaseHTTPException
from libs.helper import dump_response
from services.entities.file_grant_entities import (
FileContent,
FileGrantClaims,
FileGrantContext,
FileGrantScope,
FileKind,
FileRef,
StoredUpload,
)
from services.errors.file_grant import (
EndUserNotFoundError,
InvalidFileGrantError,
RemoteFileUnavailableError,
TooManyFileRefsError,
)
from services.file_grant_service import MAX_FILE_GRANT_REFS
# Everything outside this whitelist is served as an attachment. Produced files
# carry a plugin-declared MIME type, so SVG and the rest of the XML family must
# never render in the viewer's origin.
INLINE_MIME_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
class InvalidFileRequestError(BaseHTTPException):
error_code = "invalid_request"
description = "The file request is malformed."
code = 400
class FileRefPayload(BaseModel):
id: str
kind: FileKind
class RemoteFileUploadPayload(BaseModel):
url: HttpUrl = Field(description="Remote file URL to fetch and store")
class FileResolvePayload(BaseModel):
files: list[FileRefPayload] = Field(default_factory=list, max_length=MAX_FILE_GRANT_REFS)
class FileContentQuery(BaseModel):
token: str = Field(description="Signed content token scoped to this file")
class RemoteFileUploadResponse(FileResponse):
"""Dify's upload shape plus the ``url`` key its remote-upload clients read.
Dify has no service-api remote upload for this endpoint to stand in for, and
the web and console one it does have answers under ``url``. Carrying both
names lets a client of either move over untouched; they hold one URL.
"""
url: str
class ProducedFileResponse(ResponseModel):
id: str
name: str
size: int
mime_type: str | None = None
url: str
internal_url: str
class FileResolveResponse(ResponseModel):
files: list[ResolvedFileResponse]
register_schema_models(files_ns, RemoteFileUploadPayload, FileResolvePayload)
register_response_schema_models(
files_ns, FileResponse, RemoteFileUploadResponse, ProducedFileResponse, FileResolveResponse
)
@files_ns.route("/appdeploy/upload")
class GrantedFileUploadApi(Resource):
"""Store one uploaded file against the grant's end user."""
@file_grant_required(FileGrantScope.UPLOAD)
@files_ns.doc("grant_upload_file")
@files_ns.doc(
responses={
201: "File uploaded",
400: "No file uploaded, the file has no name, or its extension is blocked",
401: "Invalid grant",
403: "Grant lacks the upload scope",
413: "File too large",
415: "Unsupported file type",
}
)
@files_ns.response(201, "File uploaded", files_ns.models[FileResponse.__name__])
def post(self, grant: FileGrantClaims):
upload = _single_upload()
upload_file = _store_upload(
grant,
filename=upload.filename or "",
stream=upload.stream,
mimetype=upload.mimetype,
)
return _granted_file_response(upload_file), 201
@files_ns.route("/appdeploy/remote-upload")
class GrantedRemoteFileUploadApi(Resource):
"""Fetch a remote URL through the SSRF-safe fetcher and store it."""
@file_grant_required(FileGrantScope.UPLOAD)
@files_ns.doc("grant_upload_remote_file")
@files_ns.expect(files_ns.models[RemoteFileUploadPayload.__name__])
@files_ns.doc(
responses={
201: "Remote file uploaded",
400: "Invalid URL, unfetchable remote file, or a blocked extension",
401: "Invalid grant",
403: "Grant lacks the upload scope",
413: "File too large",
415: "Unsupported file type",
}
)
@files_ns.response(201, "Remote file uploaded", files_ns.models[RemoteFileUploadResponse.__name__])
def post(self, grant: FileGrantClaims):
try:
payload = RemoteFileUploadPayload.model_validate(files_ns.payload or {})
except ValidationError as exc:
raise InvalidFileRequestError(str(exc)) from exc
try:
upload_file = application_services().file_grants.store_remote_upload(
context=_grant_context(grant),
url=str(payload.url),
)
except RemoteFileUnavailableError as exc:
raise RemoteFileUploadError(f"Failed to fetch file from {payload.url}") from exc
except EndUserNotFoundError as exc:
raise GrantedFileNotFoundError() from exc
except services.errors.file.FileTooLargeError as exc:
raise FileTooLargeError(exc.description) from exc
except services.errors.file.BlockedFileExtensionError as exc:
raise BlockedFileExtensionError(exc.description) from exc
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
return _remote_granted_file_response(upload_file), 201
@files_ns.route("/appdeploy/produced")
class ProducedFileApi(Resource):
"""Store one file produced by a running workflow node."""
@file_grant_required(FileGrantScope.PRODUCE)
@files_ns.doc("grant_upload_produced_file")
@files_ns.doc(
responses={
201: "Produced file stored",
400: "No file uploaded",
401: "Invalid grant",
403: "Grant lacks the produce scope",
413: "File too large",
}
)
@files_ns.response(201, "Produced file stored", files_ns.models[ProducedFileResponse.__name__])
def post(self, grant: FileGrantClaims):
upload = _single_upload()
try:
tool_file, access = application_services().file_grants.store_produced(
context=_grant_context(grant),
filename=upload.filename,
stream=upload.stream,
mimetype=upload.mimetype,
)
except services.errors.file.FileTooLargeError as exc:
raise FileTooLargeError(exc.description)
except EndUserNotFoundError as exc:
raise GrantedFileNotFoundError() from exc
return ProducedFileResponse(
id=tool_file.id,
name=tool_file.name,
size=tool_file.size,
mime_type=tool_file.mime_type,
url=access.external_url,
internal_url=access.internal_url,
).model_dump(mode="json"), 201
@files_ns.route("/appdeploy/resolve")
class GrantedFileResolveApi(Resource):
"""Re-check ownership and sign fresh URLs at the moment of use."""
@file_grant_required(FileGrantScope.RESOLVE)
@files_ns.doc("grant_resolve_files")
@files_ns.expect(files_ns.models[FileResolvePayload.__name__])
@files_ns.doc(
responses={
200: "Files resolved",
400: "Malformed request",
401: "Invalid grant",
403: "Grant lacks the resolve scope",
}
)
@files_ns.response(200, "Files resolved", files_ns.models[FileResolveResponse.__name__])
def post(self, grant: FileGrantClaims):
try:
payload = FileResolvePayload.model_validate(files_ns.payload or {})
except ValidationError as exc:
raise InvalidFileRequestError(str(exc)) from exc
refs = [FileRef(id=ref.id, kind=ref.kind) for ref in payload.files]
try:
resolved = application_services().file_grants.resolve_file_access(
context=_grant_context(grant),
refs=refs,
)
except EndUserNotFoundError as exc:
raise GrantedFileNotFoundError() from exc
except TooManyFileRefsError as exc:
raise InvalidFileRequestError(str(exc)) from exc
return FileResolveResponse(
files=[ResolvedFileResponse.from_resolved(ref.id, file) for ref, file in zip(refs, resolved, strict=True)]
).model_dump(mode="json")
@files_ns.route("/appdeploy/<uuid:file_id>/content")
class GrantedFileContentApi(Resource):
"""Stream one file's bytes to a holder of its content token."""
@files_ns.doc("grant_file_content")
@files_ns.doc(params=query_params_from_model(FileContentQuery))
@files_ns.doc(
responses={
200: "File stream returned",
401: "Invalid or expired content token",
404: "File not found",
}
)
def get(self, file_id: UUID):
try:
query = FileContentQuery.model_validate(request.args.to_dict(flat=True))
except ValidationError as exc:
raise FileGrantInvalidError() from exc
try:
content = application_services().file_grants.load_content(
token=query.token,
requested_file_id=str(file_id),
)
except InvalidFileGrantError as exc:
raise FileGrantInvalidError() from exc
if content is None:
raise GrantedFileNotFoundError()
return _content_response(content)
def _content_response(content: FileContent) -> Response:
mime_type = _normalized_mime_type(content.mime_type)
inline = mime_type in INLINE_MIME_TYPES
response = Response(
content.stream,
mimetype=mime_type if inline else "application/octet-stream",
direct_passthrough=True,
headers={},
)
response.headers["X-Content-Type-Options"] = "nosniff"
if not inline:
encoded_filename = quote(content.name or "")
response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}"
response.headers["Content-Type"] = "application/octet-stream"
# The sibling preview endpoints advertise `Accept-Ranges` for audio and video.
# Every such type downloads here, so the hint could only ever ride on a
# response no player will seek.
if content.size > 0:
response.headers["Content-Length"] = str(content.size)
return response
def _normalized_mime_type(mime_type: str | None) -> str:
return mime_type.split(";", 1)[0].strip().lower() if mime_type else ""
def _single_upload() -> FileStorage:
if "file" not in request.files:
raise NoFileUploadedError()
if len(request.files) > 1:
raise TooManyFilesError()
upload = request.files["file"]
if not upload.filename:
raise FilenameNotExistsError()
return upload
def _store_upload(
grant: FileGrantClaims,
*,
filename: str,
stream: IO[bytes],
mimetype: str,
) -> StoredUpload:
try:
return application_services().file_grants.store_upload(
context=_grant_context(grant),
filename=filename,
stream=stream,
mimetype=mimetype,
)
except EndUserNotFoundError as exc:
raise GrantedFileNotFoundError() from exc
except services.errors.file.FileTooLargeError as exc:
raise FileTooLargeError(exc.description)
except services.errors.file.BlockedFileExtensionError as exc:
raise BlockedFileExtensionError(exc.description) from exc
except services.errors.file.UnsupportedFileTypeError:
raise UnsupportedFileTypeError()
def _grant_context(grant: FileGrantClaims) -> FileGrantContext:
return FileGrantContext(tenant_id=grant.tenant_id, app_id=grant.app_id, end_user_id=grant.sub)
def _granted_file_response(upload_file: StoredUpload) -> dict[str, object]:
"""Answer an upload exactly as dify's own upload endpoints answer it.
A client moving off ``POST /v1/files/upload`` must not have to read a second
shape, so the same model reads the same ``upload_files`` row: every key dify
leaves null for such a row is null here too. Only the value of ``source_url``
is ours. Dify signs a ``file-preview`` URL there and this channel signs a
content-token URL, which keeps that key's promise of a signed URL that
retrieves the file while keeping the grant its only way in.
"""
signed_url, _ = application_services().file_grants.content_urls(file_id=upload_file.id, kind=FileKind.UPLOAD)
return dump_response(FileResponse, upload_file) | {"source_url": signed_url}
def _remote_granted_file_response(upload_file: StoredUpload) -> dict[str, object]:
"""Answer a remote upload with the upload shape plus dify's ``url`` key.
Reuses the URL already signed for ``source_url`` rather than signing a
second one, so the two keys are one value under the two names dify's two
kinds of client look for.
"""
response = _granted_file_response(upload_file)
return response | {"url": response["source_url"]}
__all__ = [
"GrantedFileContentApi",
"GrantedFileResolveApi",
"GrantedFileUploadApi",
"GrantedRemoteFileUploadApi",
"ProducedFileApi",
]

View File

@ -0,0 +1,57 @@
"""Bearer authentication for the AppDeploy file grant endpoints."""
from collections.abc import Callable
from functools import wraps
from flask import request
from extensions.ext_application_services import application_services
from libs.exception import BaseHTTPException
from services.entities.file_grant_entities import FileGrantClaims, FileGrantScope
class FileGrantInvalidError(BaseHTTPException):
error_code = "grant_invalid"
description = "The file grant is missing, malformed, or expired."
code = 401
class FileGrantScopeDeniedError(BaseHTTPException):
error_code = "grant_scope_denied"
description = "The file grant does not carry the required scope."
code = 403
class GrantedFileNotFoundError(BaseHTTPException):
error_code = "file_not_found"
description = "File not found."
code = 404
def file_grant_required[**P, R](scope: FileGrantScope) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Require a valid file grant carrying ``scope`` and inject its claims."""
def decorator(view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
kwargs["grant"] = _authenticated_claims(scope)
return view(*args, **kwargs)
return decorated
return decorator
def _authenticated_claims(scope: FileGrantScope) -> FileGrantClaims:
scheme, _, token = request.headers.get("Authorization", "").partition(" ")
if scheme.lower() != "bearer" or not token:
raise FileGrantInvalidError()
claims = application_services().file_grants.decode_grant(token)
if claims is None:
raise FileGrantInvalidError()
if scope not in claims.scopes:
raise FileGrantScopeDeniedError()
return claims

View File

@ -21,6 +21,7 @@ from .agent import files as _agent_files
from .agent import llm as _agent_llm
from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .app import file_grants as _app_file_grants
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_config as _agent_config
from .plugin import plugin as _plugin
@ -35,6 +36,7 @@ __all__ = [
"_agent_llm",
"_agent_tools",
"_app_dsl",
"_app_file_grants",
"_knowledge_retrieval",
"_mail",
"_plugin",

View File

@ -0,0 +1,203 @@
"""Mint AppDeploy file grants for the enterprise control plane.
This is the only endpoint that asserts an AppDeploy identity: the application
service upserts the subject's ``EndUser`` row, validates the files the caller
claims to reference, and signs a short-lived grant.
"""
from __future__ import annotations
from flask_restx import Resource
from pydantic import BaseModel, Field, ValidationError
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console.wraps import setup_required
from controllers.files.wraps import GrantedFileNotFoundError
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from fields.file_grant_fields import ResolvedFileResponse
from libs.exception import BaseHTTPException
from services.entities.file_grant_entities import FileGrantMintRequest, FileGrantScope, FileKind, FileRef
from services.file_grant_service import (
AppNotFoundError,
EndUserNotFoundError,
TooManyFileRefsError,
)
from services.file_grant_service import (
GrantedFileNotFoundError as ServiceGrantedFileNotFoundError,
)
from services.file_grant_service import (
GrantTtlTooLongError as ServiceGrantTtlTooLongError,
)
from services.file_grant_service import (
InvalidGrantRequestError as ServiceInvalidGrantRequestError,
)
from services.file_grant_service import (
InvalidSubjectError as ServiceInvalidSubjectError,
)
class InvalidGrantRequestError(BaseHTTPException):
error_code = "invalid_request"
description = "The file grant request is malformed."
code = 400
class GrantTtlTooLongError(BaseHTTPException):
error_code = "grant_ttl_too_long"
description = "The requested file grant lifetime exceeds its allowed window."
code = 400
class InvalidSubjectError(BaseHTTPException):
error_code = "invalid_subject"
description = "The subject is empty or contains a NUL byte."
code = 400
class GrantAppNotFoundError(BaseHTTPException):
error_code = "app_not_found"
description = "App not found."
code = 404
class FileGrantFileRef(BaseModel):
id: str
kind: FileKind
class FileGrantMintPayload(BaseModel):
tenant_id: str
app_id: str
subject: str
is_anonymous: bool = False
scopes: list[FileGrantScope]
ttl_seconds: int = Field(gt=0)
# Recorded by the enterprise caller's own audit log; it never enters the
# grant because a rotated key must not strand the files it uploaded.
actor_key_digest: str | None = None
file_ids: list[FileGrantFileRef] = Field(default_factory=list)
optional_file_ids: list[FileGrantFileRef] = Field(default_factory=list)
run_deadline: int | None = None
class FileGrantLimits(ResponseModel):
file_size_limit: int
image_file_size_limit: int
audio_file_size_limit: int
video_file_size_limit: int
workflow_file_upload_limit: int
batch_count_limit: int
class FileGrantFileMetadata(ResponseModel):
id: str
kind: FileKind
name: str
size: int
extension: str
mime_type: str | None = None
class FileGrantMintResponse(ResponseModel):
grant: str
expires_at: int
limits: FileGrantLimits
files: list[FileGrantFileMetadata]
optional_files: list[ResolvedFileResponse]
register_schema_models(inner_api_ns, FileGrantMintPayload)
register_response_schema_models(inner_api_ns, FileGrantMintResponse)
@inner_api_ns.route("/enterprise/file-grants")
class EnterpriseFileGrantApi(Resource):
"""Assert one AppDeploy subject and sign a grant for it."""
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc("enterprise_mint_file_grant")
@inner_api_ns.expect(inner_api_ns.models[FileGrantMintPayload.__name__])
@inner_api_ns.response(
200,
"File grant minted",
inner_api_ns.models[FileGrantMintResponse.__name__],
)
@inner_api_ns.doc(
responses={
400: "Invalid request, subject, or grant TTL",
404: "App not found, or a required file is not owned by the subject",
}
)
def post(self):
try:
payload = FileGrantMintPayload.model_validate(inner_api_ns.payload or {})
except ValidationError as exc:
raise InvalidGrantRequestError(str(exc)) from exc
try:
result = application_services().file_grants.mint(
FileGrantMintRequest(
tenant_id=payload.tenant_id,
app_id=payload.app_id,
subject=payload.subject,
is_anonymous=payload.is_anonymous,
scopes=tuple(payload.scopes),
ttl_seconds=payload.ttl_seconds,
file_refs=tuple(FileRef(id=ref.id, kind=ref.kind) for ref in payload.file_ids),
optional_file_refs=tuple(FileRef(id=ref.id, kind=ref.kind) for ref in payload.optional_file_ids),
run_deadline=payload.run_deadline,
)
)
except AppNotFoundError as exc:
raise GrantAppNotFoundError() from exc
except ServiceGrantTtlTooLongError as exc:
raise GrantTtlTooLongError() from exc
except ServiceInvalidSubjectError as exc:
raise InvalidSubjectError() from exc
except (ServiceInvalidGrantRequestError, TooManyFileRefsError) as exc:
raise InvalidGrantRequestError(str(exc)) from exc
except (EndUserNotFoundError, ServiceGrantedFileNotFoundError) as exc:
raise GrantedFileNotFoundError() from exc
return FileGrantMintResponse(
grant=result.grant,
expires_at=result.expires_at,
limits=FileGrantLimits(
file_size_limit=result.limits.file_size_limit,
image_file_size_limit=result.limits.image_file_size_limit,
audio_file_size_limit=result.limits.audio_file_size_limit,
video_file_size_limit=result.limits.video_file_size_limit,
workflow_file_upload_limit=result.limits.workflow_file_upload_limit,
batch_count_limit=result.limits.batch_count_limit,
),
files=[
FileGrantFileMetadata(
id=file.id,
kind=file.kind,
name=file.name,
size=file.size,
extension=file.extension,
mime_type=file.mime_type,
)
for file in result.files
],
optional_files=[
ResolvedFileResponse.from_resolved(ref.id, access)
for ref, access in zip(
payload.optional_file_ids,
result.optional_files,
strict=True,
)
],
).model_dump(mode="json")
__all__ = [
"EnterpriseFileGrantApi",
"FileGrantMintPayload",
"FileGrantMintResponse",
]

View File

@ -26,6 +26,7 @@ from libs.passport import PassportService
from libs.token import extract_webapp_passport
from models.model import App, EndUser
from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError
from services.web_passport_service import WebAppAuthType
from services.webapp_access_query_service import (
WebAppAccessAppNotFoundError,
WebAppAccessReferenceRequiredError,
@ -184,12 +185,16 @@ class AppWebAuthPermission(Resource):
if not tk:
raise Unauthorized("Access token is missing.")
decoded = PassportService().verify(tk)
user_id = decoded.get("user_id", "visitor")
except Unauthorized:
raise WebAppAuthRequiredError() from None
except Exception:
logger.exception("Unexpected error during auth verification")
raise
if decoded.get("auth_type") != WebAppAuthType.INTERNAL:
raise WebAppAuthRequiredError()
user_id = decoded.get("user_id")
if not user_id:
raise WebAppAuthRequiredError()
try:
is_allowed = webapp_access.is_user_allowed(user_id=str(user_id), app_id=app_id)

View File

@ -19,6 +19,7 @@ from models.model import App, EndUser, Site
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService, WebAppAccessMode, WebAppSettings
from services.system_feature_service import SystemFeatureService
from services.web_passport_gateways import resolve_web_app_auth_type
from services.webapp_auth_service import WebAppAuthService
@ -133,6 +134,14 @@ def _validate_user_accessibility(
if not webapp_settings:
raise WebAppAuthRequiredError("Web app settings not found.")
auth_type = decoded.get("auth_type")
if not auth_type:
raise WebAppAuthRequiredError("Missing auth_type in the token.")
expected_auth_type = resolve_web_app_auth_type(webapp_settings.access_mode)
if auth_type != expected_auth_type:
raise WebAppAuthRequiredError()
if WebAppAuthService.is_app_require_permission_check(
access_mode=webapp_settings.access_mode, session=db.session()
):
@ -140,10 +149,7 @@ def _validate_user_accessibility(
if not EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp(user_id, app_id):
raise WebAppAuthAccessDeniedError()
auth_type = decoded.get("auth_type")
granted_at = decoded.get("granted_at")
if not auth_type:
raise WebAppAuthAccessDeniedError("Missing auth_type in the token.")
if not granted_at:
raise WebAppAuthAccessDeniedError("Missing granted_at in the token.")
# check if sso has been updated

View File

@ -1,6 +1,7 @@
"""Composition root for application services used by transport adapters."""
import json
import time
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
@ -18,8 +19,10 @@ from constants.languages import languages
from core.db.session_factory import get_session_maker
from core.helper.ssrf_proxy import ssrf_proxy
from core.schemas.schema_manager import SchemaManager
from core.tools.tool_file_manager import ToolFileManager
from enums import DeploymentEdition, WebAppAccessMode
from extensions.ext_redis import RedisClientWrapper, redis_client
from extensions.ext_storage import storage
from libs.datetime_utils import naive_utc_now
from libs.helper import RateLimiter
from libs.oauth import GitHubOAuth, GoogleOAuth
@ -39,6 +42,7 @@ from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourc
from repositories.data_source_oauth_binding_repository import SQLAlchemyDataSourceOAuthBindingRepository
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
from repositories.factory import DifyAPIRepositoryFactory
from repositories.file_grant_repository import FileGrantRepository
from repositories.installation_state_repository import InstallationStateRepository
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
@ -132,10 +136,13 @@ from services.billing_service import BillingService
from services.compliance_download_service import ComplianceDownloadService
from services.data_source_oauth_service import DataSourceOAuthService, InvalidDataSourceOAuthProviderError
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.file_grant_entities import FileGrantLimits
from services.errors.enterprise import EnterpriseServiceError
from services.explore_banner_query_service import ExploreBannerQueryService
from services.feature_query_service import FeatureQueryService
from services.feature_service_gateway import FeatureServiceGateway
from services.file_grant_gateways import FileGrantFileGateway, FileGrantRemoteFileGateway, FileGrantTokenGateway
from services.file_grant_service import FileGrantService
from services.file_service import FileService
from services.init_validation_service import InitValidationService
from services.inner_mail_service import InnerMailService
@ -235,6 +242,7 @@ class ApplicationServices:
schema_definitions: SchemaDefinitionService
setup: SetupService
feature_queries: FeatureQueryService
file_grants: FileGrantService
files: FileService
oauth_server: OAuthServerService
init_validation: InitValidationService
@ -293,6 +301,37 @@ def _build_oauth_server_service(
)
def _build_file_grant_service(*, database_client: sessionmaker[Session]) -> FileGrantService:
repository = FileGrantRepository(session_factory=database_client)
return FileGrantService(
repository=repository,
files=FileGrantFileGateway(
load_end_user=repository.get_end_user,
subject_exists=repository.subject_exists,
file_service=FileService(session_factory=database_client),
tool_files=ToolFileManager(),
storage=storage,
),
remote_files=FileGrantRemoteFileGateway(),
tokens=FileGrantTokenGateway(
secret_key=dify_config.SECRET_KEY,
external_files_url=dify_config.FILES_URL,
internal_files_url=dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL,
content_token_ttl_seconds=dify_config.FILES_ACCESS_TIMEOUT,
now=lambda: int(time.time()),
),
limits=FileGrantLimits(
file_size_limit=dify_config.UPLOAD_FILE_SIZE_LIMIT,
image_file_size_limit=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT,
audio_file_size_limit=dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT,
video_file_size_limit=dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT,
workflow_file_upload_limit=dify_config.WORKFLOW_FILE_UPLOAD_LIMIT,
batch_count_limit=dify_config.UPLOAD_FILE_BATCH_LIMIT,
),
now=lambda: int(time.time()),
)
def _build_account_oauth_service(
*,
database_client: sessionmaker[Session],
@ -574,6 +613,7 @@ def build_application_services(
features=feature_gateway,
app_dsl_version=CURRENT_APP_DSL_VERSION,
),
file_grants=_build_file_grant_service(database_client=database_client),
files=file_service,
oauth_server=_build_oauth_server_service(database_client=database_client, redis=redis),
init_validation=InitValidationService(

View File

@ -0,0 +1,49 @@
"""Response DTOs shared by the AppDeploy file grant surfaces."""
from __future__ import annotations
from fields.base import ResponseModel
from services.entities.file_grant_entities import FileKind, ResolvedFileAccess
class ResolvedFileResponse(ResponseModel):
"""One requested file reference, either resolved or accounted for.
The resolve endpoint and optional mint references answer item by item so one
missing history file cannot fail a whole run. A file that exists but belongs
to another owner is reported exactly like one that never existed.
"""
id: str
ok: bool
kind: FileKind | None = None
name: str | None = None
size: int | None = None
extension: str | None = None
mime_type: str | None = None
url: str | None = None
internal_url: str | None = None
error: str | None = None
@classmethod
def from_resolved(cls, file_id: str, access: ResolvedFileAccess | None) -> ResolvedFileResponse:
"""Describe one resolved reference without performing infrastructure work."""
if access is None:
return cls(id=file_id, ok=False, error="not_found")
file = access.file
return cls(
id=file.id,
ok=True,
kind=file.kind,
name=file.name,
size=file.size,
extension=file.extension,
mime_type=file.mime_type,
url=access.external_url,
internal_url=access.internal_url,
)
__all__ = ["ResolvedFileResponse"]

View File

@ -208,6 +208,7 @@ class InvokeFrom(StrEnum):
class EndUserType(StrEnum):
"""Persisted type values for the ``end_users.type`` column."""
APP_DEPLOY = "app-deploy"
BROWSER = "browser"
MCP = "mcp"
OPENAPI = "openapi"

View File

@ -0,0 +1,213 @@
from __future__ import annotations
import os
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.enums import CreatorUserRole, EndUserType
from models.model import App, EndUser, UploadFile
from models.tools import ToolFile
from services.entities.file_grant_entities import (
FileContentRecord,
FileGrantContext,
FileKind,
FileRef,
ResolvedFile,
)
class FileGrantRepository:
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
def get_or_create_subject(
self,
*,
tenant_id: str,
app_id: str,
session_id: str,
external_user_id: str,
is_anonymous: bool,
) -> str | None:
with self._session_factory() as session:
end_user = session.scalar(
self._subject_statement(
tenant_id=tenant_id,
app_id=app_id,
session_id=session_id,
require_app=True,
).limit(1)
)
if end_user is not None:
return end_user.id
with self._session_factory.begin() as session:
app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).with_for_update())
if app is None:
return None
end_user = session.scalar(
self._subject_statement(
tenant_id=tenant_id,
app_id=app_id,
session_id=session_id,
require_app=False,
).limit(1)
)
if end_user is None:
end_user = EndUser(
tenant_id=tenant_id,
app_id=app_id,
type=EndUserType.APP_DEPLOY,
is_anonymous=is_anonymous,
session_id=session_id,
external_user_id=external_user_id,
)
session.add(end_user)
session.flush()
return end_user.id
def subject_exists(self, context: FileGrantContext) -> bool:
with self._session_factory() as session:
return (
session.scalar(
select(EndUser.id)
.where(
EndUser.id == context.end_user_id,
EndUser.tenant_id == context.tenant_id,
EndUser.app_id == context.app_id,
EndUser.type == EndUserType.APP_DEPLOY,
)
.limit(1)
)
is not None
)
def get_end_user(self, context: FileGrantContext) -> EndUser | None:
with self._session_factory(expire_on_commit=False) as session:
return session.scalar(
select(EndUser)
.where(
EndUser.id == context.end_user_id,
EndUser.tenant_id == context.tenant_id,
EndUser.app_id == context.app_id,
EndUser.type == EndUserType.APP_DEPLOY,
)
.limit(1)
)
def resolve_owned_files(
self,
*,
context: FileGrantContext,
refs: Sequence[FileRef],
) -> list[ResolvedFile | None]:
upload_ids = {ref.id for ref in refs if ref.kind == FileKind.UPLOAD}
tool_ids = {ref.id for ref in refs if ref.kind == FileKind.TOOL}
with self._session_factory() as session:
uploads = self._load_uploads(session, context=context, file_ids=upload_ids)
tool_files = self._load_tool_files(session, context=context, file_ids=tool_ids)
return [uploads.get(ref.id) if ref.kind == FileKind.UPLOAD else tool_files.get(ref.id) for ref in refs]
def get_content_record(self, *, file_id: str, kind: FileKind) -> FileContentRecord | None:
with self._session_factory() as session:
match kind:
case FileKind.UPLOAD:
upload_file = session.scalar(select(UploadFile).where(UploadFile.id == file_id).limit(1))
if upload_file is None:
return None
return FileContentRecord(
name=upload_file.name,
size=upload_file.size,
mime_type=upload_file.mime_type,
storage_key=upload_file.key,
)
case FileKind.TOOL:
tool_file = session.scalar(select(ToolFile).where(ToolFile.id == file_id).limit(1))
if tool_file is None:
return None
return FileContentRecord(
name=tool_file.name or "",
size=tool_file.size,
mime_type=tool_file.mimetype,
storage_key=tool_file.file_key,
)
@staticmethod
def _subject_statement(*, tenant_id: str, app_id: str, session_id: str, require_app: bool):
statement = select(EndUser)
if require_app:
statement = statement.join(App, App.id == EndUser.app_id)
predicates = [
EndUser.tenant_id == tenant_id,
EndUser.app_id == app_id,
EndUser.session_id == session_id,
EndUser.type == EndUserType.APP_DEPLOY,
]
if require_app:
predicates.append(App.tenant_id == tenant_id)
return statement.where(*predicates)
@staticmethod
def _load_uploads(
session: Session,
*,
context: FileGrantContext,
file_ids: set[str],
) -> dict[str, ResolvedFile]:
if not file_ids:
return {}
rows = session.scalars(
select(UploadFile).where(
UploadFile.id.in_(file_ids),
UploadFile.tenant_id == context.tenant_id,
UploadFile.created_by_role == CreatorUserRole.END_USER,
UploadFile.created_by == context.end_user_id,
)
).all()
return {
row.id: ResolvedFile(
id=row.id,
kind=FileKind.UPLOAD,
name=row.name,
size=row.size,
extension=row.extension,
mime_type=row.mime_type,
)
for row in rows
}
@staticmethod
def _load_tool_files(
session: Session,
*,
context: FileGrantContext,
file_ids: set[str],
) -> dict[str, ResolvedFile]:
if not file_ids:
return {}
rows = session.scalars(
select(ToolFile).where(
ToolFile.id.in_(file_ids),
ToolFile.tenant_id == context.tenant_id,
ToolFile.user_id == context.end_user_id,
)
).all()
return {
row.id: ResolvedFile(
id=row.id,
kind=FileKind.TOOL,
name=row.name or "",
size=row.size,
extension=os.path.splitext(row.name or "")[1].lstrip(".").lower(),
mime_type=row.mimetype,
)
for row in rows
}
__all__ = ["FileGrantRepository"]

View File

@ -63,6 +63,11 @@ class EndUserService:
EndUser.tenant_id == tenant_id,
EndUser.app_id == app_id,
EndUser.session_id == user_id,
# An AppDeploy row is never a legacy row this could upgrade:
# the type was added after the split, and FileGrantService
# reads its rows by type. Retyping one here would hide it
# from that read and strand the files it owns.
EndUser.type != EndUserType.APP_DEPLOY,
)
.order_by(
# Prioritize records with matching type (0 = match, 1 = no match)

View File

@ -0,0 +1,139 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel
class FileKind(StrEnum):
UPLOAD = "upload"
TOOL = "tool"
class FileGrantScope(StrEnum):
UPLOAD = "upload"
RESOLVE = "resolve"
PRODUCE = "produce"
class FileGrantClaims(BaseModel):
sub: str
tenant_id: str
app_id: str
scopes: list[FileGrantScope]
exp: int
class FileContentClaims(BaseModel):
kind: FileKind
file_id: str
exp: int
@dataclass(frozen=True, slots=True)
class FileGrantContext:
tenant_id: str
app_id: str
end_user_id: str
@dataclass(frozen=True, slots=True)
class FileRef:
id: str
kind: FileKind
@dataclass(frozen=True, slots=True)
class ResolvedFile:
id: str
kind: FileKind
name: str
size: int
extension: str
mime_type: str | None
@dataclass(frozen=True, slots=True)
class ResolvedFileAccess:
file: ResolvedFile
external_url: str
internal_url: str
@dataclass(frozen=True, slots=True)
class FileContentRecord:
name: str
size: int
mime_type: str | None
storage_key: str
@dataclass(frozen=True, slots=True)
class FileContent:
name: str
size: int
mime_type: str | None
stream: Iterable[bytes]
@dataclass(frozen=True, slots=True)
class StoredUpload:
id: str
name: str
size: int
extension: str | None
mime_type: str | None
created_by: str | None
created_at: datetime | None
tenant_id: str | None
source_url: str
@dataclass(frozen=True, slots=True)
class StoredProducedFile:
id: str
name: str
size: int
mime_type: str | None
@dataclass(frozen=True, slots=True)
class RemoteFile:
filename: str
mimetype: str
content: bytes
@dataclass(frozen=True, slots=True)
class FileGrantLimits:
file_size_limit: int
image_file_size_limit: int
audio_file_size_limit: int
video_file_size_limit: int
workflow_file_upload_limit: int
batch_count_limit: int
@dataclass(frozen=True, slots=True)
class FileGrantMintRequest:
tenant_id: str
app_id: str
subject: str
is_anonymous: bool
scopes: tuple[FileGrantScope, ...]
ttl_seconds: int
file_refs: tuple[FileRef, ...]
optional_file_refs: tuple[FileRef, ...]
run_deadline: int | None
@dataclass(frozen=True, slots=True)
class FileGrantMintResult:
grant: str
expires_at: int
limits: FileGrantLimits
files: tuple[ResolvedFile, ...]
optional_files: tuple[ResolvedFileAccess | None, ...]

View File

@ -0,0 +1,34 @@
class AppNotFoundError(Exception):
pass
class EndUserNotFoundError(Exception):
pass
class InvalidGrantRequestError(Exception):
pass
class InvalidFileGrantError(Exception):
pass
class GrantTtlTooLongError(Exception):
pass
class GrantedFileNotFoundError(Exception):
pass
class InvalidSubjectError(Exception):
pass
class RemoteFileUnavailableError(Exception):
pass
class TooManyFileRefsError(Exception):
pass

View File

@ -0,0 +1,327 @@
from __future__ import annotations
import mimetypes
import os
import re
import urllib.parse
from collections.abc import Callable, Sequence
from typing import IO, cast
from uuid import uuid4
import httpx
import jwt
from pydantic import ValidationError
from core.file import remote_fetcher
from core.helper import ssrf_proxy
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
from extensions.ext_storage import Storage
from models.model import EndUser
from services.entities.file_grant_entities import (
FileContent,
FileContentClaims,
FileContentRecord,
FileGrantClaims,
FileGrantContext,
FileGrantScope,
FileKind,
RemoteFile,
StoredProducedFile,
StoredUpload,
)
from services.errors.file import FileTooLargeError
from services.errors.file_grant import EndUserNotFoundError
from services.file_service import FileService
FILE_GRANT_AUDIENCE = "dify-files"
FILE_CONTENT_AUDIENCE = "dify-files-content"
_ALGORITHM = "HS256"
class FileGrantTokenGateway:
def __init__(
self,
*,
secret_key: str,
external_files_url: str,
internal_files_url: str,
content_token_ttl_seconds: int,
now: Callable[[], int],
) -> None:
self._secret_key = secret_key
self._external_files_url = external_files_url
self._internal_files_url = internal_files_url
self._content_token_ttl_seconds = content_token_ttl_seconds
self._now = now
def issue_grant(
self,
*,
context: FileGrantContext,
scopes: Sequence[FileGrantScope],
ttl_seconds: int,
) -> tuple[str, int]:
expires_at = self._now() + ttl_seconds
token = jwt.encode(
{
"aud": FILE_GRANT_AUDIENCE,
"sub": context.end_user_id,
"tenant_id": context.tenant_id,
"app_id": context.app_id,
"scopes": [str(scope) for scope in scopes],
"exp": expires_at,
},
self._secret_key,
algorithm=_ALGORITHM,
)
return token, expires_at
def decode_grant(self, token: str) -> FileGrantClaims | None:
payload = self._decode(
token, audience=FILE_GRANT_AUDIENCE, required=["exp", "sub", "tenant_id", "app_id", "scopes"]
)
if payload is None:
return None
try:
return FileGrantClaims.model_validate(payload)
except ValidationError:
return None
def issue_content_urls(self, *, file_id: str, kind: FileKind) -> tuple[str, str]:
external_token = self._issue_content_token(file_id=file_id, kind=kind)
internal_token = self._issue_content_token(file_id=file_id, kind=kind)
path = f"/files/appdeploy/{file_id}/content"
return (
f"{self._external_files_url}{path}?token={external_token}",
f"{self._internal_files_url}{path}?token={internal_token}",
)
def decode_content_token(self, token: str) -> FileContentClaims | None:
payload = self._decode(
token,
audience=FILE_CONTENT_AUDIENCE,
required=["exp", "kind", "file_id"],
)
if payload is None:
return None
try:
return FileContentClaims.model_validate(payload)
except ValidationError:
return None
def _issue_content_token(self, *, file_id: str, kind: FileKind) -> str:
return jwt.encode(
{
"aud": FILE_CONTENT_AUDIENCE,
"kind": str(kind),
"file_id": file_id,
"nonce": os.urandom(8).hex(),
"exp": self._now() + self._content_token_ttl_seconds,
},
self._secret_key,
algorithm=_ALGORITHM,
)
def _decode(self, token: str, *, audience: str, required: list[str]) -> dict[str, object] | None:
try:
return cast(
dict[str, object],
jwt.decode(
token,
self._secret_key,
algorithms=[_ALGORITHM],
audience=audience,
options={"require": ["aud", *required]},
),
)
except jwt.PyJWTError:
return None
class FileGrantFileGateway:
def __init__(
self,
*,
load_end_user: Callable[[FileGrantContext], EndUser | None],
subject_exists: Callable[[FileGrantContext], bool],
file_service: FileService,
tool_files: ToolFileManager,
storage: Storage,
) -> None:
self._load_end_user = load_end_user
self._subject_exists = subject_exists
self._file_service = file_service
self._tool_files = tool_files
self._storage = storage
def store_upload_stream(
self,
*,
context: FileGrantContext,
filename: str,
stream: IO[bytes],
mimetype: str,
) -> StoredUpload:
if not self._subject_exists(context):
raise EndUserNotFoundError(context.end_user_id)
extension = os.path.splitext(filename)[1].lstrip(".").lower()
limit = FileService.file_size_limit(extension=extension)
content = stream.read(limit + 1)
if len(content) > limit:
raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.")
return self.store_upload(
context=context,
filename=filename,
content=content,
mimetype=mimetype,
)
def store_upload(
self,
*,
context: FileGrantContext,
filename: str,
content: bytes,
mimetype: str,
source_url: str = "",
) -> StoredUpload:
end_user = self._load_end_user(context)
if end_user is None:
raise EndUserNotFoundError(context.end_user_id)
upload = self._file_service.upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=end_user,
source_url=source_url,
)
return StoredUpload(
id=upload.id,
name=upload.name,
size=upload.size,
extension=upload.extension,
mime_type=upload.mime_type,
created_by=upload.created_by,
created_at=upload.created_at,
tenant_id=upload.tenant_id,
source_url=upload.source_url,
)
def store_produced(
self,
*,
context: FileGrantContext,
filename: str | None,
stream: IO[bytes],
mimetype: str,
) -> StoredProducedFile:
extension = resolve_extension(filename=filename, mimetype=mimetype).lstrip(".").lower()
limit = FileService.file_size_limit(extension=extension)
content = stream.read(limit + 1)
if len(content) > limit:
raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.")
stored = self._tool_files.create_file_by_raw(
user_id=context.end_user_id,
tenant_id=context.tenant_id,
conversation_id=None,
file_binary=content,
mimetype=mimetype,
filename=filename,
)
return StoredProducedFile(
id=stored.id,
name=stored.name or "",
size=stored.size,
mime_type=stored.mimetype,
)
def open_content(self, record: FileContentRecord) -> FileContent:
return FileContent(
name=record.name,
size=record.size,
mime_type=record.mime_type,
stream=self._storage.load(record.storage_key, stream=True),
)
class FileGrantRemoteFileGateway:
def fetch(self, url: str) -> RemoteFile | None:
try:
metadata = remote_fetcher.make_request("HEAD", url=url, follow_redirects=True)
if metadata.status_code != httpx.codes.OK:
metadata.close()
metadata = remote_fetcher.make_request(
"GET",
url=url,
timeout=3,
follow_redirects=True,
stream_response=True,
)
if metadata.status_code != httpx.codes.OK:
metadata.close()
return None
filename, extension, mimetype = self._file_info(metadata)
limit = FileService.file_size_limit(extension=extension)
declared_size = self._declared_size(metadata)
if declared_size is not None and declared_size > limit:
metadata.close()
raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.")
if metadata.request.method == "HEAD":
metadata.close()
response = remote_fetcher.make_request(
"GET",
url=url,
timeout=3,
follow_redirects=True,
stream_response=True,
)
if response.status_code != httpx.codes.OK:
response.close()
return None
else:
response = metadata
try:
buffered = ssrf_proxy.buffer_response(response, max_response_bytes=limit)
except ssrf_proxy.ResponseTooLargeError as exc:
raise FileTooLargeError(f"File size exceeded. The limit is {limit} bytes.") from exc
return RemoteFile(filename=filename, mimetype=mimetype, content=buffered.content)
except (httpx.RequestError, ssrf_proxy.UnsupportedResponseEncodingError):
return None
@staticmethod
def _file_info(response: httpx.Response) -> tuple[str, str, str]:
parsed_url = urllib.parse.urlparse(str(response.url))
filename = urllib.parse.unquote(os.path.basename(parsed_url.path))
if not filename:
content_disposition = response.headers.get("Content-Disposition", "")
filename_match = re.search(r'filename="?([^";]+)', content_disposition)
filename = filename_match.group(1) if filename_match else uuid4().hex
extension = os.path.splitext(filename)[1].lstrip(".").lower()
mimetype = (
mimetypes.guess_type(filename)[0]
or response.headers.get("Content-Type", "").split(";", 1)[0].strip()
or "application/octet-stream"
)
return filename, extension, mimetype
@staticmethod
def _declared_size(response: httpx.Response) -> int | None:
value = response.headers.get("Content-Length")
if value is None:
return None
try:
return int(value)
except ValueError:
return None
__all__ = [
"FILE_CONTENT_AUDIENCE",
"FILE_GRANT_AUDIENCE",
"FileGrantFileGateway",
"FileGrantRemoteFileGateway",
"FileGrantTokenGateway",
]

View File

@ -0,0 +1,314 @@
from __future__ import annotations
import base64
import hashlib
from collections.abc import Callable, Sequence
from typing import IO, Protocol
from services.entities.file_grant_entities import (
FileContent,
FileContentClaims,
FileContentRecord,
FileGrantClaims,
FileGrantContext,
FileGrantLimits,
FileGrantMintRequest,
FileGrantMintResult,
FileGrantScope,
FileKind,
FileRef,
RemoteFile,
ResolvedFile,
ResolvedFileAccess,
StoredProducedFile,
StoredUpload,
)
from services.errors.file_grant import (
AppNotFoundError,
EndUserNotFoundError,
GrantedFileNotFoundError,
GrantTtlTooLongError,
InvalidFileGrantError,
InvalidGrantRequestError,
InvalidSubjectError,
RemoteFileUnavailableError,
TooManyFileRefsError,
)
MAX_SESSION_GRANT_TTL_SECONDS = 7200
MAX_WORKFLOW_EXECUTION_SECONDS = 24 * 60 * 60
RUN_GRANT_EXPIRY_GRACE_SECONDS = 5 * 60
MAX_RUN_GRANT_TTL_SECONDS = MAX_WORKFLOW_EXECUTION_SECONDS + RUN_GRANT_EXPIRY_GRACE_SECONDS
MAX_FILE_GRANT_REFS = 100
class FileGrantRepository(Protocol):
def get_or_create_subject(
self,
*,
tenant_id: str,
app_id: str,
session_id: str,
external_user_id: str,
is_anonymous: bool,
) -> str | None: ...
def subject_exists(self, context: FileGrantContext) -> bool: ...
def resolve_owned_files(
self,
*,
context: FileGrantContext,
refs: Sequence[FileRef],
) -> list[ResolvedFile | None]: ...
def get_content_record(self, *, file_id: str, kind: FileKind) -> FileContentRecord | None: ...
class FileGrantFiles(Protocol):
def store_upload_stream(
self,
*,
context: FileGrantContext,
filename: str,
stream: IO[bytes],
mimetype: str,
) -> StoredUpload: ...
def store_upload(
self,
*,
context: FileGrantContext,
filename: str,
content: bytes,
mimetype: str,
source_url: str = "",
) -> StoredUpload: ...
def store_produced(
self,
*,
context: FileGrantContext,
filename: str | None,
stream: IO[bytes],
mimetype: str,
) -> StoredProducedFile: ...
def open_content(self, record: FileContentRecord) -> FileContent: ...
class FileGrantTokens(Protocol):
def issue_grant(
self,
*,
context: FileGrantContext,
scopes: Sequence[FileGrantScope],
ttl_seconds: int,
) -> tuple[str, int]: ...
def decode_grant(self, token: str) -> FileGrantClaims | None: ...
def issue_content_urls(self, *, file_id: str, kind: FileKind) -> tuple[str, str]: ...
def decode_content_token(self, token: str) -> FileContentClaims | None: ...
class FileGrantRemoteFiles(Protocol):
def fetch(self, url: str) -> RemoteFile | None: ...
class FileGrantService:
def __init__(
self,
*,
repository: FileGrantRepository,
files: FileGrantFiles,
tokens: FileGrantTokens,
remote_files: FileGrantRemoteFiles,
limits: FileGrantLimits,
now: Callable[[], int],
) -> None:
self._repository = repository
self._files = files
self._tokens = tokens
self._remote_files = remote_files
self._limits = limits
self._now = now
@staticmethod
def session_id_for_subject(subject: str) -> str:
digest = hashlib.sha256(subject.encode()).digest()
return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def mint(self, request: FileGrantMintRequest) -> FileGrantMintResult:
self._validate_subject(request.subject)
self._validate_ref_count((*request.file_refs, *request.optional_file_refs))
ttl_seconds = self._effective_ttl_seconds(request)
end_user_id = self._repository.get_or_create_subject(
tenant_id=request.tenant_id,
app_id=request.app_id,
session_id=self.session_id_for_subject(request.subject),
external_user_id=request.subject[:255],
is_anonymous=request.is_anonymous,
)
if end_user_id is None:
raise AppNotFoundError(request.app_id)
context = FileGrantContext(request.tenant_id, request.app_id, end_user_id)
strict_file_count = len(request.file_refs)
resolved_files = self._repository.resolve_owned_files(
context=context,
refs=(*request.file_refs, *request.optional_file_refs),
)
strict_files = resolved_files[:strict_file_count]
if any(file is None for file in strict_files):
raise GrantedFileNotFoundError()
optional_files = resolved_files[strict_file_count:]
grant, expires_at = self._tokens.issue_grant(
context=context,
scopes=request.scopes,
ttl_seconds=ttl_seconds,
)
return FileGrantMintResult(
grant=grant,
expires_at=expires_at,
limits=self._limits,
files=tuple(file for file in strict_files if file is not None),
optional_files=tuple(self._with_access(file) if file is not None else None for file in optional_files),
)
def decode_grant(self, token: str) -> FileGrantClaims | None:
return self._tokens.decode_grant(token)
def store_upload(
self,
*,
context: FileGrantContext,
filename: str,
stream: IO[bytes],
mimetype: str,
) -> StoredUpload:
return self._files.store_upload_stream(
context=context,
filename=filename,
stream=stream,
mimetype=mimetype,
)
def store_remote_upload(self, *, context: FileGrantContext, url: str) -> StoredUpload:
if not self._repository.subject_exists(context):
raise EndUserNotFoundError(context.end_user_id)
remote_file = self._remote_files.fetch(url)
if remote_file is None:
raise RemoteFileUnavailableError(url)
return self._files.store_upload(
context=context,
filename=remote_file.filename,
content=remote_file.content,
mimetype=remote_file.mimetype,
source_url=url,
)
def store_produced(
self,
*,
context: FileGrantContext,
filename: str | None,
stream: IO[bytes],
mimetype: str,
) -> tuple[StoredProducedFile, ResolvedFileAccess]:
if not self._repository.subject_exists(context):
raise EndUserNotFoundError(context.end_user_id)
stored = self._files.store_produced(
context=context,
filename=filename,
stream=stream,
mimetype=mimetype,
)
file = ResolvedFile(stored.id, FileKind.TOOL, stored.name, stored.size, "", stored.mime_type)
return stored, self._with_access(file)
def resolve_files(
self,
*,
context: FileGrantContext,
refs: Sequence[FileRef],
) -> list[ResolvedFile | None]:
self._validate_ref_count(refs)
if not self._repository.subject_exists(context):
raise EndUserNotFoundError(context.end_user_id)
return self._repository.resolve_owned_files(context=context, refs=refs)
def resolve_file_access(
self,
*,
context: FileGrantContext,
refs: Sequence[FileRef],
) -> list[ResolvedFileAccess | None]:
return [
self._with_access(file) if file is not None else None
for file in self.resolve_files(context=context, refs=refs)
]
def load_content(self, *, token: str, requested_file_id: str) -> FileContent | None:
claims = self._tokens.decode_content_token(token)
if claims is None:
raise InvalidFileGrantError()
if claims.file_id != requested_file_id:
return None
record = self._repository.get_content_record(file_id=requested_file_id, kind=claims.kind)
return self._files.open_content(record) if record is not None else None
def content_urls(self, *, file_id: str, kind: FileKind) -> tuple[str, str]:
return self._tokens.issue_content_urls(file_id=file_id, kind=kind)
def _with_access(self, file: ResolvedFile) -> ResolvedFileAccess:
external_url, internal_url = self._tokens.issue_content_urls(file_id=file.id, kind=file.kind)
return ResolvedFileAccess(file=file, external_url=external_url, internal_url=internal_url)
def _effective_ttl_seconds(self, request: FileGrantMintRequest) -> int:
if request.run_deadline is None:
if request.ttl_seconds > MAX_SESSION_GRANT_TTL_SECONDS:
raise GrantTtlTooLongError()
return request.ttl_seconds
now = self._now()
if FileGrantScope.PRODUCE not in request.scopes:
raise InvalidGrantRequestError("A run deadline requires the produce scope.")
if request.run_deadline <= now:
raise InvalidGrantRequestError("The run deadline has expired.")
if request.run_deadline > now + MAX_WORKFLOW_EXECUTION_SECONDS:
raise InvalidGrantRequestError("The run deadline exceeds the workflow execution limit.")
if request.ttl_seconds > MAX_RUN_GRANT_TTL_SECONDS:
raise GrantTtlTooLongError()
return min(request.ttl_seconds, request.run_deadline - now + RUN_GRANT_EXPIRY_GRACE_SECONDS)
@staticmethod
def _validate_subject(subject: str) -> None:
if not subject.strip() or "\x00" in subject:
raise InvalidSubjectError()
@staticmethod
def _validate_ref_count(refs: Sequence[FileRef]) -> None:
if len(refs) > MAX_FILE_GRANT_REFS:
raise TooManyFileRefsError(f"A file grant request may contain at most {MAX_FILE_GRANT_REFS} references.")
__all__ = [
"MAX_FILE_GRANT_REFS",
"MAX_RUN_GRANT_TTL_SECONDS",
"MAX_SESSION_GRANT_TTL_SECONDS",
"MAX_WORKFLOW_EXECUTION_SECONDS",
"RUN_GRANT_EXPIRY_GRACE_SECONDS",
"AppNotFoundError",
"EndUserNotFoundError",
"FileGrantService",
"GrantTtlTooLongError",
"GrantedFileNotFoundError",
"InvalidFileGrantError",
"InvalidGrantRequestError",
"InvalidSubjectError",
"RemoteFileUnavailableError",
"TooManyFileRefsError",
]

View File

@ -131,21 +131,32 @@ class FileService:
file_size: int,
default_file_size_limit: int | None = None,
) -> bool:
return file_size <= FileService.file_size_limit(
extension=extension,
default_file_size_limit=default_file_size_limit,
)
@staticmethod
def file_size_limit(
*,
extension: str,
default_file_size_limit: int | None = None,
) -> int:
"""Return the size an extension is allowed, in bytes."""
if extension in IMAGE_EXTENSIONS:
file_size_limit = dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024
file_size_limit = dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT
elif extension in VIDEO_EXTENSIONS:
file_size_limit = dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT * 1024 * 1024
file_size_limit = dify_config.UPLOAD_VIDEO_FILE_SIZE_LIMIT
elif extension in AUDIO_EXTENSIONS:
file_size_limit = dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT * 1024 * 1024
file_size_limit = dify_config.UPLOAD_AUDIO_FILE_SIZE_LIMIT
else:
# Context-specific uploads may override the default limit without changing media-specific limits.
file_size_limit = (
(default_file_size_limit if default_file_size_limit is not None else dify_config.UPLOAD_FILE_SIZE_LIMIT)
* 1024
* 1024
default_file_size_limit if default_file_size_limit is not None else dify_config.UPLOAD_FILE_SIZE_LIMIT
)
return file_size <= file_size_limit
return file_size_limit * 1024 * 1024
def get_file_base64(self, file_id: str) -> str:
with self._session_maker(expire_on_commit=False) as session:

View File

@ -10,6 +10,16 @@ from services.enterprise.enterprise_service import PERMISSION_CHECK_MODES, WebAp
from services.web_passport_service import WebAppAuthType, WebPassportUnauthorizedError
def resolve_web_app_auth_type(access_mode: str) -> WebAppAuthType:
if access_mode == WebAppAccessMode.PUBLIC:
return WebAppAuthType.PUBLIC
if access_mode in PERMISSION_CHECK_MODES:
return WebAppAuthType.INTERNAL
if access_mode == WebAppAccessMode.SSO_VERIFIED:
return WebAppAuthType.EXTERNAL
raise ValueError(f"Unsupported web app access mode: {access_mode}")
class DeploymentWebPassportAuthGateway:
def __init__(
self,
@ -25,13 +35,7 @@ class DeploymentWebPassportAuthGateway:
def get_app_auth_type(self, app_id: str) -> WebAppAuthType:
access_mode = self._get_app_access_mode(app_id).access_mode
if access_mode == WebAppAccessMode.PUBLIC:
return WebAppAuthType.PUBLIC
if access_mode in PERMISSION_CHECK_MODES:
return WebAppAuthType.INTERNAL
if access_mode == WebAppAccessMode.SSO_VERIFIED:
return WebAppAuthType.EXTERNAL
raise ValueError(f"Unsupported web app access mode: {access_mode}")
return resolve_web_app_auth_type(access_mode)
class PassportTokenGateway:

View File

@ -91,8 +91,8 @@ class TestValidateUserAccessibility:
def test_missing_auth_type_raises(self) -> None:
decoded = {"user_id": "u1", "granted_at": 1}
settings = SimpleNamespace(access_mode="public")
with pytest.raises(WebAppAuthAccessDeniedError, match="auth_type"):
settings = SimpleNamespace(access_mode="private")
with pytest.raises(WebAppAuthRequiredError, match="auth_type"):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
@ -103,7 +103,7 @@ class TestValidateUserAccessibility:
def test_missing_granted_at_raises(self) -> None:
decoded = {"user_id": "u1", "auth_type": "external"}
settings = SimpleNamespace(access_mode="public")
settings = SimpleNamespace(access_mode="sso_verified")
with pytest.raises(WebAppAuthAccessDeniedError, match="granted_at"):
_validate_user_accessibility(
decoded=decoded,
@ -121,7 +121,7 @@ class TestValidateUserAccessibility:
mock_sso_time.return_value = datetime.now(UTC)
old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp())
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": old_granted}
settings = SimpleNamespace(access_mode="public")
settings = SimpleNamespace(access_mode="sso_verified")
with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"):
_validate_user_accessibility(
decoded=decoded,
@ -139,7 +139,7 @@ class TestValidateUserAccessibility:
mock_workspace_sso.return_value = datetime.now(UTC)
old_granted = int((datetime.now(UTC) - timedelta(hours=1)).timestamp())
decoded = {"user_id": "u1", "auth_type": "internal", "granted_at": old_granted}
settings = SimpleNamespace(access_mode="public")
settings = SimpleNamespace(access_mode="private")
with pytest.raises(WebAppAuthAccessDeniedError, match="SSO settings"):
_validate_user_accessibility(
decoded=decoded,
@ -157,7 +157,7 @@ class TestValidateUserAccessibility:
mock_sso_time.return_value = datetime.now(UTC) - timedelta(hours=2)
recent_granted = int(datetime.now(UTC).timestamp())
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": recent_granted}
settings = SimpleNamespace(access_mode="public")
settings = SimpleNamespace(access_mode="sso_verified")
_validate_user_accessibility(
decoded=decoded,
app_code="code",
@ -172,8 +172,8 @@ class TestValidateUserAccessibility:
def test_permission_check_denies_unauthorized_user(
self, mock_perm: MagicMock, mock_app_id: MagicMock, mock_allowed: MagicMock
) -> None:
decoded = {"user_id": "u1", "auth_type": "external", "granted_at": int(datetime.now(UTC).timestamp())}
settings = SimpleNamespace(access_mode="internal")
decoded = {"user_id": "u1", "auth_type": "internal", "granted_at": int(datetime.now(UTC).timestamp())}
settings = SimpleNamespace(access_mode="private")
with pytest.raises(WebAppAuthAccessDeniedError):
_validate_user_accessibility(
decoded=decoded,
@ -183,6 +183,37 @@ class TestValidateUserAccessibility:
webapp_settings=settings,
)
@pytest.mark.parametrize(
("access_mode", "auth_type"),
[
pytest.param("private", "external", id="private-rejects-external"),
pytest.param("private_all", "external", id="private-all-rejects-external"),
pytest.param("sso_verified", "internal", id="sso-verified-rejects-internal"),
],
)
@patch("controllers.web.wraps.EnterpriseService.WebAppAuth.is_user_allowed_to_access_webapp")
@patch("controllers.web.wraps.WebAppAuthService.is_app_require_permission_check")
def test_auth_type_must_match_current_access_mode(
self,
mock_permission_check: MagicMock,
mock_allowed: MagicMock,
access_mode: str,
auth_type: str,
) -> None:
decoded = {"user_id": "u1", "auth_type": auth_type, "granted_at": int(datetime.now(UTC).timestamp())}
with pytest.raises(WebAppAuthRequiredError):
_validate_user_accessibility(
decoded=decoded,
app_code="code",
app_web_auth_enabled=True,
system_webapp_auth_enabled=True,
webapp_settings=SimpleNamespace(access_mode=access_mode),
)
mock_permission_check.assert_not_called()
mock_allowed.assert_not_called()
class TestDecodeJwtToken:
@pytest.fixture

View File

@ -0,0 +1,833 @@
"""Tests for the file endpoints reached with an AppDeploy file grant."""
import time
from collections.abc import Callable, Iterator
from datetime import datetime
from io import BytesIO
from types import SimpleNamespace
from typing import IO, cast
from unittest.mock import MagicMock, patch
from uuid import UUID
import jwt
import pytest
from flask import Flask
from sqlalchemy import update
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
)
from controllers.files.appdeploy_files import (
GrantedFileContentApi,
GrantedFileResolveApi,
GrantedFileUploadApi,
GrantedRemoteFileUploadApi,
InvalidFileRequestError,
ProducedFileApi,
)
from controllers.files.wraps import FileGrantInvalidError, GrantedFileNotFoundError
from extensions.ext_application_services import _build_file_grant_service
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole, EndUserType
from models.model import EndUser, UploadFile
from models.tools import ToolFile
from services.entities.file_grant_entities import FileGrantContext, FileGrantScope, FileKind, RemoteFile
from services.errors.file import FileTooLargeError as FileTooLargeServiceError
from services.file_grant_gateways import FILE_CONTENT_AUDIENCE, FileGrantFileGateway
from services.file_grant_service import MAX_FILE_GRANT_REFS, FileGrantService
from tests.unit_tests.file_grant_test_utils import issue_file_grant
CONTROLLER_MODULE = "controllers.files.appdeploy_files"
SECRET_KEY = "file-grant-test-secret-long-enough-for-hs256"
TENANT_ID = "11111111-1111-4111-8111-111111111111"
OTHER_TENANT_ID = "1a1a1a1a-1111-4111-8111-111111111111"
APP_ID = "22222222-2222-4222-8222-222222222222"
FILE_ID = UUID("66666666-6666-4666-8666-666666666666")
UPLOAD_FILE_ID = "77777777-7777-4777-8777-777777777777"
UPLOADED_AT = datetime(2026, 8, 20, 12, 0)
# What ``POST /v1/files/upload`` answers with. The grant channel is a drop-in
# for it, so the whole key set travels together and a key dify leaves null must
# still be present and null here.
DIFY_UPLOAD_RESPONSE_KEYS = frozenset(
{
"id",
"reference",
"name",
"size",
"extension",
"mime_type",
"created_by",
"created_at",
"preview_url",
"source_url",
"original_url",
"user_id",
"tenant_id",
"conversation_id",
"file_key",
}
)
# The subset dify's own web upload client reads, from
# ``web/app/components/base/file-uploader/utils.ts``.
WEB_CLIENT_KEYS = frozenset(
{
"id",
"name",
"size",
"extension",
"mime_type",
"created_by",
"created_at",
"preview_url",
"source_url",
}
)
@pytest.fixture(autouse=True)
def granted_config(config_overrides: Callable[..., None]) -> None:
config_overrides(
SECRET_KEY=SECRET_KEY,
FILES_URL="https://files.example.com",
INTERNAL_FILES_URL="http://dify-api.dify.svc:5001",
FILES_ACCESS_TIMEOUT=300,
)
@pytest.fixture
def sqlite_db(sqlite_engine: Engine) -> Iterator[FileGrantService]:
service = _build_file_grant_service(database_client=sessionmaker(bind=sqlite_engine, expire_on_commit=False))
services = SimpleNamespace(file_grants=service)
with (
patch(f"{CONTROLLER_MODULE}.application_services", return_value=services),
patch("controllers.files.wraps.application_services", return_value=services),
):
yield service
@pytest.fixture
def file_gateway(sqlite_db: FileGrantService) -> FileGrantFileGateway:
return cast(FileGrantFileGateway, sqlite_db._files)
@pytest.fixture
def end_user(sqlite_session: Session) -> EndUser:
record = EndUser(
tenant_id=TENANT_ID,
app_id=APP_ID,
type=EndUserType.APP_DEPLOY,
is_anonymous=True,
session_id="seeded",
external_user_id="adp1.seeded",
)
sqlite_session.add(record)
sqlite_session.commit()
return record
def _bearer(*scopes: FileGrantScope, end_user_id: str, tenant_id: str = TENANT_ID) -> dict[str, str]:
token, _ = issue_file_grant(
end_user_id=end_user_id,
tenant_id=tenant_id,
app_id=APP_ID,
scopes=scopes,
ttl_seconds=600,
)
return {"Authorization": f"Bearer {token}"}
def _content_token(*, file_id: str, kind: FileKind, expires_in: int = 300) -> str:
return jwt.encode(
{
"aud": FILE_CONTENT_AUDIENCE,
"kind": str(kind),
"file_id": file_id,
"nonce": "0011223344556677",
"exp": int(time.time()) + expires_in,
},
SECRET_KEY,
algorithm="HS256",
)
def _stub_upload_file(**overrides: object) -> SimpleNamespace:
"""Stand in for the ``upload_files`` row ``FileService`` hands back."""
return SimpleNamespace(
**{
"id": UPLOAD_FILE_ID,
"name": "report.pdf",
"size": 2048,
"extension": "pdf",
"mime_type": "application/pdf",
"tenant_id": TENANT_ID,
"created_by": "99999999-9999-4999-8999-999999999999",
"created_at": UPLOADED_AT,
"source_url": "",
**overrides,
}
)
def _persist_upload_file(session: Session, *, owner_id: str, tenant_id: str = TENANT_ID) -> UploadFile:
upload_file = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.OPENDAL,
key="upload_files/report.pdf",
name="report.pdf",
size=2048,
extension="pdf",
mime_type="application/pdf",
created_by=owner_id,
created_by_role=CreatorUserRole.END_USER,
created_at=naive_utc_now(),
used=False,
)
session.add(upload_file)
session.commit()
return upload_file
def _persist_tool_file(session: Session, *, owner_id: str, mimetype: str = "image/png") -> ToolFile:
tool_file = ToolFile(
user_id=owner_id,
tenant_id=TENANT_ID,
conversation_id=None,
file_key="tools/chart.png",
mimetype=mimetype,
name="chart.png",
size=64,
)
session.add(tool_file)
session.commit()
return tool_file
def test_upload_stores_the_file_for_the_grant_end_user(
app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway
) -> None:
with (
patch.object(file_gateway, "store_upload", wraps=file_gateway.store_upload) as store_upload,
patch.object(file_gateway._file_service, "upload_file", return_value=_stub_upload_file()),
):
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")},
content_type="multipart/form-data",
):
body, status = GrantedFileUploadApi().post()
assert status == 201
assert body["id"] == UPLOAD_FILE_ID
assert body["extension"] == "pdf"
assert store_upload.call_args.kwargs["context"].end_user_id == end_user.id
def test_upload_answers_in_dify_s_own_upload_shape(
app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway
) -> None:
"""A client moving off ``POST /v1/files/upload`` must not meet a second shape."""
with patch.object(
file_gateway._file_service,
"upload_file",
return_value=_stub_upload_file(created_by=end_user.id),
):
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")},
content_type="multipart/form-data",
):
body, _ = GrantedFileUploadApi().post()
source_url = body.pop("source_url")
assert body == {
"id": UPLOAD_FILE_ID,
"reference": None,
"name": "report.pdf",
"size": 2048,
"extension": "pdf",
"mime_type": "application/pdf",
"created_by": end_user.id,
"created_at": int(UPLOADED_AT.timestamp()),
"preview_url": None,
"original_url": None,
"user_id": None,
"tenant_id": TENANT_ID,
"conversation_id": None,
"file_key": None,
}
assert source_url.startswith(f"https://files.example.com/files/appdeploy/{UPLOAD_FILE_ID}/content?token=")
def test_upload_carries_every_key_dify_s_web_client_reads(
app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway
) -> None:
with patch.object(file_gateway._file_service, "upload_file", return_value=_stub_upload_file()):
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")},
content_type="multipart/form-data",
):
body, _ = GrantedFileUploadApi().post()
assert set(body) >= WEB_CLIENT_KEYS
@pytest.mark.usefixtures("sqlite_db")
def test_upload_rejects_a_grant_from_another_tenant(app: Flask, end_user: EndUser) -> None:
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id, tenant_id=OTHER_TENANT_ID),
data={"file": (BytesIO(b"pdf-bytes"), "report.pdf")},
content_type="multipart/form-data",
):
with pytest.raises(GrantedFileNotFoundError):
GrantedFileUploadApi().post()
@pytest.mark.usefixtures("sqlite_db")
def test_upload_applies_the_shared_per_extension_size_limit(
app: Flask, end_user: EndUser, config_overrides: Callable[..., None]
) -> None:
config_overrides(UPLOAD_FILE_SIZE_LIMIT=1)
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"file": (BytesIO(b"0" * (1024 * 1024 + 1)), "big.bin")},
content_type="multipart/form-data",
):
with pytest.raises(FileTooLargeError):
GrantedFileUploadApi().post()
@pytest.mark.usefixtures("sqlite_db")
def test_upload_rejects_a_blacklisted_extension(
app: Flask, end_user: EndUser, config_overrides: Callable[..., None]
) -> None:
config_overrides(inner_UPLOAD_FILE_EXTENSION_BLACKLIST="exe")
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"file": (BytesIO(b"MZ"), "payload.exe")},
content_type="multipart/form-data",
):
with pytest.raises(BlockedFileExtensionError):
GrantedFileUploadApi().post()
@pytest.mark.usefixtures("sqlite_db")
def test_upload_rejects_a_request_carrying_no_file(app: Flask, end_user: EndUser) -> None:
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"note": "no file here"},
content_type="multipart/form-data",
):
with pytest.raises(NoFileUploadedError):
GrantedFileUploadApi().post()
@pytest.mark.usefixtures("sqlite_db")
def test_upload_rejects_a_batch_of_files(app: Flask, end_user: EndUser) -> None:
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={
"file": (BytesIO(b"first"), "first.pdf"),
"second": (BytesIO(b"second"), "second.pdf"),
},
content_type="multipart/form-data",
):
with pytest.raises(TooManyFilesError):
GrantedFileUploadApi().post()
@pytest.mark.usefixtures("sqlite_db")
def test_upload_rejects_a_file_without_a_name(app: Flask, end_user: EndUser) -> None:
with app.test_request_context(
"/files/appdeploy/upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
data={"file": (BytesIO(b"nameless"), "")},
content_type="multipart/form-data",
):
with pytest.raises(FilenameNotExistsError):
GrantedFileUploadApi().post()
def test_remote_upload_fetches_through_the_ssrf_safe_fetcher(
app: Flask, end_user: EndUser, sqlite_db: FileGrantService, file_gateway: FileGrantFileGateway
) -> None:
url = "https://example.com/docs/report.pdf"
with (
patch.object(
sqlite_db._remote_files,
"fetch",
return_value=RemoteFile(filename="report.pdf", mimetype="application/pdf", content=b"pdf-bytes"),
) as fetch,
patch.object(file_gateway._file_service, "upload_file", return_value=_stub_upload_file()) as upload_file,
):
with app.test_request_context(
"/files/appdeploy/remote-upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
json={"url": url},
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = {"url": url}
body, status = GrantedRemoteFileUploadApi().post()
assert status == 201
assert body["id"] == UPLOAD_FILE_ID
fetch.assert_called_once_with(url)
kwargs = upload_file.call_args.kwargs
assert kwargs["source_url"] == url
assert kwargs["content"] == b"pdf-bytes"
assert kwargs["user"].id == end_user.id
def test_remote_upload_answers_in_the_upload_shape_plus_dify_s_url_key(
app: Flask, end_user: EndUser, sqlite_db: FileGrantService, file_gateway: FileGrantFileGateway
) -> None:
"""Dify's own remote upload answers under ``url``, so this one answers under both."""
url = "https://example.com/docs/report.pdf"
with (
patch.object(
sqlite_db._remote_files,
"fetch",
return_value=RemoteFile(filename="report.pdf", mimetype="application/pdf", content=b"pdf-bytes"),
),
patch.object(
file_gateway._file_service,
"upload_file",
return_value=_stub_upload_file(source_url=url),
),
):
with app.test_request_context(
"/files/appdeploy/remote-upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
json={"url": url},
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = {"url": url}
body, _ = GrantedRemoteFileUploadApi().post()
assert set(body) == DIFY_UPLOAD_RESPONSE_KEYS | {"url"}
# The row records where the bytes came from; the response hands back the
# signed URL that fetches them, exactly as dify's own remote upload does.
assert body["source_url"].startswith(f"https://files.example.com/files/appdeploy/{UPLOAD_FILE_ID}/content?token=")
# One URL under both names, not two signings of the same file.
assert body["url"] == body["source_url"]
def test_remote_upload_honours_the_size_precheck(app: Flask, end_user: EndUser, sqlite_db: FileGrantService) -> None:
url = "https://example.com/docs/huge.pdf"
with patch.object(
sqlite_db._remote_files,
"fetch",
side_effect=FileTooLargeServiceError("too large"),
):
with app.test_request_context(
"/files/appdeploy/remote-upload",
method="POST",
headers=_bearer(FileGrantScope.UPLOAD, end_user_id=end_user.id),
json={"url": url},
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = {"url": url}
with pytest.raises(FileTooLargeError):
GrantedRemoteFileUploadApi().post()
def test_produced_stores_a_tool_file_and_returns_both_urls(
app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway
) -> None:
tool_file = SimpleNamespace(
id="88888888-8888-4888-8888-888888888888",
name="chart.png",
size=64,
mimetype="image/png",
)
with patch.object(file_gateway._tool_files, "create_file_by_raw", return_value=tool_file) as create_file:
with app.test_request_context(
"/files/appdeploy/produced",
method="POST",
headers=_bearer(FileGrantScope.PRODUCE, end_user_id=end_user.id),
data={"file": (BytesIO(b"0" * 16), "chart.png")},
content_type="multipart/form-data",
):
body, status = ProducedFileApi().post()
assert status == 201
kwargs = create_file.call_args.kwargs
assert kwargs["user_id"] == end_user.id
assert kwargs["tenant_id"] == TENANT_ID
assert kwargs["conversation_id"] is None
assert body["url"].startswith(f"https://files.example.com/files/appdeploy/{tool_file.id}/content?token=")
assert body["internal_url"].startswith(
f"http://dify-api.dify.svc:5001/files/appdeploy/{tool_file.id}/content?token="
)
def test_produced_rejects_a_grant_whose_subject_was_deleted(app: Flask, file_gateway: FileGrantFileGateway) -> None:
with patch.object(file_gateway._tool_files, "create_file_by_raw") as create_file:
with app.test_request_context(
"/files/appdeploy/produced",
method="POST",
headers=_bearer(
FileGrantScope.PRODUCE,
end_user_id="99999999-9999-4999-8999-999999999999",
),
data={"file": (BytesIO(b"content"), "chart.png")},
content_type="multipart/form-data",
):
with pytest.raises(GrantedFileNotFoundError):
ProducedFileApi().post()
create_file.assert_not_called()
@pytest.fixture
def one_megabyte_image_limit(config_overrides: Callable[..., None]) -> int:
"""Hold images to one mebibyte while every other kind stays far above it."""
config_overrides(
UPLOAD_FILE_SIZE_LIMIT=64,
UPLOAD_IMAGE_FILE_SIZE_LIMIT=1,
UPLOAD_VIDEO_FILE_SIZE_LIMIT=64,
UPLOAD_AUDIO_FILE_SIZE_LIMIT=64,
)
return 1024 * 1024
def test_produced_accepts_a_file_of_exactly_the_per_extension_limit(
app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway, one_megabyte_image_limit: int
) -> None:
with patch.object(
file_gateway._tool_files,
"create_file_by_raw",
return_value=SimpleNamespace(
id="88888888-8888-4888-8888-888888888888",
name="chart.png",
size=one_megabyte_image_limit,
mimetype="image/png",
),
) as create_file:
with app.test_request_context(
"/files/appdeploy/produced",
method="POST",
headers=_bearer(FileGrantScope.PRODUCE, end_user_id=end_user.id),
data={"file": (BytesIO(b"0" * one_megabyte_image_limit), "chart.png")},
content_type="multipart/form-data",
):
_, status = ProducedFileApi().post()
assert status == 201
assert len(create_file.call_args.kwargs["file_binary"]) == one_megabyte_image_limit
def test_produced_rejects_a_file_one_byte_over_the_per_extension_limit(
app: Flask, end_user: EndUser, file_gateway: FileGrantFileGateway, one_megabyte_image_limit: int
) -> None:
"""``create_file_by_raw`` has no limit of its own, so nothing else would stop this."""
with patch.object(file_gateway._tool_files, "create_file_by_raw") as create_file:
with app.test_request_context(
"/files/appdeploy/produced",
method="POST",
headers=_bearer(FileGrantScope.PRODUCE, end_user_id=end_user.id),
data={"file": (BytesIO(b"0" * (one_megabyte_image_limit + 1)), "chart.png")},
content_type="multipart/form-data",
):
with pytest.raises(FileTooLargeError) as raised:
ProducedFileApi().post()
assert raised.value.code == 413
create_file.assert_not_called()
class _CountingStream:
"""A body that reports how much of itself a reader actually pulled."""
def __init__(self, size: int) -> None:
self._remaining = size
self.bytes_read = 0
def read(self, size: int = -1) -> bytes:
served = self._remaining if size < 0 else min(size, self._remaining)
self._remaining -= served
self.bytes_read += served
return b"0" * served
def test_produced_stops_reading_an_oversized_body_at_the_per_extension_limit(
end_user: EndUser,
sqlite_db: FileGrantService,
file_gateway: FileGrantFileGateway,
one_megabyte_image_limit: int,
) -> None:
"""The caller is a worker running plugin code with no proxy body limit in front of it."""
stream = _CountingStream(one_megabyte_image_limit * 64)
with patch.object(file_gateway._tool_files, "create_file_by_raw") as create_file:
with pytest.raises(FileTooLargeServiceError):
sqlite_db.store_produced(
context=FileGrantContext(TENANT_ID, APP_ID, end_user.id),
filename="chart.png",
stream=cast(IO[bytes], stream),
mimetype="image/png",
)
assert stream.bytes_read == one_megabyte_image_limit + 1
create_file.assert_not_called()
@pytest.mark.usefixtures("sqlite_db")
def test_resolve_signs_urls_per_item_and_hides_foreign_files(
app: Flask, end_user: EndUser, sqlite_session: Session
) -> None:
owned = _persist_upload_file(sqlite_session, owner_id=end_user.id)
foreign = _persist_upload_file(sqlite_session, owner_id="00000000-0000-4000-8000-000000000000")
payload = {
"files": [
{"id": owned.id, "kind": "upload"},
{"id": foreign.id, "kind": "upload"},
]
}
with app.test_request_context(
"/files/appdeploy/resolve",
method="POST",
headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id),
json=payload,
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = payload
body = GrantedFileResolveApi().post()
resolved, hidden = body["files"]
assert resolved["ok"] is True
assert resolved["kind"] == "upload"
assert resolved["extension"] == "pdf"
assert resolved["url"].startswith(f"https://files.example.com/files/appdeploy/{owned.id}/content?token=")
assert resolved["internal_url"].startswith(
f"http://dify-api.dify.svc:5001/files/appdeploy/{owned.id}/content?token="
)
assert hidden == {
"id": foreign.id,
"ok": False,
"kind": None,
"name": None,
"size": None,
"extension": None,
"mime_type": None,
"url": None,
"internal_url": None,
"error": "not_found",
}
@pytest.mark.usefixtures("sqlite_db")
def test_resolve_answers_a_mixed_batch_item_by_item(app: Flask, end_user: EndUser, sqlite_session: Session) -> None:
owned_upload = _persist_upload_file(sqlite_session, owner_id=end_user.id)
owned_tool = _persist_tool_file(sqlite_session, owner_id=end_user.id)
missing_id = "44444444-4444-4444-8444-444444444444"
payload = {
"files": [
{"id": owned_upload.id, "kind": "upload"},
{"id": missing_id, "kind": "upload"},
# A real file, but looked up in the wrong table.
{"id": owned_upload.id, "kind": "tool"},
{"id": owned_tool.id, "kind": "tool"},
]
}
with app.test_request_context(
"/files/appdeploy/resolve",
method="POST",
headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id),
json=payload,
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = payload
body = GrantedFileResolveApi().post()
assert [(file["id"], file["ok"], file["kind"], file["error"]) for file in body["files"]] == [
(owned_upload.id, True, "upload", None),
(missing_id, False, None, "not_found"),
(owned_upload.id, False, None, "not_found"),
(owned_tool.id, True, "tool", None),
]
@pytest.mark.usefixtures("sqlite_db")
def test_resolve_returns_an_empty_batch_unchanged(app: Flask, end_user: EndUser) -> None:
payload: dict[str, object] = {"files": []}
with app.test_request_context(
"/files/appdeploy/resolve",
method="POST",
headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id),
json=payload,
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = payload
assert GrantedFileResolveApi().post() == {"files": []}
def test_resolve_rejects_an_unbounded_batch(app: Flask, end_user: EndUser, sqlite_db: FileGrantService) -> None:
del sqlite_db
payload = {
"files": [
{"id": f"00000000-0000-4000-8000-{index:012d}", "kind": "upload"}
for index in range(MAX_FILE_GRANT_REFS + 1)
]
}
with app.test_request_context(
"/files/appdeploy/resolve",
method="POST",
headers=_bearer(FileGrantScope.RESOLVE, end_user_id=end_user.id),
json=payload,
):
with patch(f"{CONTROLLER_MODULE}.files_ns") as files_ns:
files_ns.payload = payload
with pytest.raises(InvalidFileRequestError):
GrantedFileResolveApi().post()
@pytest.fixture
def stored_bytes(file_gateway: FileGrantFileGateway) -> Iterator[MagicMock]:
with patch.object(file_gateway._storage, "load", return_value=iter([b"file-bytes"])) as load:
yield load
@pytest.mark.usefixtures("sqlite_db", "stored_bytes")
@pytest.mark.parametrize(
("mime_type", "expected_content_type", "expects_attachment"),
[
("image/png", "image/png", False),
("image/jpeg", "image/jpeg", False),
("image/gif", "image/gif", False),
("image/webp", "image/webp", False),
# Case and parameters are normalized before the whitelist is consulted.
("IMAGE/PNG; charset=binary", "image/png", False),
("application/pdf", "application/octet-stream", True),
("image/svg+xml", "application/octet-stream", True),
("text/html", "application/octet-stream", True),
("application/xhtml+xml", "application/octet-stream", True),
("text/javascript", "application/octet-stream", True),
("audio/mpeg", "application/octet-stream", True),
("video/mp4", "application/octet-stream", True),
],
)
def test_content_disposition_follows_the_inline_whitelist(
app: Flask,
sqlite_session: Session,
mime_type: str,
expected_content_type: str,
expects_attachment: bool,
) -> None:
tool_file = _persist_tool_file(sqlite_session, owner_id="anyone", mimetype=mime_type)
token = _content_token(file_id=tool_file.id, kind=FileKind.TOOL)
with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}):
response = GrantedFileContentApi().get(UUID(tool_file.id))
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["Content-Type"].startswith(expected_content_type)
assert ("Content-Disposition" in response.headers) is expects_attachment
if expects_attachment:
assert response.headers["Content-Disposition"] == "attachment; filename*=UTF-8''chart.png"
# Range is never honoured here, so the hint must not be advertised either.
assert "Accept-Ranges" not in response.headers
assert response.headers["Content-Length"] == "64"
@pytest.mark.usefixtures("sqlite_db", "stored_bytes")
def test_content_downloads_a_file_with_no_recorded_mime_type(app: Flask, sqlite_session: Session) -> None:
upload_file = _persist_upload_file(sqlite_session, owner_id="anyone")
sqlite_session.execute(update(UploadFile).where(UploadFile.id == upload_file.id).values(mime_type=None))
sqlite_session.commit()
token = _content_token(file_id=upload_file.id, kind=FileKind.UPLOAD)
with app.test_request_context(f"/files/appdeploy/{upload_file.id}/content", query_string={"token": token}):
response = GrantedFileContentApi().get(UUID(upload_file.id))
assert response.headers["X-Content-Type-Options"] == "nosniff"
assert response.headers["Content-Type"].startswith("application/octet-stream")
assert response.headers["Content-Disposition"] == "attachment; filename*=UTF-8''report.pdf"
@pytest.mark.usefixtures("sqlite_db", "stored_bytes")
def test_content_rejects_an_expired_token(app: Flask, sqlite_session: Session) -> None:
tool_file = _persist_tool_file(sqlite_session, owner_id="anyone")
token = _content_token(file_id=tool_file.id, kind=FileKind.TOOL, expires_in=-1)
with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}):
with pytest.raises(FileGrantInvalidError):
GrantedFileContentApi().get(UUID(tool_file.id))
@pytest.mark.usefixtures("sqlite_db", "stored_bytes")
def test_content_rejects_a_token_minted_for_another_file(app: Flask, sqlite_session: Session) -> None:
tool_file = _persist_tool_file(sqlite_session, owner_id="anyone")
token = _content_token(file_id=str(FILE_ID), kind=FileKind.TOOL)
with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}):
with pytest.raises(GrantedFileNotFoundError):
GrantedFileContentApi().get(UUID(tool_file.id))
@pytest.mark.usefixtures("sqlite_db", "stored_bytes")
def test_content_rejects_a_token_naming_the_wrong_table(app: Flask, sqlite_session: Session) -> None:
tool_file = _persist_tool_file(sqlite_session, owner_id="anyone")
token = _content_token(file_id=tool_file.id, kind=FileKind.UPLOAD)
with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": token}):
with pytest.raises(GrantedFileNotFoundError):
GrantedFileContentApi().get(UUID(tool_file.id))
@pytest.mark.usefixtures("sqlite_db", "stored_bytes")
def test_content_rejects_a_file_grant_replayed_as_a_content_token(app: Flask, sqlite_session: Session) -> None:
tool_file = _persist_tool_file(sqlite_session, owner_id="anyone")
grant, _ = issue_file_grant(
end_user_id="anyone",
tenant_id=TENANT_ID,
app_id=APP_ID,
scopes=[FileGrantScope.RESOLVE],
ttl_seconds=600,
)
with app.test_request_context(f"/files/appdeploy/{tool_file.id}/content", query_string={"token": grant}):
with pytest.raises(FileGrantInvalidError):
GrantedFileContentApi().get(UUID(tool_file.id))

View File

@ -0,0 +1,151 @@
"""Tests for the Bearer file-grant decorator."""
import time
from collections.abc import Callable
from types import SimpleNamespace
from typing import cast
import jwt
import pytest
from flask import Flask
from controllers.files.wraps import FileGrantInvalidError, FileGrantScopeDeniedError, file_grant_required
from libs.passport import PassportService
from services.entities.file_grant_entities import FileGrantClaims, FileGrantScope
from services.file_grant_gateways import FILE_GRANT_AUDIENCE
from tests.unit_tests.file_grant_test_utils import issue_file_grant, token_gateway
SECRET_KEY = "file-grant-test-secret-long-enough-for-hs256"
TENANT_ID = "11111111-1111-4111-8111-111111111111"
APP_ID = "22222222-2222-4222-8222-222222222222"
END_USER_ID = "55555555-5555-4555-8555-555555555555"
@pytest.fixture(autouse=True)
def granted_config(config_overrides: Callable[..., None]) -> None:
config_overrides(SECRET_KEY=SECRET_KEY)
@pytest.fixture(autouse=True)
def file_grant_service(granted_config: None, monkeypatch: pytest.MonkeyPatch) -> None:
del granted_config
service = SimpleNamespace(decode_grant=token_gateway().decode_grant)
monkeypatch.setattr(
"controllers.files.wraps.application_services",
lambda: SimpleNamespace(file_grants=service),
)
@file_grant_required(FileGrantScope.UPLOAD)
def _view(grant: FileGrantClaims) -> FileGrantClaims:
return grant
def _call(app: Flask, authorization: str | None) -> FileGrantClaims:
headers: dict[str, str] = {"Authorization": authorization} if authorization is not None else {}
with app.test_request_context("/", method="POST", headers=headers):
return cast(Callable[[], FileGrantClaims], _view)()
def _grant(*scopes: FileGrantScope, ttl_seconds: int = 600) -> str:
token, _ = issue_file_grant(
end_user_id=END_USER_ID,
tenant_id=TENANT_ID,
app_id=APP_ID,
scopes=scopes,
ttl_seconds=ttl_seconds,
)
return token
def test_valid_grant_is_injected_into_the_view(app: Flask) -> None:
claims = _call(app, f"Bearer {_grant(FileGrantScope.UPLOAD, FileGrantScope.RESOLVE)}")
assert claims.sub == END_USER_ID
assert claims.tenant_id == TENANT_ID
assert claims.app_id == APP_ID
assert claims.scopes == [FileGrantScope.UPLOAD, FileGrantScope.RESOLVE]
def test_missing_authorization_is_rejected(app: Flask) -> None:
with pytest.raises(FileGrantInvalidError):
_call(app, None)
def test_non_bearer_authorization_is_rejected(app: Flask) -> None:
with pytest.raises(FileGrantInvalidError):
_call(app, f"Basic {_grant(FileGrantScope.UPLOAD)}")
def test_webapp_passport_cannot_be_replayed_as_a_grant(app: Flask) -> None:
"""The passport is signed with the same key, so only ``aud`` separates them."""
passport = PassportService().issue(
{
"iss": "SELF_HOSTED",
"sub": "Web API Passport",
"app_id": APP_ID,
"end_user_id": END_USER_ID,
"exp": int(time.time()) + 600,
}
)
with pytest.raises(FileGrantInvalidError):
_call(app, f"Bearer {passport}")
def test_content_token_audience_is_not_accepted_as_a_grant(app: Flask) -> None:
content_token = jwt.encode(
{
"aud": "dify-files-content",
"kind": "upload",
"file_id": "66666666-6666-4666-8666-666666666666",
"exp": int(time.time()) + 600,
},
SECRET_KEY,
algorithm="HS256",
)
with pytest.raises(FileGrantInvalidError):
_call(app, f"Bearer {content_token}")
def test_expired_grant_is_rejected(app: Flask) -> None:
expired = jwt.encode(
{
"aud": FILE_GRANT_AUDIENCE,
"sub": END_USER_ID,
"tenant_id": TENANT_ID,
"app_id": APP_ID,
"scopes": ["upload"],
"exp": int(time.time()) - 1,
},
SECRET_KEY,
algorithm="HS256",
)
with pytest.raises(FileGrantInvalidError):
_call(app, f"Bearer {expired}")
def test_grant_signed_with_another_key_is_rejected(app: Flask) -> None:
forged = jwt.encode(
{
"aud": FILE_GRANT_AUDIENCE,
"sub": END_USER_ID,
"tenant_id": TENANT_ID,
"app_id": APP_ID,
"scopes": ["upload"],
"exp": int(time.time()) + 600,
},
"some-other-secret-long-enough-for-hs256-signing",
algorithm="HS256",
)
with pytest.raises(FileGrantInvalidError):
_call(app, f"Bearer {forged}")
def test_grant_without_the_required_scope_is_denied(app: Flask) -> None:
with pytest.raises(FileGrantScopeDeniedError):
_call(app, f"Bearer {_grant(FileGrantScope.RESOLVE, FileGrantScope.PRODUCE)}")

View File

@ -0,0 +1,493 @@
"""Tests for the AppDeploy file grant minting endpoint."""
import inspect
import os
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
import jwt
import pytest
from flask import Flask
from sqlalchemy import select
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
from controllers.files.wraps import GrantedFileNotFoundError
from controllers.inner_api.app.file_grants import (
EnterpriseFileGrantApi,
GrantAppNotFoundError,
GrantTtlTooLongError,
InvalidGrantRequestError,
InvalidSubjectError,
)
from extensions.ext_application_services import _build_file_grant_service
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole, EndUserType
from models.model import App, EndUser, UploadFile
from models.tools import ToolFile
from services import end_user_service
from services.end_user_service import EndUserService
from services.file_grant_gateways import FILE_GRANT_AUDIENCE
from services.file_grant_service import (
MAX_RUN_GRANT_TTL_SECONDS,
MAX_SESSION_GRANT_TTL_SECONDS,
MAX_WORKFLOW_EXECUTION_SECONDS,
RUN_GRANT_EXPIRY_GRACE_SECONDS,
FileGrantService,
)
CONTROLLER_MODULE = "controllers.inner_api.app.file_grants"
SECRET_KEY = "file-grant-test-secret-long-enough-for-hs256"
TENANT_ID = "11111111-1111-4111-8111-111111111111"
APP_ID = "22222222-2222-4222-8222-222222222222"
SUBJECT = "adp1.dGVzdC1zdWJqZWN0"
@pytest.fixture
def granted_config(config_overrides: Callable[..., None]) -> None:
config_overrides(
SECRET_KEY=SECRET_KEY,
FILES_URL="https://files.example.com",
INTERNAL_FILES_URL="http://dify-api.dify.svc:5001",
FILES_ACCESS_TIMEOUT=300,
)
@pytest.fixture
def seeded_app(sqlite_session: Session) -> App:
app_model = App(
id=APP_ID,
tenant_id=TENANT_ID,
name="deployed app",
mode="workflow",
enable_site=True,
enable_api=True,
)
sqlite_session.add(app_model)
sqlite_session.commit()
return app_model
def _mint(app: Flask, payload: dict[str, object]) -> dict[str, object]:
handler = EnterpriseFileGrantApi()
with app.test_request_context("/", method="POST", json=payload):
with patch(f"{CONTROLLER_MODULE}.inner_api_ns") as mock_ns:
mock_ns.payload = payload
return inspect.unwrap(handler.post)(handler)
def _subject_of(response: dict[str, object]) -> str:
grant = response["grant"]
assert isinstance(grant, str)
return str(jwt.decode(grant, SECRET_KEY, algorithms=["HS256"], audience=FILE_GRANT_AUDIENCE)["sub"])
def _payload(**overrides: object) -> dict[str, object]:
return {
"tenant_id": TENANT_ID,
"app_id": APP_ID,
"subject": SUBJECT,
"is_anonymous": True,
"scopes": ["upload"],
"ttl_seconds": 600,
} | overrides
def _persist_upload_file(session: Session, *, owner_id: str, tenant_id: str = TENANT_ID) -> UploadFile:
upload_file = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.OPENDAL,
key="upload_files/report.pdf",
name="report.pdf",
size=2048,
extension="pdf",
mime_type="application/pdf",
created_by=owner_id,
created_by_role=CreatorUserRole.END_USER,
created_at=naive_utc_now(),
used=False,
)
session.add(upload_file)
session.commit()
return upload_file
def _persist_tool_file(session: Session, *, owner_id: str, tenant_id: str = TENANT_ID) -> ToolFile:
tool_file = ToolFile(
user_id=owner_id,
tenant_id=tenant_id,
conversation_id=None,
file_key="tools/chart.png",
mimetype="image/png",
name="chart.png",
size=64,
)
session.add(tool_file)
session.commit()
return tool_file
@pytest.fixture
def sqlite_db(sqlite_engine: Engine, granted_config: None) -> Iterator[None]:
del granted_config
service = _build_file_grant_service(database_client=sessionmaker(bind=sqlite_engine, expire_on_commit=False))
services = SimpleNamespace(file_grants=service)
with patch(f"{CONTROLLER_MODULE}.application_services", return_value=services):
yield
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_creates_exactly_one_end_user_and_reuses_it(app: Flask, sqlite_session: Session) -> None:
first = _mint(app, _payload())
second = _mint(app, _payload(ttl_seconds=900))
end_users = list(sqlite_session.scalars(select(EndUser).where(EndUser.tenant_id == TENANT_ID)).all())
assert len(end_users) == 1
assert end_users[0].type == EndUserType.APP_DEPLOY
assert end_users[0].session_id == FileGrantService.session_id_for_subject(SUBJECT)
assert end_users[0].external_user_id == SUBJECT
first_grant = first["grant"]
second_grant = second["grant"]
assert isinstance(first_grant, str)
assert isinstance(second_grant, str)
first_claims = jwt.decode(first_grant, SECRET_KEY, algorithms=["HS256"], audience=FILE_GRANT_AUDIENCE)
second_claims = jwt.decode(second_grant, SECRET_KEY, algorithms=["HS256"], audience=FILE_GRANT_AUDIENCE)
assert first_claims["sub"] == second_claims["sub"] == end_users[0].id
assert first_claims["tenant_id"] == TENANT_ID
assert first_claims["app_id"] == APP_ID
assert first_claims["scopes"] == ["upload"]
first_expires_at = first["expires_at"]
second_expires_at = second["expires_at"]
assert isinstance(first_expires_at, int)
assert isinstance(second_expires_at, int)
assert second_expires_at > first_expires_at
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_returns_dify_upload_limits(app: Flask, config_overrides: Callable[..., None]) -> None:
config_overrides(
UPLOAD_FILE_SIZE_LIMIT=15,
UPLOAD_IMAGE_FILE_SIZE_LIMIT=10,
UPLOAD_AUDIO_FILE_SIZE_LIMIT=50,
UPLOAD_VIDEO_FILE_SIZE_LIMIT=100,
WORKFLOW_FILE_UPLOAD_LIMIT=10,
UPLOAD_FILE_BATCH_LIMIT=5,
)
response = _mint(app, _payload())
assert response["limits"] == {
"file_size_limit": 15,
"image_file_size_limit": 10,
"audio_file_size_limit": 50,
"video_file_size_limit": 100,
"workflow_file_upload_limit": 10,
"batch_count_limit": 5,
}
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_rejects_ttl_over_the_cap_before_touching_identity(app: Flask, sqlite_session: Session) -> None:
with pytest.raises(GrantTtlTooLongError):
_mint(app, _payload(ttl_seconds=MAX_SESSION_GRANT_TTL_SECONDS + 1))
assert sqlite_session.scalars(select(EndUser)).all() == []
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_accepts_a_session_ttl_exactly_at_the_cap(app: Flask) -> None:
before = int(time.time())
response = _mint(app, _payload(ttl_seconds=MAX_SESSION_GRANT_TTL_SECONDS))
expires_at = response["expires_at"]
assert isinstance(expires_at, int)
assert MAX_SESSION_GRANT_TTL_SECONDS <= expires_at - before <= MAX_SESSION_GRANT_TTL_SECONDS + 5
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_accepts_a_run_ttl_until_deadline_plus_grace(app: Flask) -> None:
now = int(time.time())
run_duration = MAX_RUN_GRANT_TTL_SECONDS - RUN_GRANT_EXPIRY_GRACE_SECONDS
response = _mint(
app,
_payload(
scopes=["resolve", "produce"],
ttl_seconds=MAX_RUN_GRANT_TTL_SECONDS,
run_deadline=now + run_duration,
),
)
expires_at = response["expires_at"]
assert isinstance(expires_at, int)
assert MAX_RUN_GRANT_TTL_SECONDS <= expires_at - now <= MAX_RUN_GRANT_TTL_SECONDS + 5
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_rejects_a_run_ttl_past_deadline_grace(app: Flask) -> None:
now = int(time.time())
with pytest.raises(GrantTtlTooLongError):
_mint(
app,
_payload(
scopes=["resolve", "produce"],
ttl_seconds=MAX_RUN_GRANT_TTL_SECONDS + 1,
run_deadline=now + MAX_WORKFLOW_EXECUTION_SECONDS,
),
)
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_rejects_an_expired_run_deadline(app: Flask) -> None:
with pytest.raises(InvalidGrantRequestError):
_mint(
app,
_payload(scopes=["resolve", "produce"], ttl_seconds=1, run_deadline=int(time.time()) - 1),
)
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_rejects_a_run_deadline_without_produce_scope(app: Flask) -> None:
with pytest.raises(InvalidGrantRequestError):
_mint(app, _payload(run_deadline=int(time.time()) + 60))
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_rejects_a_run_deadline_beyond_the_workflow_limit(app: Flask) -> None:
with pytest.raises(InvalidGrantRequestError):
_mint(
app,
_payload(
scopes=["resolve", "produce"],
run_deadline=int(time.time()) + MAX_WORKFLOW_EXECUTION_SECONDS + 1,
),
)
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_caps_a_run_grant_at_deadline_plus_grace(app: Flask) -> None:
now = int(time.time())
run_deadline = now + 60
response = _mint(
app,
_payload(
scopes=["resolve", "produce"],
ttl_seconds=1200,
run_deadline=run_deadline,
),
)
expires_at = response["expires_at"]
assert isinstance(expires_at, int)
assert (
run_deadline + RUN_GRANT_EXPIRY_GRACE_SECONDS
<= expires_at
<= (run_deadline + RUN_GRANT_EXPIRY_GRACE_SECONDS + 1)
)
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
@pytest.mark.parametrize("ttl_seconds", [0, -1])
def test_mint_rejects_a_non_positive_ttl(app: Flask, ttl_seconds: int) -> None:
with pytest.raises(InvalidGrantRequestError):
_mint(app, _payload(ttl_seconds=ttl_seconds))
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
@pytest.mark.parametrize("subject", ["", " ", "\t\n", "adp1.with\x00nul", "\x00"])
def test_mint_rejects_an_unusable_subject(app: Flask, subject: str, sqlite_session: Session) -> None:
"""A NUL would reach ``external_user_id`` verbatim and blow up on PostgreSQL."""
with pytest.raises(InvalidSubjectError):
_mint(app, _payload(subject=subject))
assert sqlite_session.scalars(select(EndUser)).all() == []
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_folds_an_oversized_subject_into_one_identity(app: Flask, sqlite_session: Session) -> None:
"""``external_user_id`` truncates at 255, so ``session_id`` is what keeps them apart."""
long_subject = "adp1." + "s" * 4000
sibling = long_subject[:-1] + "t"
first = _mint(app, _payload(subject=long_subject))
again = _mint(app, _payload(subject=long_subject))
other = _mint(app, _payload(subject=sibling))
end_users = sqlite_session.scalars(select(EndUser).order_by(EndUser.created_at)).all()
assert len(end_users) == 2
assert all(len(end_user.external_user_id) == 255 for end_user in end_users)
assert _subject_of(first) == _subject_of(again) != _subject_of(other)
@pytest.mark.usefixtures("granted_config", "sqlite_db")
def test_mint_rejects_unknown_app(app: Flask) -> None:
with pytest.raises(GrantAppNotFoundError):
_mint(app, _payload())
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_returns_strict_metadata_without_urls(app: Flask, sqlite_session: Session) -> None:
owner_id = _subject_of(_mint(app, _payload()))
upload_file = _persist_upload_file(sqlite_session, owner_id=owner_id)
tool_file = _persist_tool_file(sqlite_session, owner_id=owner_id)
response = _mint(
app,
_payload(file_ids=[{"id": upload_file.id, "kind": "upload"}, {"id": tool_file.id, "kind": "tool"}]),
)
assert response["files"] == [
{
"id": upload_file.id,
"kind": "upload",
"name": "report.pdf",
"size": 2048,
"extension": "pdf",
"mime_type": "application/pdf",
},
{
"id": tool_file.id,
"kind": "tool",
"name": "chart.png",
"size": 64,
"extension": "png",
"mime_type": "image/png",
},
]
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_fails_the_whole_strict_batch_on_one_miss(app: Flask, sqlite_session: Session) -> None:
owner_id = _subject_of(_mint(app, _payload()))
upload_file = _persist_upload_file(sqlite_session, owner_id=owner_id)
with pytest.raises(GrantedFileNotFoundError):
_mint(
app,
_payload(
file_ids=[
{"id": upload_file.id, "kind": "upload"},
{"id": "33333333-3333-4333-8333-333333333333", "kind": "upload"},
]
),
)
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_hides_files_owned_by_another_subject(app: Flask, sqlite_session: Session) -> None:
other_owner = _subject_of(_mint(app, _payload(subject="adp1.other")))
foreign_file = _persist_upload_file(sqlite_session, owner_id=other_owner)
with pytest.raises(GrantedFileNotFoundError):
_mint(app, _payload(file_ids=[{"id": foreign_file.id, "kind": "upload"}]))
@pytest.mark.usefixtures("granted_config", "seeded_app", "sqlite_db")
def test_mint_reports_optional_files_item_by_item(app: Flask, sqlite_session: Session) -> None:
owner_id = _subject_of(_mint(app, _payload()))
upload_file = _persist_upload_file(sqlite_session, owner_id=owner_id)
missing_id = "44444444-4444-4444-8444-444444444444"
response = _mint(
app,
_payload(
optional_file_ids=[
{"id": upload_file.id, "kind": "upload"},
{"id": missing_id, "kind": "tool"},
]
),
)
optional_files = response["optional_files"]
assert isinstance(optional_files, list)
present, absent = optional_files
assert present["ok"] is True
assert present["name"] == "report.pdf"
assert present["url"].startswith(f"https://files.example.com/files/appdeploy/{upload_file.id}/content?token=")
assert present["internal_url"].startswith(
f"http://dify-api.dify.svc:5001/files/appdeploy/{upload_file.id}/content?token="
)
assert absent == {
"id": missing_id,
"ok": False,
"kind": None,
"name": None,
"size": None,
"extension": None,
"mime_type": None,
"url": None,
"internal_url": None,
"error": "not_found",
}
assert response["files"] == []
@pytest.mark.usefixtures("seeded_app")
def test_end_user_service_never_retypes_an_app_deploy_row(
sqlite_engine: Engine,
sqlite_session: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Retyping would hide the row from the grant read and strand its files."""
subject = "subject-that-also-reaches-the-service-api"
session_id = FileGrantService.session_id_for_subject(subject)
owner = EndUser(
tenant_id=TENANT_ID,
app_id=APP_ID,
type=EndUserType.APP_DEPLOY,
is_anonymous=True,
session_id=session_id,
external_user_id=session_id,
)
sqlite_session.add(owner)
sqlite_session.commit()
owner_id = owner.id
monkeypatch.setattr(end_user_service, "db", SimpleNamespace(engine=sqlite_engine))
EndUserService.get_or_create_end_user_by_type(EndUserType.SERVICE_API, TENANT_ID, APP_ID, session_id)
sqlite_session.expire_all()
persisted_owner = sqlite_session.get(EndUser, owner_id)
assert persisted_owner is not None
assert persisted_owner.type == EndUserType.APP_DEPLOY
SKIPPED_DIRECTORY_NAMES = frozenset({".git", ".venv", "__pycache__", "migrations", "node_modules", "tests"})
def test_app_deploy_end_users_have_exactly_one_writer() -> None:
"""``end_users`` has no unique constraint, so a second writer would fork identities.
``end_user_service`` names the type only to exclude it from the legacy retype;
the behavioural guard above is what holds that exclusion in place.
"""
api_root = Path(__file__).resolve().parents[5]
assert api_root.name == "api"
referencing_modules: set[str] = set()
for directory, subdirectories, filenames in os.walk(api_root):
subdirectories[:] = [name for name in subdirectories if name not in SKIPPED_DIRECTORY_NAMES]
for filename in filenames:
if not filename.endswith(".py"):
continue
path = Path(directory) / filename
if "EndUserType.APP_DEPLOY" in path.read_text(encoding="utf-8"):
referencing_modules.add(path.relative_to(api_root).as_posix())
assert referencing_modules == {
"repositories/file_grant_repository.py",
"services/end_user_service.py",
}

View File

@ -216,18 +216,17 @@ class TestAppWebAuthPermission:
extract_passport.assert_not_called()
@pytest.mark.parametrize(
("decoded", "expected_user_id", "allowed"),
("user_id", "allowed"),
[
pytest.param({"user_id": "user-1"}, "user-1", True, id="identified-user"),
pytest.param({}, "visitor", False, id="visitor-fallback"),
pytest.param("user-1", True, id="allowed-user"),
pytest.param("user-2", False, id="denied-user"),
],
)
@patch("controllers.web.app.application_services")
def test_checks_private_app_permission(
self,
application_services: MagicMock,
decoded: dict[str, str],
expected_user_id: str,
user_id: str,
allowed: bool,
app: Flask,
) -> None:
@ -241,14 +240,41 @@ class TestAppWebAuthPermission:
patch("controllers.web.app.extract_webapp_passport", return_value="passport") as extract_passport,
patch("controllers.web.app.PassportService") as passport_service,
):
passport_service.return_value.verify.return_value = decoded
passport_service.return_value.verify.return_value = {"user_id": user_id, "auth_type": "internal"}
result = AppWebAuthPermission().get()
assert result == {"result": allowed}
webapp_access.requires_permission_check.assert_called_once_with("app-1")
extract_passport.assert_called_once()
passport_service.return_value.verify.assert_called_once_with("passport")
webapp_access.is_user_allowed.assert_called_once_with(user_id=expected_user_id, app_id="app-1")
webapp_access.is_user_allowed.assert_called_once_with(user_id=user_id, app_id="app-1")
@pytest.mark.parametrize(
"decoded",
[
pytest.param({}, id="missing-auth-type"),
pytest.param({"auth_type": "internal"}, id="missing-user-id"),
pytest.param({"user_id": "sso_external_user", "auth_type": "external"}, id="external-auth-type"),
],
)
@patch("controllers.web.app.application_services")
def test_private_app_requires_internal_identity(
self, application_services: MagicMock, decoded: dict[str, str], app: Flask
) -> None:
webapp_access = MagicMock()
webapp_access.requires_permission_check.return_value = True
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport", return_value="passport"),
patch("controllers.web.app.PassportService") as passport_service,
):
passport_service.return_value.verify.return_value = decoded
with pytest.raises(WebAppAuthRequiredError):
AppWebAuthPermission().get()
webapp_access.is_user_allowed.assert_not_called()
@pytest.mark.parametrize("failing_method", ["requires_permission_check", "is_user_allowed"])
@patch("controllers.web.app.application_services")
@ -264,7 +290,7 @@ class TestAppWebAuthPermission:
application_services.return_value = SimpleNamespace(webapp_access=webapp_access)
passport_service = MagicMock()
passport_service.return_value.verify.return_value = {"user_id": "user-1"}
passport_service.return_value.verify.return_value = {"user_id": "user-1", "auth_type": "internal"}
with (
app.test_request_context("/webapp/permission?appId=app-1", headers={"X-App-Code": "code1"}),
patch("controllers.web.app.extract_webapp_passport", return_value="passport"),

View File

@ -0,0 +1,31 @@
import time
from collections.abc import Sequence
from configs import dify_config
from services.entities.file_grant_entities import FileGrantContext, FileGrantScope
from services.file_grant_gateways import FileGrantTokenGateway
def token_gateway() -> FileGrantTokenGateway:
return FileGrantTokenGateway(
secret_key=dify_config.SECRET_KEY,
external_files_url=dify_config.FILES_URL,
internal_files_url=dify_config.INTERNAL_FILES_URL or dify_config.FILES_URL,
content_token_ttl_seconds=dify_config.FILES_ACCESS_TIMEOUT,
now=lambda: int(time.time()),
)
def issue_file_grant(
*,
end_user_id: str,
tenant_id: str,
app_id: str,
scopes: Sequence[FileGrantScope],
ttl_seconds: int,
) -> tuple[str, int]:
return token_gateway().issue_grant(
context=FileGrantContext(tenant_id=tenant_id, app_id=app_id, end_user_id=end_user_id),
scopes=scopes,
ttl_seconds=ttl_seconds,
)

View File

@ -15,6 +15,7 @@ API_ROOT = Path(__file__).resolve().parents[3]
def test_end_user_type_covers_persisted_creation_values():
assert {member.value for member in EndUserType} == {
"app-deploy",
"browser",
"mcp",
"openapi",

View File

@ -0,0 +1,75 @@
from sqlalchemy import Engine, event
from sqlalchemy.orm import Session, sessionmaker
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.model import UploadFile
from models.tools import ToolFile
from repositories.file_grant_repository import FileGrantRepository
from services.entities.file_grant_entities import FileGrantContext, FileKind, FileRef
def test_resolve_owned_files_uses_one_query_per_file_kind(
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> None:
end_user_id = "11111111-1111-4111-8111-111111111111"
tenant_id = "22222222-2222-4222-8222-222222222222"
with sqlite_session_factory.begin() as session:
upload = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType.OPENDAL,
key="upload_files/report.pdf",
name="report.pdf",
size=10,
extension="pdf",
mime_type="application/pdf",
created_by=end_user_id,
created_by_role=CreatorUserRole.END_USER,
created_at=naive_utc_now(),
used=False,
)
tool_file = ToolFile(
user_id=end_user_id,
tenant_id=tenant_id,
conversation_id=None,
file_key="tools/chart.png",
mimetype="image/png",
name="chart.png",
size=20,
)
session.add_all([upload, tool_file])
session.flush()
upload_id = upload.id
tool_file_id = tool_file.id
statements: list[str] = []
def record_statement(
_connection: object,
_cursor: object,
statement: str,
_parameters: object,
_context: object,
_executemany: object,
) -> None:
statements.append(statement)
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
try:
refs = tuple(
FileRef(id=upload_id, kind=FileKind.UPLOAD)
if index % 2 == 0
else FileRef(id=tool_file_id, kind=FileKind.TOOL)
for index in range(100)
)
resolved = FileGrantRepository(session_factory=sqlite_session_factory).resolve_owned_files(
context=FileGrantContext(tenant_id, "app-1", end_user_id),
refs=refs,
)
finally:
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
assert len(statements) == 2
assert [file.kind if file is not None else None for file in resolved] == [ref.kind for ref in refs]

View File

@ -0,0 +1,90 @@
from collections.abc import Callable
from unittest.mock import patch
import httpx
import pytest
from services.errors.file import FileTooLargeError
from services.file_grant_gateways import FileGrantRemoteFileGateway
def test_remote_file_gateway_bounds_a_get_without_content_length(
config_overrides: Callable[..., None],
) -> None:
config_overrides(UPLOAD_FILE_SIZE_LIMIT=1)
url = "https://example.com/report.pdf"
head = httpx.Response(200, request=httpx.Request("HEAD", url))
download = httpx.Response(
200,
content=b"0" * (1024 * 1024 + 1),
request=httpx.Request("GET", url),
)
with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]) as request:
with pytest.raises(FileTooLargeError):
FileGrantRemoteFileGateway().fetch(url)
assert request.call_args_list[1].kwargs["stream_response"] is True
assert download.is_closed
def test_remote_file_gateway_uses_the_actual_body_size_when_content_length_is_incorrect(
config_overrides: Callable[..., None],
) -> None:
config_overrides(UPLOAD_FILE_SIZE_LIMIT=1)
url = "https://example.com/report.pdf"
head = httpx.Response(
200,
headers={"Content-Length": "1"},
request=httpx.Request("HEAD", url),
)
download = httpx.Response(
200,
headers={"Content-Length": "1"},
content=b"0" * (1024 * 1024 + 1),
request=httpx.Request("GET", url),
)
with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]):
with pytest.raises(FileTooLargeError):
FileGrantRemoteFileGateway().fetch(url)
assert download.is_closed
def test_remote_file_gateway_rejects_encoded_content_that_cannot_be_safely_bounded(
config_overrides: Callable[..., None],
) -> None:
config_overrides(UPLOAD_FILE_SIZE_LIMIT=1)
url = "https://example.com/report.pdf"
head = httpx.Response(200, request=httpx.Request("HEAD", url))
download = httpx.Response(
200,
headers={"Content-Encoding": "gzip"},
stream=httpx.ByteStream(b"compressed"),
request=httpx.Request("GET", url),
)
with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]):
assert FileGrantRemoteFileGateway().fetch(url) is None
assert download.is_closed
def test_remote_file_gateway_returns_bounded_content(config_overrides: Callable[..., None]) -> None:
config_overrides(UPLOAD_FILE_SIZE_LIMIT=1)
url = "https://example.com/report.pdf"
head = httpx.Response(
200,
headers={"Content-Length": "9", "Content-Type": "application/pdf"},
request=httpx.Request("HEAD", url),
)
download = httpx.Response(200, content=b"pdf-bytes", request=httpx.Request("GET", url))
with patch("services.file_grant_gateways.remote_fetcher.make_request", side_effect=[head, download]):
file = FileGrantRemoteFileGateway().fetch(url)
assert file is not None
assert file.filename == "report.pdf"
assert file.mimetype == "application/pdf"
assert file.content == b"pdf-bytes"

View File

@ -0,0 +1,147 @@
from io import BytesIO
from unittest.mock import MagicMock
import pytest
from services.entities.file_grant_entities import (
FileGrantContext,
FileGrantLimits,
FileGrantMintRequest,
FileGrantScope,
FileKind,
FileRef,
ResolvedFile,
)
from services.errors.file_grant import EndUserNotFoundError, GrantTtlTooLongError
from services.file_grant_service import MAX_SESSION_GRANT_TTL_SECONDS, FileGrantService
def _service() -> tuple[FileGrantService, MagicMock, MagicMock, MagicMock, MagicMock]:
repository = MagicMock()
repository.get_or_create_subject.return_value = "end-user-1"
repository.subject_exists.return_value = True
repository.resolve_owned_files.return_value = list[ResolvedFile | None]()
files = MagicMock()
tokens = MagicMock()
tokens.issue_grant.return_value = ("grant", 1600)
remote_files = MagicMock()
service = FileGrantService(
repository=repository,
files=files,
tokens=tokens,
remote_files=remote_files,
limits=FileGrantLimits(15, 10, 50, 100, 10, 5),
now=lambda: 1000,
)
return service, repository, files, tokens, remote_files
def _mint_request(
*,
ttl_seconds: int = 600,
file_refs: tuple[FileRef, ...] = (),
optional_file_refs: tuple[FileRef, ...] = (),
) -> FileGrantMintRequest:
return FileGrantMintRequest(
tenant_id="tenant-1",
app_id="app-1",
subject="subject-1",
is_anonymous=True,
scopes=(FileGrantScope.UPLOAD,),
ttl_seconds=ttl_seconds,
file_refs=file_refs,
optional_file_refs=optional_file_refs,
run_deadline=None,
)
def test_mint_rejects_an_invalid_ttl_before_persistence() -> None:
service, repository, _files, tokens, _remote_files = _service()
with pytest.raises(GrantTtlTooLongError):
service.mint(_mint_request(ttl_seconds=MAX_SESSION_GRANT_TTL_SECONDS + 1))
repository.get_or_create_subject.assert_not_called()
tokens.issue_grant.assert_not_called()
def test_mint_orchestrates_identity_resolution_and_token_issuance() -> None:
service, repository, _files, tokens, _remote_files = _service()
result = service.mint(_mint_request())
assert result.grant == "grant"
repository.get_or_create_subject.assert_called_once()
tokens.issue_grant.assert_called_once_with(
context=FileGrantContext("tenant-1", "app-1", "end-user-1"),
scopes=(FileGrantScope.UPLOAD,),
ttl_seconds=600,
)
def test_mint_resolves_required_and_optional_files_in_one_batch() -> None:
service, repository, _files, tokens, _remote_files = _service()
required_ref = FileRef(id="upload-1", kind=FileKind.UPLOAD)
optional_ref = FileRef(id="tool-1", kind=FileKind.TOOL)
required_file = ResolvedFile("upload-1", FileKind.UPLOAD, "report.pdf", 10, "pdf", "application/pdf")
optional_file = ResolvedFile("tool-1", FileKind.TOOL, "chart.png", 20, "png", "image/png")
repository.resolve_owned_files.return_value = [required_file, optional_file]
tokens.issue_content_urls.return_value = ("https://files/tool-1", "http://files/tool-1")
result = service.mint(
_mint_request(
file_refs=(required_ref,),
optional_file_refs=(optional_ref,),
)
)
repository.resolve_owned_files.assert_called_once_with(
context=FileGrantContext("tenant-1", "app-1", "end-user-1"),
refs=(required_ref, optional_ref),
)
assert result.files == (required_file,)
assert result.optional_files[0] is not None
assert result.optional_files[0].file == optional_file
def test_store_produced_rejects_a_deleted_subject_before_reading_the_file() -> None:
service, repository, files, _tokens, _remote_files = _service()
repository.subject_exists.return_value = False
stream = BytesIO(b"produced content")
with pytest.raises(EndUserNotFoundError):
service.store_produced(
context=FileGrantContext("tenant-1", "app-1", "deleted-user"),
filename="result.txt",
stream=stream,
mimetype="text/plain",
)
assert stream.tell() == 0
files.store_produced.assert_not_called()
def test_store_remote_upload_rejects_a_deleted_subject_before_fetching() -> None:
service, repository, _files, _tokens, remote_files = _service()
repository.subject_exists.return_value = False
with pytest.raises(EndUserNotFoundError):
service.store_remote_upload(
context=FileGrantContext("tenant-1", "app-1", "deleted-user"),
url="https://example.com/report.pdf",
)
remote_files.fetch.assert_not_called()
def test_resolve_rejects_a_deleted_subject_before_querying_files() -> None:
service, repository, _files, _tokens, _remote_files = _service()
repository.subject_exists.return_value = False
with pytest.raises(EndUserNotFoundError):
service.resolve_files(
context=FileGrantContext("tenant-1", "app-1", "deleted-user"),
refs=(),
)
repository.resolve_owned_files.assert_not_called()

View File

@ -258,6 +258,20 @@ class TestFileService:
is False
)
def test_file_size_limit(self, config_overrides: Callable[..., None]):
config_overrides(
UPLOAD_IMAGE_FILE_SIZE_LIMIT=10,
UPLOAD_VIDEO_FILE_SIZE_LIMIT=20,
UPLOAD_AUDIO_FILE_SIZE_LIMIT=30,
UPLOAD_FILE_SIZE_LIMIT=5,
)
assert FileService.file_size_limit(extension="jpg") == 10 * 1024 * 1024
assert FileService.file_size_limit(extension="mp4") == 20 * 1024 * 1024
assert FileService.file_size_limit(extension="mp3") == 30 * 1024 * 1024
assert FileService.file_size_limit(extension="txt") == 5 * 1024 * 1024
assert FileService.file_size_limit(extension="txt", default_file_size_limit=7) == 7 * 1024 * 1024
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):
self._persist_upload_file(db_session, key="test_key")

View File

@ -28,7 +28,7 @@ export const getAccessRegion = (world: DifyWorld) =>
export type AccessSurfaceName = 'Web app' | 'Backend service API'
export const getAccessSurfaceCard = (world: DifyWorld, surface: AccessSurfaceName) =>
getAccessRegion(world).getByRole('article', { name: surface }).first()
getAccessRegion(world).getByRole('region', { name: surface }).first()
export const getWebAppCard = (world: DifyWorld) => getAccessSurfaceCard(world, 'Web app')

View File

@ -22,9 +22,9 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW
const webAppCard = getWebAppCard(this)
await expect(webAppCard.getByRole('heading', { name: 'Web app' })).toBeVisible()
await expect(webAppCard.getByText('Web App URL')).toBeVisible()
await expect(webAppCard.getByText('Access URL')).toBeVisible()
await expect(webAppCard.getByLabel('Copy access URL')).toBeEnabled()
await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible()
await expect(webAppCard.getByRole('link', { name: 'Open' })).toBeVisible()
})
When('I copy the Agent v2 Web app access URL', async function (this: DifyWorld) {
@ -38,11 +38,11 @@ Then('the Agent v2 Web app access URL should show it was copied', async function
When('I launch the Agent v2 Web app', async function (this: DifyWorld) {
await recordComposerDraftSnapshot(this)
const launchLink = getWebAppCard(this).getByRole('link', { name: 'Launch' })
const href = await launchLink.getAttribute('href')
if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.')
const openLink = getWebAppCard(this).getByRole('link', { name: 'Open' })
const href = await openLink.getAttribute('href')
if (!href) throw new Error('Agent v2 Web app Open link does not expose an href.')
const [webAppPage] = await Promise.all([this.getPage().waitForEvent('popup'), launchLink.click()])
const [webAppPage] = await Promise.all([this.getPage().waitForEvent('popup'), openLink.click()])
this.agentBuilder.accessPoint.webAppURL = href
this.agentBuilder.accessPoint.webAppPage = webAppPage
@ -130,7 +130,7 @@ When('I close the Agent v2 Web app', async function (this: DifyWorld) {
When('I open Agent v2 Embedded configuration', async function (this: DifyWorld) {
await recordComposerDraftSnapshot(this)
await getWebAppCard(this).getByRole('button', { name: 'Embedded' }).click()
await getWebAppCard(this).getByRole('button', { name: 'Embed Into Site' }).click()
})
Then('I should see the Agent v2 Embedded configuration dialog', async function (this: DifyWorld) {
@ -143,7 +143,7 @@ Then('I should see the Agent v2 Embedded configuration dialog', async function (
When('I open Agent v2 Web app customization', async function (this: DifyWorld) {
await recordComposerDraftSnapshot(this)
await getWebAppCard(this).getByRole('button', { name: 'Custom Frontend' }).click()
await getWebAppCard(this).getByRole('button', { name: 'Custom frontend' }).click()
})
Then('I should see the Agent v2 Web app customization dialog', async function (this: DifyWorld) {
@ -156,7 +156,7 @@ Then('I should see the Agent v2 Web app customization dialog', async function (t
When('I open Agent v2 Web app settings', async function (this: DifyWorld) {
await recordComposerDraftSnapshot(this)
await getWebAppCard(this).getByRole('button', { name: 'Branding' }).click()
await getWebAppCard(this).getByRole('button', { name: 'Settings' }).click()
})
Then('I should see the Agent v2 Web app settings dialog', async function (this: DifyWorld) {

View File

@ -81,7 +81,7 @@ Then(
await expect(webAppCard.getByText('Out of service')).toBeVisible({ timeout: 30_000 })
await expect(webAppCard.getByLabel('Toggle Web app access')).toBeDisabled()
await expect(webAppCard.getByRole('button', { name: 'Launch' })).toBeDisabled()
await expect(webAppCard.getByRole('button', { name: 'Open' })).toBeDisabled()
await expect(serviceApiCard.getByText('Out of service')).toBeVisible()
await expect(serviceApiCard.getByLabel('Toggle Backend service API access')).toBeDisabled()
await expect(serviceApiCard.getByRole('button', { name: /^API Key\b/ })).toBeDisabled()
@ -94,9 +94,9 @@ When(
const accessSurfaceCard = getAccessSurfaceCard(this, surface)
if (surface === 'Web app') {
const launchLink = accessSurfaceCard.getByRole('link', { name: 'Launch' })
const href = await launchLink.getAttribute('href')
if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.')
const openLink = accessSurfaceCard.getByRole('link', { name: 'Open' })
const href = await openLink.getAttribute('href')
if (!href) throw new Error('Agent v2 Web app Open link does not expose an href.')
this.agentBuilder.accessPoint.webAppURL = href
}
@ -122,7 +122,7 @@ Then(
await expect(toggle).toBeEnabled()
await expect(toggle).toHaveAttribute('aria-checked', 'false')
if (surface === 'Web app')
await expect(accessSurfaceCard.getByRole('button', { name: 'Launch' })).toBeDisabled()
await expect(accessSurfaceCard.getByRole('button', { name: 'Open' })).toBeDisabled()
},
)
@ -136,6 +136,6 @@ Then(
await expect(toggle).toBeEnabled()
await expect(toggle).toHaveAttribute('aria-checked', 'true')
if (surface === 'Web app')
await expect(accessSurfaceCard.getByRole('link', { name: 'Launch' })).toBeVisible()
await expect(accessSurfaceCard.getByRole('link', { name: 'Open' })).toBeVisible()
},
)

View File

@ -20,6 +20,10 @@ const getLatestCreatedAppId = (world: DifyWorld) => {
return appId
}
const expectAppEditorContent = async (world: DifyWorld) => {
await expect(world.getPage().getByRole('link', { name: 'Orchestrate' })).toBeVisible()
}
When('I start creating a blank app', async function (this: DifyWorld) {
await openBlankAppCreation(this.getPage())
})
@ -78,14 +82,17 @@ Then('I should land on the app editor', async function (this: DifyWorld) {
await expect(this.getPage()).toHaveURL(
new RegExp(`/app/${appId}/(workflow|configuration)(?:\\?.*)?$`),
)
await expectAppEditorContent(this)
})
Then('I should land on the workflow editor', async function (this: DifyWorld) {
const appId = getLatestCreatedAppId(this)
await expect(this.getPage()).toHaveURL(new RegExp(`/app/${appId}/workflow(?:\\?.*)?$`))
await expectAppEditorContent(this)
})
Then('I should land on the app configuration page', async function (this: DifyWorld) {
const appId = getLatestCreatedAppId(this)
await expect(this.getPage()).toHaveURL(new RegExp(`/app/${appId}/configuration(?:\\?.*)?$`))
await expectAppEditorContent(this)
})

View File

@ -4491,12 +4491,6 @@
"eslint-react/set-state-in-effect": {
"count": 4
},
"jsx-a11y/click-events-have-key-events": {
"count": 1
},
"jsx-a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}

View File

@ -4,6 +4,8 @@ export type ClientOptions = {
baseUrl: `${string}://${string}` | (string & {})
}
export type GoogleProtobufValue = unknown
export const EnvironmentStatus = {
ENVIRONMENT_STATUS_UNSPECIFIED: 'ENVIRONMENT_STATUS_UNSPECIFIED',
ENVIRONMENT_STATUS_PENDING: 'ENVIRONMENT_STATUS_PENDING',
@ -21,11 +23,27 @@ export const ApplicationInteractionStatus = {
APPLICATION_INTERACTION_STATUS_FAILED: 'APPLICATION_INTERACTION_STATUS_FAILED',
APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED:
'APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED',
APPLICATION_INTERACTION_STATUS_STOPPED: 'APPLICATION_INTERACTION_STATUS_STOPPED',
APPLICATION_INTERACTION_STATUS_PAUSED: 'APPLICATION_INTERACTION_STATUS_PAUSED',
} as const
export type ApplicationInteractionStatus =
(typeof ApplicationInteractionStatus)[keyof typeof ApplicationInteractionStatus]
export const ApplicationInteractionSource = {
APPLICATION_INTERACTION_SOURCE_UNSPECIFIED: 'APPLICATION_INTERACTION_SOURCE_UNSPECIFIED',
APPLICATION_INTERACTION_SOURCE_WEB_APP: 'APPLICATION_INTERACTION_SOURCE_WEB_APP',
APPLICATION_INTERACTION_SOURCE_SERVICE_API: 'APPLICATION_INTERACTION_SOURCE_SERVICE_API',
APPLICATION_INTERACTION_SOURCE_TRIGGER: 'APPLICATION_INTERACTION_SOURCE_TRIGGER',
APPLICATION_INTERACTION_SOURCE_EXPLORE: 'APPLICATION_INTERACTION_SOURCE_EXPLORE',
APPLICATION_INTERACTION_SOURCE_DEBUGGER: 'APPLICATION_INTERACTION_SOURCE_DEBUGGER',
APPLICATION_INTERACTION_SOURCE_VALIDATION: 'APPLICATION_INTERACTION_SOURCE_VALIDATION',
APPLICATION_INTERACTION_SOURCE_OPENAPI: 'APPLICATION_INTERACTION_SOURCE_OPENAPI',
} as const
export type ApplicationInteractionSource =
(typeof ApplicationInteractionSource)[keyof typeof ApplicationInteractionSource]
export const EnvironmentMode = {
ENVIRONMENT_MODE_UNSPECIFIED: 'ENVIRONMENT_MODE_UNSPECIFIED',
ENVIRONMENT_MODE_SHARED: 'ENVIRONMENT_MODE_SHARED',
@ -124,36 +142,17 @@ export const EnvironmentBackend = {
export type EnvironmentBackend = (typeof EnvironmentBackend)[keyof typeof EnvironmentBackend]
export const EnvironmentManagedBy = {
ENVIRONMENT_MANAGED_BY_UNSPECIFIED: 'ENVIRONMENT_MANAGED_BY_UNSPECIFIED',
ENVIRONMENT_MANAGED_BY_SYSTEM: 'ENVIRONMENT_MANAGED_BY_SYSTEM',
ENVIRONMENT_MANAGED_BY_USER: 'ENVIRONMENT_MANAGED_BY_USER',
export const RuntimeState = {
RUNTIME_STATE_UNSPECIFIED: 'RUNTIME_STATE_UNSPECIFIED',
RUNTIME_STATE_UNDEPLOYED: 'RUNTIME_STATE_UNDEPLOYED',
RUNTIME_STATE_RUNNING: 'RUNTIME_STATE_RUNNING',
RUNTIME_STATE_STARTING: 'RUNTIME_STATE_STARTING',
RUNTIME_STATE_STOPPING: 'RUNTIME_STATE_STOPPING',
RUNTIME_STATE_ERROR: 'RUNTIME_STATE_ERROR',
RUNTIME_STATE_UNKNOWN: 'RUNTIME_STATE_UNKNOWN',
} as const
export type EnvironmentManagedBy = (typeof EnvironmentManagedBy)[keyof typeof EnvironmentManagedBy]
export const EnvironmentDeployedAppStatus = {
ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED',
ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED',
ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING: 'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING',
ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED',
ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED: 'ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED',
} as const
export type EnvironmentDeployedAppStatus =
(typeof EnvironmentDeployedAppStatus)[keyof typeof EnvironmentDeployedAppStatus]
export const DeploymentStatus = {
DEPLOYMENT_STATUS_UNSPECIFIED: 'DEPLOYMENT_STATUS_UNSPECIFIED',
DEPLOYMENT_STATUS_UNDEPLOYED: 'DEPLOYMENT_STATUS_UNDEPLOYED',
DEPLOYMENT_STATUS_DEPLOYING: 'DEPLOYMENT_STATUS_DEPLOYING',
DEPLOYMENT_STATUS_RUNNING: 'DEPLOYMENT_STATUS_RUNNING',
DEPLOYMENT_STATUS_UNDEPLOYING: 'DEPLOYMENT_STATUS_UNDEPLOYING',
DEPLOYMENT_STATUS_INVALID: 'DEPLOYMENT_STATUS_INVALID',
DEPLOYMENT_STATUS_FAILED: 'DEPLOYMENT_STATUS_FAILED',
} as const
export type DeploymentStatus = (typeof DeploymentStatus)[keyof typeof DeploymentStatus]
export type RuntimeState = (typeof RuntimeState)[keyof typeof RuntimeState]
export const EnvVarValueSource = {
ENV_VAR_VALUE_SOURCE_UNSPECIFIED: 'ENV_VAR_VALUE_SOURCE_UNSPECIFIED',
@ -169,6 +168,7 @@ export const EnvVarValueType = {
ENV_VAR_VALUE_TYPE_STRING: 'ENV_VAR_VALUE_TYPE_STRING',
ENV_VAR_VALUE_TYPE_NUMBER: 'ENV_VAR_VALUE_TYPE_NUMBER',
ENV_VAR_VALUE_TYPE_SECRET: 'ENV_VAR_VALUE_TYPE_SECRET',
ENV_VAR_VALUE_TYPE_LLM: 'ENV_VAR_VALUE_TYPE_LLM',
} as const
export type EnvVarValueType = (typeof EnvVarValueType)[keyof typeof EnvVarValueType]
@ -203,22 +203,17 @@ export type AppEnvironment = {
export type ApplicationInteraction = {
id: string
timestamp: string
workflowRunId: string
status: ApplicationInteractionStatus
durationSeconds: number
totalTokens: string
workspace: NamedRef
environment: NamedRef
app: NamedRef
operator?: Operator
invokeFrom: string
traceId: string
difyTraceId: string
deploymentVersionId: string
operator?: NamedRef
source: ApplicationInteractionSource
version?: WorkflowVersion
traceId?: string
error?: string
body?: string
attributesJson?: string
resourceAttributesJson?: string
}
export type BatchGetSourceVersionDeploymentsRequest = {
@ -262,7 +257,7 @@ export type CreateEnvironmentRequest = {
displayName: string
description?: string
mode: EnvironmentMode
cpuPool: number
cpuPoolMillicores: number
namespace?: string
maxMemoryMib?: string
}
@ -292,12 +287,7 @@ export type CredentialSlot = {
last_deployed_credential_id?: string
icon?: string
icon_dark?: string
}
export type DashboardApp = {
id: string
workspaceId: string
displayName: string
workflow_as_tool_dependency?: WorkflowAsToolDependency
}
export type DeleteEnvironmentApiKeyResponse = {
@ -308,6 +298,15 @@ export type DeleteEnvironmentResponse = {
[key: string]: unknown
}
export type DeleteServiceApiConversationRequest = {
conversationId: string
user: string
}
export type DeleteServiceApiConversationResponse = {
[key: string]: unknown
}
export type DeployWorkflowResponse = {
operation: DeploymentOperationReceipt
}
@ -358,8 +357,7 @@ export type Environment = {
statusMessage: string
lastError?: Error
namespace?: string
managedBy?: EnvironmentManagedBy
cpuPool: number
cpuPoolMillicores: number
createdAt: string
updatedAt: string
memory?: RunnerMemory
@ -381,6 +379,14 @@ export type EnvironmentAccess = {
enable_api: boolean
}
export type EnvironmentActivity = {
environmentId: string
invocationCount: string
failedInvocationCount: string
failedDeploymentCount: string
deployedAppCount: string
}
export type EnvironmentApiKey = {
id: string
type: string
@ -393,13 +399,15 @@ export type EnvironmentDeployedApp = {
deploymentId: string
workspace: NamedRef
app: NamedRef
status: EnvironmentDeployedAppStatus
runtimeState: RuntimeState
currentVersion?: WorkflowVersion
deployedAt?: string
deployedBy?: Operator
latestAttempt?: EnvironmentDeployedAppAttempt
sizing?: RunnerSizing
occupiesPool?: boolean
recentInvocationCount?: string
versionsBehind?: number
}
export type EnvironmentDeployedAppAttempt = {
@ -412,13 +420,6 @@ export type EnvironmentDeployedAppAttempt = {
finalizedAt?: string
}
export type EnvironmentDeployedAppSummary = {
total: number
deployed: number
deploying: number
failed: number
}
export type EnvironmentDeployment = {
environment: DeploymentEnvironment
deployment?: EnvironmentDeploymentState
@ -435,7 +436,7 @@ export type EnvironmentDeploymentOperation = {
}
export type EnvironmentDeploymentState = {
status: DeploymentStatus
runtimeState: RuntimeState
current_version?: WorkflowVersion
versions_behind?: number
deployed_at?: number
@ -449,17 +450,17 @@ export type EnvironmentMcpServer = {
export type EnvironmentPoolComposition = {
topApps?: Array<EnvironmentPoolShare>
otherCpu?: number
otherCpuMillicores?: number
otherAppCount?: number
}
export type EnvironmentPoolShare = {
app: NamedRef
isolatedCpu: number
isolatedCpuMillicores: number
}
export type EnvironmentPoolUsage = {
occupiedCpu: number
occupiedCpuMillicores: number
appCount: number
}
@ -478,10 +479,16 @@ export type EnvironmentTrigger = {
[key: string]: unknown
}
export type EnvironmentVariableGroup = {
from_app?: WorkflowReference
from_workflow_as_tool?: WorkflowAsToolSource
environment_variable_slots: Array<EnvironmentVariableSlot>
}
export type EnvironmentVariableInput = {
key: string
value_source: EnvVarValueSource
value?: string
value?: GoogleProtobufValue
}
export type EnvironmentVariableSlot = {
@ -490,8 +497,8 @@ export type EnvironmentVariableSlot = {
description: string
has_configured_value: boolean
has_last_deployed_value: boolean
configured_value?: string
last_deployed_value?: string
configured_value?: GoogleProtobufValue
last_deployed_value?: GoogleProtobufValue
}
export type EnvironmentWebAppAccessModeUpdate = {
@ -540,7 +547,6 @@ export type Error = {
| 'APPDEPLOY_APP_LOG_INVALID_TIME_RANGE'
| 'APPDEPLOY_APP_LOG_INVALID_CURSOR'
| 'APPDEPLOY_APP_LOG_CURSOR_FILTER_MISMATCH'
| 'APPDEPLOY_APP_LOG_ID_INVALID'
| 'APPDEPLOY_UNSUPPORTED_NODE_TYPE'
| 'APPDEPLOY_UNSUPPORTED_TOOL_PROVIDER_TYPE'
| 'APPDEPLOY_TOOL_PROVIDER_TYPE_INVALID'
@ -552,15 +558,12 @@ export type Error = {
| 'APPDEPLOY_INVALID_WORKFLOW_ID'
| 'APPDEPLOY_INVALID_DEPLOYMENT_VERSION_ID'
| 'APPDEPLOY_DEVELOPER_API_URL_NOT_CONFIGURED'
| 'APPDEPLOY_INVALID_DEPLOYMENT_OPERATION_ID'
| 'APPDEPLOY_APP_LOG_EXPORT_RANGE_TOO_WIDE'
| 'APPDEPLOY_APP_LOG_EXPORT_TOO_MANY_ROWS'
| 'APPDEPLOY_APP_LOG_EXPORT_TOO_LARGE'
| 'APPDEPLOY_UNAUTHORIZED'
| 'APPDEPLOY_FORBIDDEN'
| 'APPDEPLOY_APP_RUNNER_AUTH_REQUIRED'
| 'APPDEPLOY_APP_RUNNER_INVALID_JOIN_TOKEN'
| 'APPDEPLOY_APP_RUNNER_INVALID_CONTROL_TOKEN'
| 'APPDEPLOY_WEB_APP_ACCESS_DENIED'
| 'APPDEPLOY_ENVIRONMENT_NOT_FOUND'
| 'APPDEPLOY_DEPLOYMENT_NOT_FOUND'
| 'APPDEPLOY_REVISION_NOT_FOUND'
@ -571,15 +574,15 @@ export type Error = {
| 'APPDEPLOY_ACCESS_SUBJECT_NOT_FOUND'
| 'APPDEPLOY_API_KEY_NOT_FOUND'
| 'APPDEPLOY_SOURCE_VERSION_NOT_FOUND'
| 'APPDEPLOY_APP_LOG_NOT_FOUND'
| 'APPDEPLOY_WORKSPACE_NOT_FOUND'
| 'APPDEPLOY_APP_RUNNER_NOT_FOUND'
| 'APPDEPLOY_WORKFLOW_NOT_FOUND'
| 'APPDEPLOY_DEPLOYMENT_OPERATION_NOT_FOUND'
| 'APPDEPLOY_RUN_FILE_NOT_FOUND'
| 'APPDEPLOY_APPLICATION_UNAVAILABLE'
| 'APPDEPLOY_TARGET_ENVIRONMENT_REMOVED'
| 'APPDEPLOY_VERSION_UNAVAILABLE'
| 'APPDEPLOY_CONVERSATION_NOT_FOUND'
| 'APPDEPLOY_CHAT_MESSAGE_NOT_FOUND'
| 'APPDEPLOY_CONFLICT'
| 'APPDEPLOY_DEPLOYMENT_IN_PROGRESS'
| 'APPDEPLOY_ALREADY_UNDEPLOYED'
@ -618,10 +621,13 @@ export type Error = {
| 'APPDEPLOY_ENVIRONMENT_CPU_POOL_EXHAUSTED'
| 'APPDEPLOY_RESOURCE_NOT_APPLICABLE_FOR_MODE'
| 'APPDEPLOY_ENVIRONMENT_CPU_POOL_BELOW_ALLOCATED'
| 'APPDEPLOY_CHAT_CONTEXT_TOO_LARGE'
| 'APPDEPLOY_FILE_GRANT_UNAVAILABLE'
| 'APPDEPLOY_APP_RUNNER_CONTROL_NOT_CONFIGURED'
| 'APPDEPLOY_RUNTIME_ASSIGNMENT_FAILED'
| 'APPDEPLOY_REVISION_TIMEOUT'
| 'APPDEPLOY_INTERNAL_ERROR'
| 'APPDEPLOY_RECEIPT_RETRY'
| 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_AUTH_REJECTED'
| 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_NAMESPACE_MISSING'
| 'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INSUFFICIENT_RBAC'
@ -640,12 +646,14 @@ export type Error = {
detailCode?: string
}
export type GetApplicationInteractionResponse = {
interaction: ApplicationInteraction
export type GetApplicationInteractionSummaryResponse = {
totalCount: string
failedCount: string
lookbackStart: string
}
export type GetDeploymentOperationResponse = {
operation: DeploymentOperation
export type GetEnvironmentActivityResponse = {
data: Array<EnvironmentActivity>
}
export type GetEnvironmentCapabilitiesResponse = {
@ -673,12 +681,17 @@ export type GetWebAppAccessModeResponse = {
accessMode?: string
}
export type GetWebAppLoginStatusResponse = {
logged_in?: boolean
app_logged_in?: boolean
}
export type GetWebAppPermissionResponse = {
result?: boolean
}
export type GetWorkflowDeploymentOptionsResponse = {
environment_variable_slots: Array<EnvironmentVariableSlot>
environment_variable_groups: Array<EnvironmentVariableGroup>
credential_slots: Array<CredentialSlot>
}
@ -686,14 +699,15 @@ export type ListAppEnvironmentsResponse = {
data: Array<AppEnvironment>
}
export type ListApplicationInteractionsResponse = {
data: Array<ApplicationInteraction>
export type ListApplicationInteractionAppsResponse = {
data: Array<NamedRef>
pagination: Pagination
}
export type ListAppsResponse = {
data: Array<DashboardApp>
pagination: Pagination
export type ListApplicationInteractionsResponse = {
data: Array<ApplicationInteraction>
nextPageToken?: string
previousPageToken?: string
}
export type ListDeploymentOperationsResponse = {
@ -707,7 +721,6 @@ export type ListEnvironmentApiKeysResponse = {
export type ListEnvironmentDeployedAppsResponse = {
data: Array<EnvironmentDeployedApp>
summary: EnvironmentDeployedAppSummary
pagination: Pagination
}
@ -724,11 +737,34 @@ export type ListEnvironmentsResponse = {
pagination: Pagination
}
export type ListOperationAppsResponse = {
data: Array<OperationApp>
pagination: Pagination
}
export type MintServiceApiFileGrantRequest = {
tenantId?: string
appId?: string
environmentId?: string
user?: string
}
export type MintServiceApiFileGrantResponse = {
grant?: string
expiresAt?: string
}
export type NamedRef = {
id: string
displayName: string
}
export type OperationApp = {
id?: string
workspaceId?: string
displayName?: string
}
export type Operator = {
type: OperatorType
id: string
@ -744,6 +780,20 @@ export type PrepareAppDeletionRequest = {
appId?: string
}
export type RenameServiceApiConversationRequest = {
conversationId: string
user: string
name?: string
autoGenerate?: boolean
}
export type RenameWebAppConversationRequest = {
appCode: string
conversationId: string
name?: string
autoGenerate?: boolean
}
export type ResolveApiTokenRouteRequest = {
token?: string
}
@ -753,17 +803,14 @@ export type ResolveApiTokenRouteResponse = {
namespace?: string
serviceName?: string
servicePort?: number
environmentStatus?: EnvironmentStatus
appId?: string
tenantId?: string
deploymentId?: string
servingRevisionId?: string
deploymentStatus?: DeploymentStatus
revoked?: boolean
unavailableReason?: string
targetKind?: RouteTargetKind
directUpstream?: string
deploymentGeneration?: string
assignmentGeneration?: string
decision?: string
}
export type ResolveWebAppRouteRequest = {
@ -777,18 +824,18 @@ export type ResolveWebAppRouteResponse = {
namespace?: string
serviceName?: string
servicePort?: number
environmentStatus?: EnvironmentStatus
appId?: string
tenantId?: string
deploymentId?: string
servingRevisionId?: string
deploymentStatus?: DeploymentStatus
unavailableReason?: string
targetKind?: RouteTargetKind
directUpstream?: string
deploymentGeneration?: string
endUserId?: string
authType?: string
assignmentGeneration?: string
userId?: string
userFrom?: string
userAuthType?: string
fileGrant?: string
fileGrantExpiresAt?: string
}
export type RetryEnvironmentBootstrapRequest = {
@ -806,7 +853,7 @@ export type RunnerMemory = {
}
export type RunnerSizing = {
isolatedCpu: number
isolatedCpuMillicores: number
memory: RunnerMemory
}
@ -818,7 +865,12 @@ export type SimpleAccount = {
export type SourceVersionDeployment = {
sourceVersionId?: string
environments?: Array<WorkflowDeploymentEnvironment>
environments?: Array<SourceVersionDeploymentEnvironment>
}
export type SourceVersionDeploymentEnvironment = {
id?: string
name?: string
}
export type TestConnectionRequest = {
@ -843,6 +895,7 @@ export type UnsupportedNode = {
type: string
title: string
provider?: UnsupportedNodeProvider
workflow_as_tool_dependency?: WorkflowAsToolDependency
}
export type UnsupportedNodeProvider = {
@ -855,15 +908,15 @@ export type UnsupportedNodeProvider = {
export type UpdateEnvironmentDeployedAppResourcesRequest = {
environmentId: string
deploymentId: string
isolatedCpu: number
isolatedCpuMillicores: number
maxMemoryMib?: string
}
export type UpdateEnvironmentDeployedAppResourcesResponse = {
deploymentId: string
isolatedCpu: number
allocatedCpuCount: number
poolCpuCount: number
isolatedCpuMillicores: number
allocatedCpuMillicores: number
poolCpuMillicores: number
memory: RunnerMemory
}
@ -871,7 +924,7 @@ export type UpdateEnvironmentRequest = {
environmentId?: string
displayName?: string
description?: string
cpuPool?: number
cpuPoolMillicores?: number
maxMemoryMib?: string
}
@ -879,16 +932,46 @@ export type UpdateEnvironmentResponse = {
environment: Environment
}
export type WorkflowDeploymentEnvironment = {
id?: string
name?: string
export type UpdateServiceApiConversationVariableRequest = {
conversationId: string
variableId: string
user: string
value: GoogleProtobufValue
}
export type WorkflowAsToolDependency = {
paths: Array<WorkflowPath>
}
export type WorkflowAsToolSource = {
workflow: WorkflowReference
paths: Array<WorkflowPath>
}
export type WorkflowDeploymentInput = {
environment_variables?: Array<EnvironmentVariableInput>
environment_variable_groups: Array<WorkflowEnvironmentVariableInputGroup>
credentials?: Array<CredentialSelectionInput>
}
export type WorkflowEnvironmentVariableInputGroup = {
workflow_id: string
environment_variables: Array<EnvironmentVariableInput>
}
export type WorkflowPath = {
workflows: Array<WorkflowReference>
}
export type WorkflowReference = {
app_id: string
workflow_id: string
name: string
icon: string
icon_background: string
icon_type: string
icon_url?: string
}
export type WorkflowVersion = {
version?: string
marked_name?: string
@ -898,7 +981,6 @@ export type WorkflowVersion = {
created_at?: number
created_by?: SimpleAccount
dsl_hash?: string
deleted?: boolean
}
export type Pagination = {
@ -920,6 +1002,10 @@ export type DeleteEnvironmentResponseWritable = {
[key: string]: unknown
}
export type DeleteServiceApiConversationResponseWritable = {
[key: string]: unknown
}
export type EnvironmentWritable = {
id: string
displayName: string
@ -930,8 +1016,7 @@ export type EnvironmentWritable = {
statusMessage: string
lastError?: Error
namespace?: string
managedBy?: EnvironmentManagedBy
cpuPool: number
cpuPoolMillicores: number
createdAt: string
updatedAt: string
memory?: RunnerMemoryWritable
@ -942,13 +1027,15 @@ export type EnvironmentDeployedAppWritable = {
deploymentId: string
workspace: NamedRef
app: NamedRef
status: EnvironmentDeployedAppStatus
runtimeState: RuntimeState
currentVersion?: WorkflowVersion
deployedAt?: string
deployedBy?: Operator
latestAttempt?: EnvironmentDeployedAppAttempt
sizing?: RunnerSizingWritable
occupiesPool?: boolean
recentInvocationCount?: string
versionsBehind?: number
}
export type EnvironmentMcpServerWritable = {
@ -966,7 +1053,6 @@ export type GetEnvironmentResponseWritable = {
export type ListEnvironmentDeployedAppsResponseWritable = {
data: Array<EnvironmentDeployedAppWritable>
summary: EnvironmentDeployedAppSummary
pagination: Pagination
}
@ -984,15 +1070,15 @@ export type RunnerMemoryWritable = {
}
export type RunnerSizingWritable = {
isolatedCpu: number
isolatedCpuMillicores: number
memory: RunnerMemoryWritable
}
export type UpdateEnvironmentDeployedAppResourcesResponseWritable = {
deploymentId: string
isolatedCpu: number
allocatedCpuCount: number
poolCpuCount: number
isolatedCpuMillicores: number
allocatedCpuMillicores: number
poolCpuMillicores: number
memory: RunnerMemoryWritable
}

View File

@ -2,6 +2,11 @@
import * as z from 'zod'
/**
* Represents a dynamically typed value which can be either null, a number, a string, a boolean, a recursive struct value, or a list of values.
*/
export const zGoogleProtobufValue = z.unknown()
export const zEnvironmentStatus = z.enum([
'ENVIRONMENT_STATUS_UNSPECIFIED',
'ENVIRONMENT_STATUS_PENDING',
@ -16,6 +21,19 @@ export const zApplicationInteractionStatus = z.enum([
'APPLICATION_INTERACTION_STATUS_SUCCEEDED',
'APPLICATION_INTERACTION_STATUS_FAILED',
'APPLICATION_INTERACTION_STATUS_PARTIAL_SUCCEEDED',
'APPLICATION_INTERACTION_STATUS_STOPPED',
'APPLICATION_INTERACTION_STATUS_PAUSED',
])
export const zApplicationInteractionSource = z.enum([
'APPLICATION_INTERACTION_SOURCE_UNSPECIFIED',
'APPLICATION_INTERACTION_SOURCE_WEB_APP',
'APPLICATION_INTERACTION_SOURCE_SERVICE_API',
'APPLICATION_INTERACTION_SOURCE_TRIGGER',
'APPLICATION_INTERACTION_SOURCE_EXPLORE',
'APPLICATION_INTERACTION_SOURCE_DEBUGGER',
'APPLICATION_INTERACTION_SOURCE_VALIDATION',
'APPLICATION_INTERACTION_SOURCE_OPENAPI',
])
export const zEnvironmentMode = z.enum([
@ -79,28 +97,14 @@ export const zEnvironmentBackend = z.enum([
'ENVIRONMENT_BACKEND_EXTERNAL',
])
export const zEnvironmentManagedBy = z.enum([
'ENVIRONMENT_MANAGED_BY_UNSPECIFIED',
'ENVIRONMENT_MANAGED_BY_SYSTEM',
'ENVIRONMENT_MANAGED_BY_USER',
])
export const zEnvironmentDeployedAppStatus = z.enum([
'ENVIRONMENT_DEPLOYED_APP_STATUS_UNSPECIFIED',
'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYED',
'ENVIRONMENT_DEPLOYED_APP_STATUS_DEPLOYING',
'ENVIRONMENT_DEPLOYED_APP_STATUS_FAILED',
'ENVIRONMENT_DEPLOYED_APP_STATUS_UNDEPLOYED',
])
export const zDeploymentStatus = z.enum([
'DEPLOYMENT_STATUS_UNSPECIFIED',
'DEPLOYMENT_STATUS_UNDEPLOYED',
'DEPLOYMENT_STATUS_DEPLOYING',
'DEPLOYMENT_STATUS_RUNNING',
'DEPLOYMENT_STATUS_UNDEPLOYING',
'DEPLOYMENT_STATUS_INVALID',
'DEPLOYMENT_STATUS_FAILED',
export const zRuntimeState = z.enum([
'RUNTIME_STATE_UNSPECIFIED',
'RUNTIME_STATE_UNDEPLOYED',
'RUNTIME_STATE_RUNNING',
'RUNTIME_STATE_STARTING',
'RUNTIME_STATE_STOPPING',
'RUNTIME_STATE_ERROR',
'RUNTIME_STATE_UNKNOWN',
])
export const zEnvVarValueSource = z.enum([
@ -115,6 +119,7 @@ export const zEnvVarValueType = z.enum([
'ENV_VAR_VALUE_TYPE_STRING',
'ENV_VAR_VALUE_TYPE_NUMBER',
'ENV_VAR_VALUE_TYPE_SECRET',
'ENV_VAR_VALUE_TYPE_LLM',
])
export const zOperatorType = z.enum([
@ -173,7 +178,10 @@ export const zCreateEnvironmentRequest = z.object({
displayName: z.string(),
description: z.string().optional(),
mode: zEnvironmentMode,
cpuPool: z.number(),
cpuPoolMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
namespace: z.string().optional(),
maxMemoryMib: z.string().optional(),
})
@ -192,25 +200,17 @@ export const zCredentialSelectionInput = z.object({
credential_id: z.string(),
})
export const zCredentialSlot = z.object({
provider_id: z.string(),
category: zPluginCategory,
candidates: z.array(zCredentialCandidate),
last_deployed_credential_id: z.string().optional(),
icon: z.string().optional(),
icon_dark: z.string().optional(),
})
export const zDashboardApp = z.object({
id: z.string(),
workspaceId: z.string(),
displayName: z.string(),
})
export const zDeleteEnvironmentApiKeyResponse = z.record(z.string(), z.unknown())
export const zDeleteEnvironmentResponse = z.record(z.string(), z.unknown())
export const zDeleteServiceApiConversationRequest = z.object({
conversationId: z.string(),
user: z.string(),
})
export const zDeleteServiceApiConversationResponse = z.record(z.string(), z.unknown())
export const zDeploymentEnvironment = z.object({
id: z.string(),
display_name: z.string(),
@ -257,6 +257,18 @@ export const zEnvironmentAccess = z.object({
enable_api: z.boolean(),
})
/**
* EnvironmentActivity reports a numerator and a denominator rather than a rate,
* so a caller can tell a healthy environment from one nothing has called.
*/
export const zEnvironmentActivity = z.object({
environmentId: z.string(),
invocationCount: z.string(),
failedInvocationCount: z.string(),
failedDeploymentCount: z.string(),
deployedAppCount: z.string(),
})
export const zEnvironmentApiKey = z.object({
id: z.string(),
type: z.string(),
@ -275,29 +287,13 @@ export const zEnvironmentDeployedAppAttempt = z.object({
finalizedAt: z.iso.datetime().optional(),
})
export const zEnvironmentDeployedAppSummary = z.object({
total: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
deployed: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
deploying: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
failed: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
})
export const zEnvironmentMcpServer = z.record(z.string(), z.unknown())
export const zEnvironmentPoolUsage = z.object({
occupiedCpu: z.number(),
occupiedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
appCount: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
@ -320,7 +316,7 @@ export const zEnvironmentTrigger = z.record(z.string(), z.unknown())
export const zEnvironmentVariableInput = z.object({
key: z.string(),
value_source: zEnvVarValueSource,
value: z.string().optional(),
value: zGoogleProtobufValue.optional(),
})
export const zEnvironmentVariableSlot = z.object({
@ -329,8 +325,8 @@ export const zEnvironmentVariableSlot = z.object({
description: z.string(),
has_configured_value: z.boolean(),
has_last_deployed_value: z.boolean(),
configured_value: z.string().optional(),
last_deployed_value: z.string().optional(),
configured_value: zGoogleProtobufValue.optional(),
last_deployed_value: zGoogleProtobufValue.optional(),
})
export const zEnvironmentWebAppSubjectAccountData = z.object({
@ -384,7 +380,6 @@ export const zError = z.object({
'APPDEPLOY_APP_LOG_INVALID_TIME_RANGE',
'APPDEPLOY_APP_LOG_INVALID_CURSOR',
'APPDEPLOY_APP_LOG_CURSOR_FILTER_MISMATCH',
'APPDEPLOY_APP_LOG_ID_INVALID',
'APPDEPLOY_UNSUPPORTED_NODE_TYPE',
'APPDEPLOY_UNSUPPORTED_TOOL_PROVIDER_TYPE',
'APPDEPLOY_TOOL_PROVIDER_TYPE_INVALID',
@ -396,15 +391,12 @@ export const zError = z.object({
'APPDEPLOY_INVALID_WORKFLOW_ID',
'APPDEPLOY_INVALID_DEPLOYMENT_VERSION_ID',
'APPDEPLOY_DEVELOPER_API_URL_NOT_CONFIGURED',
'APPDEPLOY_INVALID_DEPLOYMENT_OPERATION_ID',
'APPDEPLOY_APP_LOG_EXPORT_RANGE_TOO_WIDE',
'APPDEPLOY_APP_LOG_EXPORT_TOO_MANY_ROWS',
'APPDEPLOY_APP_LOG_EXPORT_TOO_LARGE',
'APPDEPLOY_UNAUTHORIZED',
'APPDEPLOY_FORBIDDEN',
'APPDEPLOY_APP_RUNNER_AUTH_REQUIRED',
'APPDEPLOY_APP_RUNNER_INVALID_JOIN_TOKEN',
'APPDEPLOY_APP_RUNNER_INVALID_CONTROL_TOKEN',
'APPDEPLOY_WEB_APP_ACCESS_DENIED',
'APPDEPLOY_ENVIRONMENT_NOT_FOUND',
'APPDEPLOY_DEPLOYMENT_NOT_FOUND',
'APPDEPLOY_REVISION_NOT_FOUND',
@ -415,15 +407,15 @@ export const zError = z.object({
'APPDEPLOY_ACCESS_SUBJECT_NOT_FOUND',
'APPDEPLOY_API_KEY_NOT_FOUND',
'APPDEPLOY_SOURCE_VERSION_NOT_FOUND',
'APPDEPLOY_APP_LOG_NOT_FOUND',
'APPDEPLOY_WORKSPACE_NOT_FOUND',
'APPDEPLOY_APP_RUNNER_NOT_FOUND',
'APPDEPLOY_WORKFLOW_NOT_FOUND',
'APPDEPLOY_DEPLOYMENT_OPERATION_NOT_FOUND',
'APPDEPLOY_RUN_FILE_NOT_FOUND',
'APPDEPLOY_APPLICATION_UNAVAILABLE',
'APPDEPLOY_TARGET_ENVIRONMENT_REMOVED',
'APPDEPLOY_VERSION_UNAVAILABLE',
'APPDEPLOY_CONVERSATION_NOT_FOUND',
'APPDEPLOY_CHAT_MESSAGE_NOT_FOUND',
'APPDEPLOY_CONFLICT',
'APPDEPLOY_DEPLOYMENT_IN_PROGRESS',
'APPDEPLOY_ALREADY_UNDEPLOYED',
@ -462,10 +454,13 @@ export const zError = z.object({
'APPDEPLOY_ENVIRONMENT_CPU_POOL_EXHAUSTED',
'APPDEPLOY_RESOURCE_NOT_APPLICABLE_FOR_MODE',
'APPDEPLOY_ENVIRONMENT_CPU_POOL_BELOW_ALLOCATED',
'APPDEPLOY_CHAT_CONTEXT_TOO_LARGE',
'APPDEPLOY_FILE_GRANT_UNAVAILABLE',
'APPDEPLOY_APP_RUNNER_CONTROL_NOT_CONFIGURED',
'APPDEPLOY_RUNTIME_ASSIGNMENT_FAILED',
'APPDEPLOY_REVISION_TIMEOUT',
'APPDEPLOY_INTERNAL_ERROR',
'APPDEPLOY_RECEIPT_RETRY',
'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_AUTH_REJECTED',
'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_NAMESPACE_MISSING',
'APPDEPLOY_ENVIRONMENT_BOOTSTRAP_INSUFFICIENT_RBAC',
@ -486,6 +481,16 @@ export const zError = z.object({
detailCode: z.string().optional(),
})
export const zGetApplicationInteractionSummaryResponse = z.object({
totalCount: z.string(),
failedCount: z.string(),
lookbackStart: z.iso.datetime(),
})
export const zGetEnvironmentActivityResponse = z.object({
data: z.array(zEnvironmentActivity),
})
export const zGetEnvironmentCapabilitiesResponse = z.object({
backend: zEnvironmentBackend,
supportedModes: z.array(
@ -506,13 +511,13 @@ export const zGetWebAppAccessModeResponse = z.object({
accessMode: z.string().optional(),
})
export const zGetWebAppPermissionResponse = z.object({
result: z.boolean().optional(),
export const zGetWebAppLoginStatusResponse = z.object({
logged_in: z.boolean().optional(),
app_logged_in: z.boolean().optional(),
})
export const zGetWorkflowDeploymentOptionsResponse = z.object({
environment_variable_slots: z.array(zEnvironmentVariableSlot),
credential_slots: z.array(zCredentialSlot),
export const zGetWebAppPermissionResponse = z.object({
result: z.boolean().optional(),
})
export const zListAppEnvironmentsResponse = z.object({
@ -527,6 +532,18 @@ export const zListEnvironmentTriggersResponse = z.object({
data: z.array(zEnvironmentTrigger),
})
export const zMintServiceApiFileGrantRequest = z.object({
tenantId: z.string().optional(),
appId: z.string().optional(),
environmentId: z.string().optional(),
user: z.string().optional(),
})
export const zMintServiceApiFileGrantResponse = z.object({
grant: z.string().optional(),
expiresAt: z.string().optional(),
})
export const zNamedRef = z.object({
id: z.string(),
displayName: z.string(),
@ -534,7 +551,10 @@ export const zNamedRef = z.object({
export const zEnvironmentPoolShare = z.object({
app: zNamedRef,
isolatedCpu: z.number(),
isolatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
})
/**
@ -544,7 +564,11 @@ export const zEnvironmentPoolShare = z.object({
*/
export const zEnvironmentPoolComposition = z.object({
topApps: z.array(zEnvironmentPoolShare).optional(),
otherCpu: z.number().optional(),
otherCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
otherAppCount: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
@ -552,42 +576,37 @@ export const zEnvironmentPoolComposition = z.object({
.optional(),
})
export const zOperationApp = z.object({
id: z.string().optional(),
workspaceId: z.string().optional(),
displayName: z.string().optional(),
})
export const zOperator = z.object({
type: zOperatorType,
id: z.string(),
display_name: z.string(),
})
export const zApplicationInteraction = z.object({
id: z.string(),
timestamp: z.iso.datetime(),
workflowRunId: z.string(),
status: zApplicationInteractionStatus,
durationSeconds: z.number(),
totalTokens: z.string(),
workspace: zNamedRef,
environment: zNamedRef,
app: zNamedRef,
operator: zOperator.optional(),
invokeFrom: z.string(),
traceId: z.string(),
difyTraceId: z.string(),
deploymentVersionId: z.string(),
error: z.string().optional(),
body: z.string().optional(),
attributesJson: z.string().optional(),
resourceAttributesJson: z.string().optional(),
})
export const zGetApplicationInteractionResponse = z.object({
interaction: zApplicationInteraction,
})
export const zPrepareAppDeletionRequest = z.object({
tenantId: z.string().optional(),
appId: z.string().optional(),
})
export const zRenameServiceApiConversationRequest = z.object({
conversationId: z.string(),
user: z.string(),
name: z.string().optional(),
autoGenerate: z.boolean().optional(),
})
export const zRenameWebAppConversationRequest = z.object({
appCode: z.string(),
conversationId: z.string(),
name: z.string().optional(),
autoGenerate: z.boolean().optional(),
})
export const zResolveApiTokenRouteRequest = z.object({
token: z.string().optional(),
})
@ -601,17 +620,14 @@ export const zResolveApiTokenRouteResponse = z.object({
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
environmentStatus: zEnvironmentStatus.optional(),
appId: z.string().optional(),
tenantId: z.string().optional(),
deploymentId: z.string().optional(),
servingRevisionId: z.string().optional(),
deploymentStatus: zDeploymentStatus.optional(),
revoked: z.boolean().optional(),
unavailableReason: z.string().optional(),
targetKind: zRouteTargetKind.optional(),
directUpstream: z.string().optional(),
deploymentGeneration: z.string().optional(),
assignmentGeneration: z.string().optional(),
decision: z.string().optional(),
})
export const zResolveWebAppRouteRequest = z.object({
@ -629,18 +645,18 @@ export const zResolveWebAppRouteResponse = z.object({
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
environmentStatus: zEnvironmentStatus.optional(),
appId: z.string().optional(),
tenantId: z.string().optional(),
deploymentId: z.string().optional(),
servingRevisionId: z.string().optional(),
deploymentStatus: zDeploymentStatus.optional(),
unavailableReason: z.string().optional(),
targetKind: zRouteTargetKind.optional(),
directUpstream: z.string().optional(),
deploymentGeneration: z.string().optional(),
endUserId: z.string().optional(),
authType: z.string().optional(),
assignmentGeneration: z.string().optional(),
userId: z.string().optional(),
userFrom: z.string().optional(),
userAuthType: z.string().optional(),
fileGrant: z.string().optional(),
fileGrantExpiresAt: z.string().optional(),
})
export const zRetryEnvironmentBootstrapRequest = z.object({
@ -667,8 +683,10 @@ export const zEnvironment = z.object({
statusMessage: z.string(),
lastError: zError.optional(),
namespace: z.string().optional(),
managedBy: zEnvironmentManagedBy.optional(),
cpuPool: z.number(),
cpuPoolMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
memory: zRunnerMemory.optional(),
@ -693,7 +711,10 @@ export const zRetryEnvironmentBootstrapResponse = z.object({
* in an isolated environment; elsewhere the runner belongs to the environment.
*/
export const zRunnerSizing = z.object({
isolatedCpu: z.number(),
isolatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
memory: zRunnerMemory,
})
@ -707,6 +728,20 @@ export const zSimpleAccount = z.object({
email: z.string().optional(),
})
export const zSourceVersionDeploymentEnvironment = z.object({
id: z.string().optional(),
name: z.string().optional(),
})
export const zSourceVersionDeployment = z.object({
sourceVersionId: z.string().optional(),
environments: z.array(zSourceVersionDeploymentEnvironment).optional(),
})
export const zBatchGetSourceVersionDeploymentsResponse = z.object({
items: z.array(zSourceVersionDeployment).optional(),
})
export const zTestConnectionRequest = z.object({
environmentId: z.string().optional(),
})
@ -731,29 +766,30 @@ export const zUnsupportedNodeProvider = z.object({
provider_name: z.string(),
})
export const zUnsupportedNode = z.object({
id: z.string(),
type: z.string(),
title: z.string(),
provider: zUnsupportedNodeProvider.optional(),
})
export const zPrecheckWorkflowDeploymentResponse = z.object({
unsupported_nodes: z.array(zUnsupportedNode),
})
export const zUpdateEnvironmentDeployedAppResourcesRequest = z.object({
environmentId: z.string(),
deploymentId: z.string(),
isolatedCpu: z.number(),
isolatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
maxMemoryMib: z.string().optional(),
})
export const zUpdateEnvironmentDeployedAppResourcesResponse = z.object({
deploymentId: z.string(),
isolatedCpu: z.number(),
allocatedCpuCount: z.number(),
poolCpuCount: z.number(),
isolatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
allocatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
poolCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
memory: zRunnerMemory,
})
@ -761,7 +797,11 @@ export const zUpdateEnvironmentRequest = z.object({
environmentId: z.string().optional(),
displayName: z.string().optional(),
description: z.string().optional(),
cpuPool: z.number().optional(),
cpuPoolMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
maxMemoryMib: z.string().optional(),
})
@ -769,25 +809,79 @@ export const zUpdateEnvironmentResponse = z.object({
environment: zEnvironment,
})
export const zWorkflowDeploymentEnvironment = z.object({
id: z.string().optional(),
name: z.string().optional(),
export const zUpdateServiceApiConversationVariableRequest = z.object({
conversationId: z.string(),
variableId: z.string(),
user: z.string(),
value: zGoogleProtobufValue,
})
export const zSourceVersionDeployment = z.object({
sourceVersionId: z.string().optional(),
environments: z.array(zWorkflowDeploymentEnvironment).optional(),
})
export const zBatchGetSourceVersionDeploymentsResponse = z.object({
items: z.array(zSourceVersionDeployment).optional(),
export const zWorkflowEnvironmentVariableInputGroup = z.object({
workflow_id: z.string(),
environment_variables: z.array(zEnvironmentVariableInput),
})
export const zWorkflowDeploymentInput = z.object({
environment_variables: z.array(zEnvironmentVariableInput).optional(),
environment_variable_groups: z.array(zWorkflowEnvironmentVariableInputGroup),
credentials: z.array(zCredentialSelectionInput).optional(),
})
export const zWorkflowReference = z.object({
app_id: z.string(),
workflow_id: z.string(),
name: z.string(),
icon: z.string(),
icon_background: z.string(),
icon_type: z.string(),
icon_url: z.string().optional(),
})
export const zWorkflowPath = z.object({
workflows: z.array(zWorkflowReference),
})
export const zWorkflowAsToolDependency = z.object({
paths: z.array(zWorkflowPath),
})
export const zCredentialSlot = z.object({
provider_id: z.string(),
category: zPluginCategory,
candidates: z.array(zCredentialCandidate),
last_deployed_credential_id: z.string().optional(),
icon: z.string().optional(),
icon_dark: z.string().optional(),
workflow_as_tool_dependency: zWorkflowAsToolDependency.optional(),
})
export const zUnsupportedNode = z.object({
id: z.string(),
type: z.string(),
title: z.string(),
provider: zUnsupportedNodeProvider.optional(),
workflow_as_tool_dependency: zWorkflowAsToolDependency.optional(),
})
export const zPrecheckWorkflowDeploymentResponse = z.object({
unsupported_nodes: z.array(zUnsupportedNode),
})
export const zWorkflowAsToolSource = z.object({
workflow: zWorkflowReference,
paths: z.array(zWorkflowPath),
})
export const zEnvironmentVariableGroup = z.object({
from_app: zWorkflowReference.optional(),
from_workflow_as_tool: zWorkflowAsToolSource.optional(),
environment_variable_slots: z.array(zEnvironmentVariableSlot),
})
export const zGetWorkflowDeploymentOptionsResponse = z.object({
environment_variable_groups: z.array(zEnvironmentVariableGroup),
credential_slots: z.array(zCredentialSlot),
})
export const zWorkflowVersion = z.object({
version: z.string().optional(),
marked_name: z.string().optional(),
@ -801,7 +895,22 @@ export const zWorkflowVersion = z.object({
created_at: z.number().optional(),
created_by: zSimpleAccount.optional(),
dsl_hash: z.string().optional(),
deleted: z.boolean().optional(),
})
export const zApplicationInteraction = z.object({
id: z.string(),
timestamp: z.iso.datetime(),
status: zApplicationInteractionStatus,
durationSeconds: z.number(),
totalTokens: z.string(),
workspace: zNamedRef,
environment: zNamedRef,
app: zNamedRef,
operator: zNamedRef.optional(),
source: zApplicationInteractionSource,
version: zWorkflowVersion.optional(),
traceId: z.string().optional(),
error: z.string().optional(),
})
export const zDeploymentOperation = z.object({
@ -824,13 +933,19 @@ export const zEnvironmentDeployedApp = z.object({
deploymentId: z.string(),
workspace: zNamedRef,
app: zNamedRef,
status: zEnvironmentDeployedAppStatus,
runtimeState: zRuntimeState,
currentVersion: zWorkflowVersion.optional(),
deployedAt: z.iso.datetime().optional(),
deployedBy: zOperator.optional(),
latestAttempt: zEnvironmentDeployedAppAttempt.optional(),
sizing: zRunnerSizing.optional(),
occupiesPool: z.boolean().optional(),
recentInvocationCount: z.string().optional(),
versionsBehind: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
})
export const zEnvironmentDeploymentOperation = z.object({
@ -843,7 +958,7 @@ export const zEnvironmentDeploymentOperation = z.object({
})
export const zEnvironmentDeploymentState = z.object({
status: zDeploymentStatus,
runtimeState: zRuntimeState,
current_version: zWorkflowVersion.optional(),
versions_behind: z
.int()
@ -861,14 +976,16 @@ export const zEnvironmentDeployment = z.object({
access: zEnvironmentAccess,
})
export const zGetDeploymentOperationResponse = z.object({
operation: zDeploymentOperation,
})
export const zGetEnvironmentDeploymentResponse = z.object({
environment_deployment: zEnvironmentDeployment,
})
export const zListApplicationInteractionsResponse = z.object({
data: z.array(zApplicationInteraction),
nextPageToken: z.string().optional(),
previousPageToken: z.string().optional(),
})
export const zListEnvironmentDeploymentsResponse = z.object({
environment_deployments: z.array(zEnvironmentDeployment),
})
@ -899,13 +1016,8 @@ export const zPagination = z.object({
.optional(),
})
export const zListApplicationInteractionsResponse = z.object({
data: z.array(zApplicationInteraction),
pagination: zPagination,
})
export const zListAppsResponse = z.object({
data: z.array(zDashboardApp),
export const zListApplicationInteractionAppsResponse = z.object({
data: z.array(zNamedRef),
pagination: zPagination,
})
@ -916,7 +1028,6 @@ export const zListDeploymentOperationsResponse = z.object({
export const zListEnvironmentDeployedAppsResponse = z.object({
data: z.array(zEnvironmentDeployedApp),
summary: zEnvironmentDeployedAppSummary,
pagination: zPagination,
})
@ -925,10 +1036,17 @@ export const zListEnvironmentsResponse = z.object({
pagination: zPagination,
})
export const zListOperationAppsResponse = z.object({
data: z.array(zOperationApp),
pagination: zPagination,
})
export const zDeleteEnvironmentApiKeyResponseWritable = z.record(z.string(), z.unknown())
export const zDeleteEnvironmentResponseWritable = z.record(z.string(), z.unknown())
export const zDeleteServiceApiConversationResponseWritable = z.record(z.string(), z.unknown())
export const zEnvironmentMcpServerWritable = z.record(z.string(), z.unknown())
export const zEnvironmentTriggerWritable = z.record(z.string(), z.unknown())
@ -951,8 +1069,10 @@ export const zEnvironmentWritable = z.object({
statusMessage: z.string(),
lastError: zError.optional(),
namespace: z.string().optional(),
managedBy: zEnvironmentManagedBy.optional(),
cpuPool: z.number(),
cpuPoolMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
createdAt: z.iso.datetime(),
updatedAt: z.iso.datetime(),
memory: zRunnerMemoryWritable.optional(),
@ -982,7 +1102,10 @@ export const zRetryEnvironmentBootstrapResponseWritable = z.object({
* in an isolated environment; elsewhere the runner belongs to the environment.
*/
export const zRunnerSizingWritable = z.object({
isolatedCpu: z.number(),
isolatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
memory: zRunnerMemoryWritable,
})
@ -990,26 +1113,40 @@ export const zEnvironmentDeployedAppWritable = z.object({
deploymentId: z.string(),
workspace: zNamedRef,
app: zNamedRef,
status: zEnvironmentDeployedAppStatus,
runtimeState: zRuntimeState,
currentVersion: zWorkflowVersion.optional(),
deployedAt: z.iso.datetime().optional(),
deployedBy: zOperator.optional(),
latestAttempt: zEnvironmentDeployedAppAttempt.optional(),
sizing: zRunnerSizingWritable.optional(),
occupiesPool: z.boolean().optional(),
recentInvocationCount: z.string().optional(),
versionsBehind: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' })
.optional(),
})
export const zListEnvironmentDeployedAppsResponseWritable = z.object({
data: z.array(zEnvironmentDeployedAppWritable),
summary: zEnvironmentDeployedAppSummary,
pagination: zPagination,
})
export const zUpdateEnvironmentDeployedAppResourcesResponseWritable = z.object({
deploymentId: z.string(),
isolatedCpu: z.number(),
allocatedCpuCount: z.number(),
poolCpuCount: z.number(),
isolatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
allocatedCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
poolCpuMillicores: z
.int()
.min(-2147483648, { error: 'Invalid value: Expected int32 to be >= -2147483648' })
.max(2147483647, { error: 'Invalid value: Expected int32 to be <= 2147483647' }),
memory: zRunnerMemoryWritable,
})

View File

@ -166,7 +166,9 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
(routeAppDetail.mode === AppModeEnum.AGENT || !appACLCapabilities.canAccessConfig)) ||
(isAccessPointPath && !appACLCapabilities.canViewAccessPoint) ||
(isDeployPath &&
(routeAppDetail.mode !== AppModeEnum.WORKFLOW || !appACLCapabilities.canDeploy))
((routeAppDetail.mode !== AppModeEnum.WORKFLOW &&
routeAppDetail.mode !== AppModeEnum.ADVANCED_CHAT) ||
!appACLCapabilities.canDeploy))
) {
router.replace(
getRedirectionPath(routeAppDetail, {

View File

@ -46,6 +46,8 @@ describe('Splash', () => {
beforeEach(() => {
vi.clearAllMocks()
webAppState.shareCode = 'share-app'
webAppState.webAppAccessMode = 'public'
webAppState.embeddedUserId = 'embedded-user'
navigationMocks.pathname = '/chatbot/share-app'
window.history.replaceState({}, '', navigationMocks.pathname)
navigationMocks.searchParams = new URLSearchParams({
@ -140,6 +142,74 @@ describe('Splash', () => {
expect(await screen.findByText('share.common.appUnavailable')).toBeInTheDocument()
})
it('should redirect an unauthenticated sso verified environment to the sign-in page', async () => {
navigationMocks.searchParams = new URLSearchParams()
navigationMocks.pathname = '/environment/chat/environment-app'
window.history.replaceState({}, '', navigationMocks.pathname)
webAppState.shareCode = 'environment-app'
webAppState.webAppAccessMode = 'sso_verified'
render(
<Splash>
<div>share application</div>
</Splash>,
)
await waitFor(() => {
expect(navigationMocks.replace).toHaveBeenCalledWith(
'/webapp-signin?redirect_url=%2Fenvironment%2Fchat%2Fenvironment-app',
)
})
expect(fetchAccessTokenMock).not.toHaveBeenCalled()
expect(screen.queryByText('share application')).not.toBeInTheDocument()
})
it('should redirect an sso verified environment when its passport cannot be issued', async () => {
navigationMocks.searchParams = new URLSearchParams({
query: 'keep-me',
web_sso_token: 'expired-token',
})
navigationMocks.pathname = '/environment/chat/environment-app'
window.history.replaceState({}, '', navigationMocks.pathname)
webAppState.shareCode = 'environment-app'
webAppState.webAppAccessMode = 'sso_verified'
webAppAuthMocks.webAppLoginStatus.mockResolvedValue({
userLoggedIn: true,
appLoggedIn: false,
})
fetchAccessTokenMock.mockRejectedValue(new Response(null, { status: 401 }))
render(
<Splash>
<div>share application</div>
</Splash>,
)
await waitFor(() => {
expect(navigationMocks.replace).toHaveBeenCalledWith(
'/webapp-signin?redirect_url=%2Fenvironment%2Fchat%2Fenvironment-app%3Fquery%3Dkeep-me',
)
})
expect(webAppAuthMocks.webAppLogout).toHaveBeenCalledWith({
kind: 'environment',
code: 'environment-app',
})
expect(screen.queryByText('share application')).not.toBeInTheDocument()
})
it('should keep the existing authentication surface for an ordinary Web App', async () => {
navigationMocks.searchParams = new URLSearchParams()
render(
<Splash>
<div>share application</div>
</Splash>,
)
expect(await screen.findByText('share application')).toBeInTheDocument()
expect(navigationMocks.replace).not.toHaveBeenCalled()
})
it('should expose the unavailable-state action as a button', () => {
navigationMocks.searchParams = new URLSearchParams({
code: '404',

View File

@ -4,11 +4,13 @@ import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
isWebAppSigninPath,
navigateAfterWebAppLogin,
resolveWebAppLoginRedirect,
} from '@/app/(shareLayout)/webapp-signin/login-redirect'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { useWebAppStore } from '@/context/web-app-context'
import { AccessMode } from '@/models/access-control'
import { usePathname, useRouter, useSearchParams } from '@/next/navigation'
import { fetchAccessToken } from '@/service/share'
import { resolveWebAppAddress } from '@/service/webapp-address'
@ -76,11 +78,25 @@ function Splash({ children }: PropsWithChildren) {
if (tokenFromUrl) setWebAppAccessToken(tokenFromUrl)
const redirectOrFinish = () => {
if (loginRedirect) replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
if (loginRedirect) navigateAfterWebAppLogin(loginRedirect, router.replace, basePath)
else setIsLoading(false)
}
const proceedToAuth = () => {
const proceedToAuth = (authenticationRequired: boolean) => {
if (
authenticationRequired &&
address.kind === 'environment' &&
webAppAccessMode !== AccessMode.PUBLIC &&
!isSigninRoute
) {
const redirectSearchParams = new URLSearchParams(searchParams)
redirectSearchParams.delete('web_sso_token')
const redirectSearch = redirectSearchParams.toString()
const redirectTarget = redirectSearch ? `${pathname}?${redirectSearch}` : pathname
const signinSearchParams = new URLSearchParams({ redirect_url: redirectTarget })
router.replace(`/webapp-signin?${signinSearchParams.toString()}`)
return
}
setIsLoading(false)
}
@ -88,13 +104,12 @@ function Splash({ children }: PropsWithChildren) {
// if access mode is public, user login is always true, but the app login(passport) may be expired
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(
effectiveShareCode,
webAppAccessMode,
embeddedUserId || undefined,
)
if (userLoggedIn && appLoggedIn) {
redirectOrFinish()
} else if (!userLoggedIn && !appLoggedIn) {
proceedToAuth()
proceedToAuth(true)
} else if (!userLoggedIn && appLoggedIn) {
redirectOrFinish()
} else if (userLoggedIn && !appLoggedIn) {
@ -112,7 +127,7 @@ function Splash({ children }: PropsWithChildren) {
return
}
await webAppLogout(address)
proceedToAuth()
proceedToAuth(error instanceof Response && error.status === 401)
}
}
})()

View File

@ -0,0 +1,14 @@
'use client'
import * as React from 'react'
import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history'
import AuthenticatedLayout from '../../../components/authenticated-layout'
const EnvironmentChat = () => {
return (
<AuthenticatedLayout>
<ChatWithHistoryWrap />
</AuthenticatedLayout>
)
}
export default React.memo(EnvironmentChat)

View File

@ -1,4 +1,4 @@
import { resolveWebAppLoginRedirect } from '../login-redirect'
import { navigateAfterWebAppLogin, resolveWebAppLoginRedirect } from '../login-redirect'
describe('resolveWebAppLoginRedirect', () => {
// Covers the canonical relative redirect shape used by share applications.
@ -27,14 +27,14 @@ describe('resolveWebAppLoginRedirect', () => {
it('should resolve an environment workflow redirect', () => {
const result = resolveWebAppLoginRedirect(
'/env/workflow/workflow-app',
'/environment/workflow/workflow-app',
'https://self-hosted.example.com',
)
expect(result).toEqual({
appCode: 'workflow-app',
address: { kind: 'environment', code: 'workflow-app' },
target: { kind: 'internal', href: '/env/workflow/workflow-app' },
target: { kind: 'internal', href: '/environment/workflow/workflow-app' },
})
})
})
@ -84,3 +84,68 @@ describe('resolveWebAppLoginRedirect', () => {
})
})
})
describe('navigateAfterWebAppLogin', () => {
const routerReplace = vi.fn()
const locationReplace = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('location', {
...window.location,
replace: locationReplace,
} as unknown as Location)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('reloads the document after an environment login', () => {
navigateAfterWebAppLogin(
{
appCode: 'environment-app',
address: { kind: 'environment', code: 'environment-app' },
target: { kind: 'internal', href: '/environment/chat/environment-app' },
},
routerReplace,
'',
)
expect(locationReplace).toHaveBeenCalledWith('/environment/chat/environment-app')
expect(routerReplace).not.toHaveBeenCalled()
})
it.each([
['/environment/chat/environment-app', '/console/environment/chat/environment-app'],
['/console/environment/chat/environment-app', '/console/environment/chat/environment-app'],
])('keeps the deployment base path when reloading %s', (href, expected) => {
navigateAfterWebAppLogin(
{
appCode: 'environment-app',
address: { kind: 'environment', code: 'environment-app' },
target: { kind: 'internal', href },
},
routerReplace,
'/console',
)
expect(locationReplace).toHaveBeenCalledWith(expected)
expect(routerReplace).not.toHaveBeenCalled()
})
it('keeps client navigation for an ordinary Web App login', () => {
navigateAfterWebAppLogin(
{
appCode: 'share-app',
address: { kind: 'default', code: 'share-app' },
target: { kind: 'internal', href: '/chatbot/share-app' },
},
routerReplace,
'',
)
expect(routerReplace).toHaveBeenCalledWith('/chatbot/share-app')
expect(locationReplace).not.toHaveBeenCalled()
})
})

View File

@ -84,7 +84,7 @@ describe('WebSSOForm environment access modes', () => {
beforeEach(() => {
vi.clearAllMocks()
navigationMocks.searchParams = new URLSearchParams({
redirect_url: '/env/workflow/workflow-app',
redirect_url: '/environment/workflow/workflow-app',
})
})
@ -105,7 +105,7 @@ describe('WebSSOForm environment access modes', () => {
await waitFor(() => {
expect(serviceMocks.fetchWebSAMLSSOUrl).toHaveBeenCalledWith(
'workflow-app',
'/env/workflow/workflow-app',
'/environment/workflow/workflow-app',
)
})
expect(navigationMocks.push).toHaveBeenCalledWith('https://idp.example/authorize')

View File

@ -3,10 +3,12 @@ import type { FormEvent } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { Input } from '@langgenius/dify-ui/input'
import { toast } from '@langgenius/dify-ui/toast'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import {
navigateAfterWebAppLogin,
resolveWebAppLoginRedirect,
} from '@/app/(shareLayout)/webapp-signin/login-redirect'
import Countdown from '@/app/components/signin/countdown'
import { useLocale } from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
@ -67,7 +69,7 @@ export default function CheckCode() {
userId: embeddedUserId || undefined,
})
setWebAppPassport(loginRedirect.address, access_token)
replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
navigateAfterWebAppLogin(loginRedirect, router.replace, basePath)
}
} catch (error) {
console.error(error)
@ -107,7 +109,10 @@ export default function CheckCode() {
return (
<div className="flex w-100 flex-col gap-3">
<div className="inline-flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" />
<span
aria-hidden
className="i-ri-mail-send-fill size-6 text-2xl text-text-accent-light-mode-only"
/>
</div>
<div className="pt-2 pb-4">
<h1 className="title-4xl-semi-bold text-text-primary">
@ -160,7 +165,7 @@ export default function CheckCode() {
className="flex h-9 cursor-pointer appearance-none items-center justify-center text-text-tertiary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span className="bg-background-default-dimm inline-block rounded-full p-1">
<RiArrowLeftLine aria-hidden size={12} />
<span aria-hidden className="i-ri-arrow-left-line block size-3" />
</span>
<span className="ml-2 system-xs-regular">{t(($) => $.back, { ns: 'login' })}</span>
</button>

View File

@ -8,7 +8,10 @@ import { InputGroup, InputGroupAddon, InputGroupInput } from '@langgenius/dify-u
import { toast } from '@langgenius/dify-ui/toast'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { resolveWebAppLoginRedirect } from '@/app/(shareLayout)/webapp-signin/login-redirect'
import {
navigateAfterWebAppLogin,
resolveWebAppLoginRedirect,
} from '@/app/(shareLayout)/webapp-signin/login-redirect'
import { emailRegex } from '@/config'
import { useWebAppStore } from '@/context/web-app-context'
import Link from '@/next/link'
@ -84,7 +87,7 @@ export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAut
userId: embeddedUserId || undefined,
})
setWebAppPassport(loginRedirect.address, access_token)
replaceLoginRedirect(loginRedirect.target, router.replace, basePath)
navigateAfterWebAppLogin(loginRedirect, router.replace, basePath)
} else {
toast.error(res.data)
}

View File

@ -2,6 +2,7 @@ import type { WebAppAddress } from '@/service/webapp-address'
import type { LoginRedirectTarget } from '@/utils/login-redirect'
import { parseWebAppAddress } from '@/service/webapp-address'
import { resolveLoginRedirectTarget } from '@/utils/login-redirect'
import { replaceLoginRedirect } from '@/utils/login-redirect.client'
const INTERNAL_PATH_PARSE_BASE = 'https://login-redirect.invalid'
@ -11,6 +12,34 @@ export type WebAppLoginRedirect = {
target: LoginRedirectTarget
}
function addBasePathOnce(href: string, basePath: string) {
const normalizedBasePath = basePath === '/' ? '' : basePath.replace(/\/+$/, '')
if (!normalizedBasePath) return href
const url = new URL(href, INTERNAL_PATH_PARSE_BASE)
if (url.pathname === normalizedBasePath || url.pathname.startsWith(`${normalizedBasePath}/`))
return href
return `${normalizedBasePath}${url.pathname}${url.search}${url.hash}`
}
export function navigateAfterWebAppLogin(
loginRedirect: WebAppLoginRedirect,
routerReplace: (href: string) => void,
basePath: string,
) {
if (loginRedirect.address.kind === 'environment') {
const href =
loginRedirect.target.kind === 'internal'
? addBasePathOnce(loginRedirect.target.href, basePath)
: loginRedirect.target.href
globalThis.location.replace(href)
return
}
replaceLoginRedirect(loginRedirect.target, routerReplace, basePath)
}
export function isWebAppSigninPath(pathname: string): boolean {
let candidate = pathname

View File

@ -210,20 +210,23 @@ describe('AppDetailSection', () => {
).not.toBeInTheDocument()
})
it('should render deploy navigation with app deploy ACL regardless of the legacy workspace role', () => {
// Arrange
mockAppMode = 'workflow'
mockAppPermissionKeys = [AppACLPermission.Deploy]
it.each(['workflow', 'advanced-chat'])(
'should render deploy navigation for a %s app with app deploy ACL regardless of the legacy workspace role',
(mode) => {
// Arrange
mockAppMode = mode
mockAppPermissionKeys = [AppACLPermission.Deploy]
// Act
render(<AppDetailSection />)
// Act
render(<AppDetailSection />)
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
'href',
'/app/app-1/deploy',
)
})
// Assert
expect(screen.getByRole('link', { name: 'common.appMenus.deploy' })).toHaveAttribute(
'href',
'/app/app-1/deploy',
)
},
)
it.each([
{

View File

@ -98,7 +98,6 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
const appId = appDetail.id
const isWorkflowApp =
appDetail.mode === AppModeEnum.WORKFLOW || appDetail.mode === AppModeEnum.ADVANCED_CHAT
const supportsAppDeploy = appDetail.mode === AppModeEnum.WORKFLOW
const supportsAnnotations =
appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.COMPLETION
const supportsResourceAccess = appDetail.mode !== AppModeEnum.AGENT
@ -130,7 +129,7 @@ const AppDetailSection = ({ expand = true }: AppDetailSectionProps) => {
},
]
: []),
...(supportsAppDeploy && appACLCapabilities.canDeploy
...(isWorkflowApp && appACLCapabilities.canDeploy
? [
{
name: t(($) => $['appMenus.deploy'], { ns: 'common' }),

View File

@ -1,7 +1,8 @@
import type { AccessPointStatus } from '../shared/access-point-status'
import type { AccessPointStatus } from '@/app/components/base/access-point/status'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { render } from '@/test/console/render'
import { AccessPointCard } from '../shared/access-point-card'
describe('AccessPointCard', () => {
it('marks the card when it is the highlighted access point', () => {
@ -11,6 +12,7 @@ describe('AccessPointCard', () => {
description="Web application access"
icon="i-ri-robot-2-line"
status="inService"
statusLabel="In service"
highlighted
>
Access URL
@ -39,6 +41,7 @@ describe('AccessPointCard', () => {
description="Web application access"
icon="i-ri-robot-2-line"
status={status}
statusLabel={label}
onEnabledChange={vi.fn()}
>
Access URL
@ -51,4 +54,34 @@ describe('AccessPointCard', () => {
else expect(card).not.toHaveAttribute('aria-busy')
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('keeps an unavailable switch focusable and explains why it is disabled', async () => {
const user = userEvent.setup()
const onEnabledChange = vi.fn()
render(
<AccessPointCard
title="Web App"
description="Web application access"
icon="i-ri-robot-2-line"
status="disabled"
statusLabel="Disabled"
switchDisabled
switchDisabledReason="Publish first"
switchLabel="Toggle Web App"
onEnabledChange={onEnabledChange}
>
Access URL
</AccessPointCard>,
)
const accessSwitch = screen.getByRole('switch', { name: 'Toggle Web App' })
expect(accessSwitch).toHaveAttribute('aria-disabled', 'true')
await user.tab()
expect(accessSwitch).toHaveFocus()
expect(await screen.findByRole('tooltip')).toHaveTextContent('Publish first')
await user.click(accessSwitch)
expect(onEnabledChange).not.toHaveBeenCalled()
})
})

View File

@ -1,6 +1,7 @@
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
import { render } from '@/test/console/render'
import { AccessPointUrl } from '../shared/access-point-url'
const endpointProps = {
label: 'Access URL',
@ -34,6 +35,22 @@ describe('AccessPointUrl', () => {
expect(openLink).toHaveAttribute('rel', 'noopener noreferrer')
})
it('explains why the open action is disabled', async () => {
const user = userEvent.setup()
render(
<AccessPointUrl
{...endpointProps}
enabled={false}
showOpen
openLabel="Open"
openDisabledReason="Publish first"
/>,
)
await user.hover(screen.getByRole('button', { name: 'Open' }))
expect(await screen.findByRole('tooltip')).toHaveTextContent('Publish first')
})
it('shows an unavailable endpoint without replacing it with a loading skeleton', () => {
render(<AccessPointUrl {...endpointProps} enabled={false} unavailable />)

View File

@ -57,7 +57,6 @@ vi.mock('@/service/use-workflow', () => ({
vi.mock('../shared/use-access-point-actions', () => ({
useAccessPointActions: () => ({
handleAppStateChanged: vi.fn(),
handleResult: vi.fn(),
refreshAppDetail: vi.fn(),
saveSiteConfig: vi.fn(),

View File

@ -1,4 +1,4 @@
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point'
import { screen, within } from '@testing-library/react'
import { render } from '@/test/console/render'
import { DeployedEnvironmentAccessPoints } from '../deployed-environment-access-points'

View File

@ -1,6 +1,7 @@
import type { ReactElement } from 'react'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
@ -84,6 +85,8 @@ vi.mock('@/features/system-features/client', () => ({
}),
}))
let mockAppMode = 'workflow'
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
@ -93,11 +96,13 @@ vi.mock('@/app/components/app/store', () => ({
selector({
appDetail: {
id: 'app-1',
get mode() {
return mockAppMode
},
icon: '🤖',
icon_background: '#FFEAD5',
icon_type: 'emoji',
icon_url: null,
mode: 'workflow',
site: {
access_token: 'built-in-code',
app_base_url: 'https://built-in.example.test',
@ -195,6 +200,17 @@ function renderCard(ui: ReactElement) {
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
describe('environment access point cards', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -227,6 +243,25 @@ describe('environment access point cards', () => {
})
})
it('sends a chatflow app to the chat web app shell', async () => {
mockAppMode = 'advanced-chat'
renderCard(
<EnvironmentWebAppCard
appId="app-1"
environmentId="staging"
canManageAccessPoint
canReleaseAndVersion
/>,
)
expect(await screen.findByText(/environment\/chat\/site-code/)).toHaveTextContent(
'https://site.example.test/environment/chat/site-code',
)
mockAppMode = 'workflow'
})
it('renders the real environment Web app URL and workflow actions without Embed', async () => {
renderCard(
<EnvironmentWebAppCard
@ -237,8 +272,8 @@ describe('environment access point cards', () => {
/>,
)
expect(await screen.findByText(/env\/workflow\/site-code/)).toHaveTextContent(
'https://site.example.test/env/workflow/site-code',
expect(await screen.findByText(/environment\/workflow\/site-code/)).toHaveTextContent(
'https://site.example.test/environment/workflow/site-code',
)
expect(
await screen.findByRole('button', {
@ -292,6 +327,23 @@ describe('environment access point cards', () => {
).not.toBeInTheDocument()
})
it('does not announce the access control placeholder as loading after the Site query fails', async () => {
mocks.getSite.mockRejectedValueOnce(new Error('Site unavailable'))
renderCard(
<EnvironmentWebAppCard
appId="app-1"
environmentId="staging"
canManageAccessPoint
canReleaseAndVersion
/>,
)
const card = screen.getByRole('region', { name: /webApp\.title/ })
await screen.findAllByText('deployments.health.ENVIRONMENT_STATUS_FAILED')
expect(within(card).queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument()
})
it('uses environment Site mutations for status and URL reset, and opens its access container', async () => {
const user = userEvent.setup()
renderCard(
@ -339,6 +391,49 @@ describe('environment access point cards', () => {
})
})
it('optimistically serializes environment Web app changes without a success toast', async () => {
const user = userEvent.setup()
const firstToggle = createDeferredPromise<typeof site>()
const secondToggle = createDeferredPromise<typeof site>()
mocks.updateSite
.mockReturnValueOnce(firstToggle.promise)
.mockReturnValueOnce(secondToggle.promise)
renderCard(
<EnvironmentWebAppCard
appId="app-1"
environmentId="staging"
canManageAccessPoint
canReleaseAndVersion
/>,
)
const accessSwitch = await screen.findByRole('switch')
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
expect(mocks.updateSite).toHaveBeenCalledTimes(1)
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
expect(mocks.updateSite).toHaveBeenCalledTimes(1)
firstToggle.resolve({ ...site, enabled: false })
await waitFor(() => {
expect(mocks.updateSite).toHaveBeenCalledTimes(2)
})
expect(mocks.updateSite.mock.calls[1]?.[0]).toEqual({
body: { enabled: true },
params: environmentParams,
})
secondToggle.resolve({ ...site, enabled: true })
await screen.findByRole('link', { name: /studio\.accessPoint\.open/ })
expect(toast.success).not.toHaveBeenCalled()
})
it('opens Customize and Settings with environment endpoint data', async () => {
const user = userEvent.setup()
renderCard(
@ -350,7 +445,7 @@ describe('environment access point cards', () => {
/>,
)
await screen.findByText(/env\/workflow\/site-code/)
await screen.findByText(/environment\/workflow\/site-code/)
await user.click(screen.getByRole('button', { name: /customize\.entry/ }))
expect(screen.getByRole('dialog', { name: 'environment customize' })).toHaveTextContent(
'https://api.example.test/v1',
@ -452,6 +547,29 @@ describe('environment access point cards', () => {
})
})
it('rolls back a failed environment Service API change and shows only an error toast', async () => {
const user = userEvent.setup()
const toggle = createDeferredPromise<typeof api>()
mocks.updateApi.mockReturnValueOnce(toggle.promise)
renderCard(
<EnvironmentServiceApiCard appId="app-1" environmentId="staging" canManageAccessPoint />,
)
await screen.findByText(api.base_url)
const accessSwitch = screen.getByRole('switch')
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
toggle.reject(new Error('request failed'))
await waitFor(() => {
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
})
expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
expect(toast.success).not.toHaveBeenCalled()
})
it('keeps environment API keys and external documentation available when the API is stopped', async () => {
mocks.getApi.mockResolvedValue({
...api,

View File

@ -1,6 +1,6 @@
import type { AppEnvironment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/access-point'
import type { AccessPoint as AccessPointType } from '@/app/components/app/deploy/utils/access-point'
import { EnvironmentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

View File

@ -1,8 +1,11 @@
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import { screen } from '@testing-library/react'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
import { MCPAccessPointCard } from '../built-in-access-points/mcp-card'
@ -17,6 +20,30 @@ const mocks = vi.hoisted(() => ({
updateServer: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
server: {
put: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateServer,
...options,
}),
},
},
},
},
},
}))
vi.mock('@/service/use-tools', () => ({
useInvalidateMCPServerDetail: () => mocks.invalidateServerDetail,
useMCPServerDetail: () => mocks.serverDetail,
@ -24,10 +51,6 @@ vi.mock('@/service/use-tools', () => ({
isPending: false,
mutateAsync: mocks.refreshServerCode,
}),
useUpdateMCPServer: () => ({
isPending: false,
mutateAsync: mocks.updateServer,
}),
}))
vi.mock('@/app/components/tools/mcp/mcp-server-modal', () => ({
@ -74,11 +97,39 @@ const publishedWorkflow = {
},
} as unknown as PublishedWorkflow
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
function renderCard(cardAppInfo: AccessPointAppInfo = appInfo, workflow?: PublishedWorkflow) {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<MCPAccessPointCard
appInfo={cardAppInfo}
canManageAccessPoint
triggerModeDisabled={false}
workflow={workflow}
workflowLoading={false}
/>
</QueryClientProvider>,
)
}
describe('MCPAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.serverDetail.data = undefined
mocks.serverDetail.isPending = false
mocks.updateServer.mockResolvedValue(undefined)
})
afterEach(() => {
@ -91,15 +142,7 @@ describe('MCPAccessPointCard', () => {
.spyOn(globalThis, 'fetch')
.mockResolvedValue(new Response('{}', { status: 200 }))
render(
<MCPAccessPointCard
appInfo={appInfo}
canManageAccessPoint
triggerModeDisabled={false}
workflow={undefined}
workflowLoading={false}
/>,
)
renderCard()
await user.click(screen.getByRole('button', { name: /addDescription/ }))
@ -122,15 +165,7 @@ describe('MCPAccessPointCard', () => {
it('uses workflow inputs when the app model config is null', async () => {
const user = userEvent.setup()
render(
<MCPAccessPointCard
appInfo={workflowAppInfo}
canManageAccessPoint
triggerModeDisabled={false}
workflow={publishedWorkflow}
workflowLoading={false}
/>,
)
renderCard(workflowAppInfo, publishedWorkflow)
await user.click(screen.getByRole('button', { name: /addDescription/ }))
@ -144,15 +179,7 @@ describe('MCPAccessPointCard', () => {
it('shows loading without reporting an environment failure', () => {
mocks.serverDetail.isPending = true
render(
<MCPAccessPointCard
appInfo={workflowAppInfo}
canManageAccessPoint
triggerModeDisabled={false}
workflow={publishedWorkflow}
workflowLoading={false}
/>,
)
renderCard(workflowAppInfo, publishedWorkflow)
const card = screen.getByRole('region', { name: /mcp\.server\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
@ -161,4 +188,68 @@ describe('MCPAccessPointCard', () => {
screen.queryByText('deployments.health.ENVIRONMENT_STATUS_FAILED'),
).not.toBeInTheDocument()
})
it('rolls back a failed status change and shows only an error toast', async () => {
const user = userEvent.setup()
const toggle = createDeferredPromise<void>()
mocks.serverDetail.data = {
id: 'server-1',
server_code: 'server-code',
status: 'active',
}
mocks.updateServer.mockReturnValueOnce(toggle.promise)
renderCard()
const accessSwitch = screen.getByRole('switch')
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
toggle.reject(new Error('request failed'))
await waitFor(() => {
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
})
expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
expect(toast.success).not.toHaveBeenCalled()
})
it('optimistically serializes rapid status changes without a busy switch', async () => {
const user = userEvent.setup()
const firstToggle = createDeferredPromise<void>()
const secondToggle = createDeferredPromise<void>()
mocks.serverDetail.data = {
id: 'server-1',
server_code: 'server-code',
status: 'active',
}
mocks.updateServer
.mockReturnValueOnce(firstToggle.promise)
.mockReturnValueOnce(secondToggle.promise)
renderCard()
const accessSwitch = screen.getByRole('switch')
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
expect(accessSwitch).toBeEnabled()
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
expect(mocks.updateServer).toHaveBeenCalledTimes(1)
firstToggle.resolve()
await waitFor(() => {
expect(mocks.updateServer).toHaveBeenCalledTimes(2)
})
secondToggle.resolve()
await waitFor(() => {
expect(mocks.invalidateServerDetail).toHaveBeenCalledTimes(2)
})
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
})
})

View File

@ -1,8 +1,9 @@
import type { ReactElement } from 'react'
import type { AccessPointAppInfo } from '../shared/utils'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { useStore as useAppStore } from '@/app/components/app/store'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
@ -10,13 +11,13 @@ import { ServiceApiAccessPointCard } from '../built-in-access-points/service-api
const mocks = vi.hoisted(() => ({
apiSecretKeyButtonProps: vi.fn(),
toastError: vi.fn(),
updateApiStatus: vi.fn().mockResolvedValue({}),
apiEnable: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mocks.toastError,
error: vi.fn(),
success: vi.fn(),
},
}))
@ -27,7 +28,7 @@ vi.mock('@/service/client', () => ({
apiEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateApiStatus,
mutationFn: mocks.apiEnable,
...options,
}),
},
@ -65,13 +66,58 @@ function createAppInfo(
} as AccessPointAppInfo
}
function renderWithQueryClient(ui: ReactElement) {
return render(ui, { wrapper: createQueryClientWrapper(createTestQueryClient()) })
function renderCard(
mode: AppModeEnum,
availability: 'available' | 'loading' | 'unavailable' = 'available',
canManage = true,
overrides: Partial<AccessPointAppInfo> = {},
) {
useAppStore.setState({ appDetail: createAppInfo(mode, overrides) })
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<StoreConnectedServiceApiCard availability={availability} canManage={canManage} />
</QueryClientProvider>,
)
}
function StoreConnectedServiceApiCard({
availability,
canManage,
}: {
availability: 'available' | 'loading' | 'unavailable'
canManage: boolean
}) {
const appInfo = useAppStore((state) => state.appDetail)
if (!appInfo) return null
return (
<ServiceApiAccessPointCard
appInfo={appInfo}
availability={availability}
canManage={canManage}
/>
)
}
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
describe('ServiceApiAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.apiEnable.mockResolvedValue({
enable_api: true,
})
})
it.each([
@ -81,14 +127,7 @@ describe('ServiceApiAccessPointCard', () => {
[AppModeEnum.AGENT_CHAT, '/api-reference/guides/chat'],
[AppModeEnum.COMPLETION, '/api-reference/guides/completion'],
])('links %s apps to the matching external API reference', (mode, path) => {
renderWithQueryClient(
<ServiceApiAccessPointCard
appInfo={createAppInfo(mode)}
availability="available"
canManage
onAppStateChanged={vi.fn()}
/>,
)
renderCard(mode)
const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ })
@ -99,36 +138,20 @@ describe('ServiceApiAccessPointCard', () => {
it('updates API status through the generated contract', async () => {
const user = userEvent.setup()
const onAppStateChanged = vi.fn()
renderWithQueryClient(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="available"
canManage
onAppStateChanged={onAppStateChanged}
/>,
)
renderCard(AppModeEnum.WORKFLOW)
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.updateApiStatus.mock.calls[0]?.[0]).toEqual({
expect(mocks.apiEnable.mock.calls[0]?.[0]).toEqual({
params: { app_id: 'app-1' },
body: { enable_api: false },
})
expect(onAppStateChanged).toHaveBeenCalledTimes(1)
})
})
it('shows loading without reporting an environment failure', () => {
renderWithQueryClient(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="loading"
canManage
onAppStateChanged={vi.fn()}
/>,
)
renderCard(AppModeEnum.WORKFLOW, 'loading')
const card = screen.getByRole('region', { name: /serviceApi\.title/ })
expect(card).toHaveAttribute('aria-busy', 'true')
@ -139,14 +162,7 @@ describe('ServiceApiAccessPointCard', () => {
})
it('keeps API keys and external documentation available when the API is stopped', () => {
renderWithQueryClient(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW, { enable_api: false })}
availability="available"
canManage
onAppStateChanged={vi.fn()}
/>,
)
renderCard(AppModeEnum.WORKFLOW, 'available', true, { enable_api: false })
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeEnabled()
const apiReferenceLink = screen.getByRole('link', { name: /apiInfo\.doc/ })
@ -157,30 +173,51 @@ describe('ServiceApiAccessPointCard', () => {
})
it('disables API management without Access Point management permission', () => {
renderWithQueryClient(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="available"
canManage={false}
onAppStateChanged={vi.fn()}
/>,
)
renderCard(AppModeEnum.WORKFLOW, 'available', false)
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
})
it('disables API keys and external documentation when the access point is unavailable', () => {
renderWithQueryClient(
<ServiceApiAccessPointCard
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
availability="unavailable"
canManage
onAppStateChanged={vi.fn()}
/>,
)
renderCard(AppModeEnum.WORKFLOW, 'unavailable')
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
expect(screen.getByRole('button', { name: /apiInfo\.doc/ })).toBeDisabled()
})
it('rolls back a failed status change and shows only an error toast', async () => {
const user = userEvent.setup()
const toggle = createDeferredPromise<{ enable_api: boolean }>()
mocks.apiEnable.mockReturnValueOnce(toggle.promise)
renderCard(AppModeEnum.WORKFLOW)
const accessSwitch = screen.getByRole('switch')
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
toggle.reject(new Error('request failed'))
await waitFor(() => {
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
})
expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
expect(toast.success).not.toHaveBeenCalled()
})
it('keeps successful status changes silent', async () => {
const user = userEvent.setup()
mocks.apiEnable.mockResolvedValueOnce({ enable_api: false })
renderCard(AppModeEnum.WORKFLOW)
const accessSwitch = screen.getByRole('switch')
await user.click(accessSwitch)
await waitFor(() => {
expect(useAppStore.getState().appDetail?.enable_api).toBe(false)
})
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
expect(toast.success).not.toHaveBeenCalled()
})
})

View File

@ -1,13 +1,16 @@
import type { AccessPointAppInfo } from '../shared/utils'
import type { AppTrigger } from '@/service/use-tools'
import { screen } from '@testing-library/react'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
import { TriggerAccessPointCard } from '../built-in-access-points/trigger-card'
const mocks = vi.hoisted(() => ({
invalidateTriggers: vi.fn(),
setTriggerStatus: vi.fn(),
setTriggerStatuses: vi.fn(),
triggerQuery: {
data: undefined as { data: AppTrigger[] } | undefined,
@ -16,24 +19,46 @@ const mocks = vi.hoisted(() => ({
updateTriggerStatus: vi.fn(),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: vi.fn(),
success: vi.fn(),
},
}))
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
useTriggerStatusStore: () => ({
setTriggerStatus: mocks.setTriggerStatus,
setTriggerStatuses: mocks.setTriggerStatuses,
}),
useTriggerStatusStore: (
selector: (state: { setTriggerStatuses: typeof mocks.setTriggerStatuses }) => unknown,
) =>
selector({
setTriggerStatuses: mocks.setTriggerStatuses,
}),
}))
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.example.test/en${path}`,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
apps: {
byAppId: {
triggerEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateTriggerStatus,
...options,
}),
},
},
},
},
},
}))
vi.mock('@/service/use-tools', () => ({
useAppTriggers: () => mocks.triggerQuery,
useInvalidateAppTriggers: () => mocks.invalidateTriggers,
useUpdateTriggerStatus: () => ({
isPending: false,
mutateAsync: mocks.updateTriggerStatus,
}),
}))
vi.mock('@/service/use-triggers', () => ({
@ -64,21 +89,32 @@ function createTrigger(id: string, status: AppTrigger['status']): AppTrigger {
}
function renderCard(availability: 'available' | 'loading' | 'unavailable') {
const queryClient = createTestQueryClient()
render(
<TriggerAccessPointCard
appInfo={appInfo}
availability={availability}
canManageAccessPoint
onToggleResult={vi.fn()}
/>,
<QueryClientProvider client={queryClient}>
<TriggerAccessPointCard appInfo={appInfo} availability={availability} canManageAccessPoint />
</QueryClientProvider>,
)
}
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
describe('TriggerAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.triggerQuery.data = undefined
mocks.triggerQuery.isLoading = false
mocks.updateTriggerStatus.mockResolvedValue(undefined)
})
it('shows loading without reporting an environment failure', () => {
@ -145,4 +181,84 @@ describe('TriggerAccessPointCard', () => {
screen.queryByText('deployments.studio.accessPoint.noTriggerNodes'),
).not.toBeInTheDocument()
})
it('keeps successful trigger changes silent', async () => {
const user = userEvent.setup()
mocks.triggerQuery.data = {
data: [createTrigger('disabled', 'disabled')],
}
renderCard('available')
await user.click(screen.getByRole('switch', { name: 'Trigger disabled' }))
await waitFor(() => {
expect(mocks.updateTriggerStatus).toHaveBeenCalledWith(
{
params: {
app_id: 'app-1',
},
body: {
trigger_id: 'disabled',
enable_trigger: true,
},
},
expect.any(Object),
)
expect(mocks.invalidateTriggers).toHaveBeenCalledWith('app-1')
})
expect(toast.success).not.toHaveBeenCalled()
})
it('shows an error toast when a trigger change fails', async () => {
const user = userEvent.setup()
mocks.triggerQuery.data = {
data: [createTrigger('enabled', 'enabled')],
}
mocks.updateTriggerStatus.mockRejectedValueOnce(new Error('request failed'))
renderCard('available')
await user.click(screen.getByRole('switch', { name: 'Trigger enabled' }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
})
expect(toast.success).not.toHaveBeenCalled()
})
it('optimistically serializes rapid changes for each trigger without disabling its switch', async () => {
const user = userEvent.setup()
const firstToggle = createDeferredPromise<void>()
const secondToggle = createDeferredPromise<void>()
mocks.triggerQuery.data = {
data: [createTrigger('enabled', 'enabled')],
}
mocks.updateTriggerStatus
.mockReturnValueOnce(firstToggle.promise)
.mockReturnValueOnce(secondToggle.promise)
renderCard('available')
const triggerSwitch = screen.getByRole('switch', { name: 'Trigger enabled' })
await user.click(triggerSwitch)
expect(triggerSwitch).toHaveAttribute('aria-checked', 'false')
expect(triggerSwitch).toBeEnabled()
await user.click(triggerSwitch)
expect(triggerSwitch).toHaveAttribute('aria-checked', 'true')
expect(mocks.updateTriggerStatus).toHaveBeenCalledTimes(1)
firstToggle.resolve()
await waitFor(() => {
expect(mocks.updateTriggerStatus).toHaveBeenCalledTimes(2)
})
secondToggle.resolve()
await waitFor(() => {
expect(mocks.invalidateTriggers).toHaveBeenCalledTimes(2)
})
expect(triggerSwitch).toHaveAttribute('aria-checked', 'true')
})
})

View File

@ -5,10 +5,7 @@ import { createTestQueryClient } from '@/test/query-client'
import { useAccessPointActions } from '../shared/use-access-point-actions'
const mocks = vi.hoisted(() => ({
emit: vi.fn(),
fetchAppDetail: vi.fn().mockResolvedValue({ id: 'app-1' }),
getSocket: vi.fn(),
onAppStateUpdate: vi.fn(() => vi.fn()),
setAppDetail: vi.fn(),
toast: vi.fn(),
updateAppSiteConfig: vi.fn().mockResolvedValue({}),
@ -21,14 +18,6 @@ vi.mock('@/app/components/app/store', () => ({
selector({ setAppDetail: mocks.setAppDetail }),
}))
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
collaborationManager: { onAppStateUpdate: mocks.onAppStateUpdate },
}))
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
webSocketClient: { getSocket: mocks.getSocket },
}))
vi.mock('@/service/apps', () => ({
fetchAppDetail: mocks.fetchAppDetail,
updateAppSiteConfig: mocks.updateAppSiteConfig,
@ -81,10 +70,9 @@ function renderActions(appId = 'app-1', canManageAccessPoint = true) {
describe('useAccessPointActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSocket.mockReturnValue({ emit: mocks.emit })
})
it('refreshes and broadcasts a successful access point result', async () => {
it('refreshes after a successful access point result', async () => {
const { result } = renderActions()
act(() => result.current.handleResult(null))
@ -93,25 +81,17 @@ describe('useAccessPointActions', () => {
expect(mocks.fetchAppDetail).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mocks.setAppDetail).toHaveBeenCalledWith({ id: 'app-1' })
})
expect(mocks.emit).toHaveBeenCalledWith(
'collaboration_event',
expect.objectContaining({
type: 'app_state_update',
timestamp: expect.any(Number),
}),
)
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedSuccessfully', {
type: 'success',
})
})
it('reports a failed result without refreshing or broadcasting stale state', () => {
it('reports a failed result without refreshing stale state', () => {
const { result } = renderActions()
act(() => result.current.handleResult(new Error('request failed')))
expect(mocks.fetchAppDetail).not.toHaveBeenCalled()
expect(mocks.getSocket).not.toHaveBeenCalled()
expect(mocks.toast).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully', {
type: 'error',
})

View File

@ -1,10 +1,12 @@
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import type { InputVar, Node } from '@/app/components/workflow/types'
import { screen, waitFor } from '@testing-library/react'
import { toast } from '@langgenius/dify-ui/toast'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import { AccessMode } from '@/models/access-control'
import { createQueryClientWrapper } from '@/test/console/query-client'
import { render } from '@/test/console/render'
import { createTestQueryClient } from '@/test/query-client'
import { AppModeEnum } from '@/types/app'
@ -12,14 +14,14 @@ import { basePath } from '@/utils/var'
import { WebAppAccessPointCard } from '../built-in-access-points/web-app-card'
const mocks = vi.hoisted(() => ({
siteEnable: vi.fn(),
resetSiteAccessToken: vi.fn().mockResolvedValue({}),
toastError: vi.fn(),
updateSiteStatus: vi.fn().mockResolvedValue({}),
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
toast: {
error: mocks.toastError,
error: vi.fn(),
success: vi.fn(),
},
}))
@ -30,7 +32,7 @@ vi.mock('@/service/client', () => ({
siteEnable: {
post: {
mutationOptions: (options = {}) => ({
mutationFn: mocks.updateSiteStatus,
mutationFn: mocks.siteEnable,
...options,
}),
},
@ -111,30 +113,67 @@ function renderCard(
workflow?: PublishedWorkflow,
{
canManageAccessPoint = true,
onAppStateChanged = vi.fn().mockResolvedValue(undefined),
onRefreshApp = vi.fn().mockResolvedValue(undefined),
}: {
canManageAccessPoint?: boolean
onAppStateChanged?: () => Promise<void>
onRefreshApp?: () => Promise<void>
} = {},
) {
useAppStore.setState({ appDetail: createAppInfo(mode) })
const queryClient = createTestQueryClient()
render(
return render(
<QueryClientProvider client={queryClient}>
<StoreConnectedWebAppCard
availability={availability}
canManageAccessPoint={canManageAccessPoint}
onRefreshApp={onRefreshApp}
workflow={workflow}
/>
</QueryClientProvider>,
)
}
function StoreConnectedWebAppCard({
availability,
canManageAccessPoint,
onRefreshApp,
workflow,
}: {
availability: 'available' | 'loading' | 'unavailable'
canManageAccessPoint: boolean
onRefreshApp: () => Promise<void>
workflow?: PublishedWorkflow
}) {
const appInfo = useAppStore((state) => state.appDetail)
if (!appInfo) return null
return (
<WebAppAccessPointCard
appInfo={createAppInfo(mode)}
appInfo={appInfo}
availability={availability}
canDeploy
canManageAccess
canManageAccessPoint={canManageAccessPoint}
showAccessControl
onAppStateChanged={onAppStateChanged}
onRefreshApp={vi.fn().mockResolvedValue(undefined)}
onRefreshApp={onRefreshApp}
onSaveSiteConfig={vi.fn().mockResolvedValue(undefined)}
workflow={workflow}
/>,
{ wrapper: createQueryClientWrapper(queryClient) },
/>
)
}
function createDeferredPromise<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve
reject = promiseReject
})
return { promise, reject, resolve }
}
const startNode: Node<{ variables: InputVar[] }> = {
id: 'start',
position: { x: 0, y: 0 },
@ -179,6 +218,10 @@ const workflowWithHiddenInput: NonNullable<PublishedWorkflow> = {
describe('WebAppAccessPointCard', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.siteEnable.mockResolvedValue({
enable_site: true,
})
mocks.resetSiteAccessToken.mockResolvedValue({})
})
afterEach(() => {
@ -216,43 +259,15 @@ describe('WebAppAccessPointCard', () => {
it('updates site status through the generated contract', async () => {
const user = userEvent.setup()
const onAppStateChanged = vi.fn().mockResolvedValue(undefined)
renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged })
renderCard(AppModeEnum.CHAT)
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.updateSiteStatus.mock.calls[0]?.[0]).toEqual({
expect(mocks.siteEnable.mock.calls[0]?.[0]).toEqual({
params: { app_id: 'app-1' },
body: { enable_site: false },
})
expect(onAppStateChanged).toHaveBeenCalledTimes(1)
})
})
it('keeps the status switch pending until the app state refresh completes', async () => {
const user = userEvent.setup()
let resolveRefresh: (() => void) | undefined
const refresh = new Promise<void>((resolve) => {
resolveRefresh = resolve
})
const onAppStateChanged = vi.fn(() => refresh)
renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged })
const statusSwitch = screen.getByRole('switch')
await user.click(statusSwitch)
await waitFor(() => {
expect(onAppStateChanged).toHaveBeenCalledTimes(1)
expect(statusSwitch).toHaveAttribute('aria-busy', 'true')
expect(statusSwitch).toHaveAttribute('aria-disabled', 'true')
})
resolveRefresh?.()
await waitFor(() => {
expect(statusSwitch).not.toHaveAttribute('aria-busy', 'true')
expect(statusSwitch).not.toHaveAttribute('aria-disabled', 'true')
})
})
@ -262,13 +277,13 @@ describe('WebAppAccessPointCard', () => {
await user.click(screen.getByRole('switch'))
expect(mocks.updateSiteStatus).not.toHaveBeenCalled()
expect(mocks.siteEnable).not.toHaveBeenCalled()
})
it('resets the site access token through the generated contract', async () => {
const user = userEvent.setup()
const onAppStateChanged = vi.fn().mockResolvedValue(undefined)
renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged })
const onRefreshApp = vi.fn().mockResolvedValue(undefined)
renderCard(AppModeEnum.CHAT, 'available', undefined, { onRefreshApp })
await user.click(screen.getByRole('button', { name: /overview\.appInfo\.regenerate/ }))
await user.click(screen.getByRole('button', { name: /operation\.confirm/ }))
@ -277,23 +292,21 @@ describe('WebAppAccessPointCard', () => {
expect(mocks.resetSiteAccessToken.mock.calls[0]?.[0]).toEqual({
params: { app_id: 'app-1' },
})
expect(onAppStateChanged).toHaveBeenCalledTimes(1)
expect(onRefreshApp).toHaveBeenCalledTimes(1)
})
})
it('keeps generated mutation failures inside the card owner', async () => {
const user = userEvent.setup()
const error = new Error('request failed')
const onAppStateChanged = vi.fn().mockResolvedValue(undefined)
mocks.updateSiteStatus.mockRejectedValueOnce(error)
renderCard(AppModeEnum.CHAT, 'available', undefined, { onAppStateChanged })
mocks.siteEnable.mockRejectedValueOnce(error)
renderCard(AppModeEnum.CHAT)
await user.click(screen.getByRole('switch'))
await waitFor(() => {
expect(mocks.toastError).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
expect(toast.error).toHaveBeenCalledWith('common.actionMsg.modifiedUnsuccessfully')
})
expect(onAppStateChanged).not.toHaveBeenCalled()
})
it('passes hidden Chatflow inputs to the embed dialog', async () => {
@ -333,6 +346,53 @@ describe('WebAppAccessPointCard', () => {
).not.toBeInTheDocument()
})
it('does not announce an unavailable access control entry as loading', () => {
renderCard(AppModeEnum.WORKFLOW, 'unavailable')
const card = screen.getByRole('region', { name: /webApp\.title/ })
expect(within(card).queryByRole('status', { name: 'common.loading' })).not.toBeInTheDocument()
})
it('optimistically serializes status changes without a success toast', async () => {
const user = userEvent.setup()
const firstToggle = createDeferredPromise<{ enable_site: boolean }>()
const secondToggle = createDeferredPromise<{ enable_site: boolean }>()
mocks.siteEnable
.mockReturnValueOnce(firstToggle.promise)
.mockReturnValueOnce(secondToggle.promise)
renderCard(AppModeEnum.CHAT)
const accessSwitch = screen.getByRole('switch')
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'false')
expect(mocks.siteEnable).toHaveBeenCalledTimes(1)
await user.click(accessSwitch)
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
expect(mocks.siteEnable).toHaveBeenCalledTimes(1)
expect(
screen.queryByRole('link', { name: /studio\.accessPoint\.open/ }),
).not.toBeInTheDocument()
firstToggle.resolve({ enable_site: false })
await waitFor(() => {
expect(mocks.siteEnable).toHaveBeenCalledTimes(2)
})
expect(mocks.siteEnable.mock.calls[1]?.[0]).toEqual({
body: { enable_site: true },
params: { app_id: 'app-1' },
})
secondToggle.resolve({ enable_site: true })
await screen.findByRole('link', { name: /studio\.accessPoint\.open/ })
expect(accessSwitch).toHaveAttribute('aria-checked', 'true')
expect(toast.success).not.toHaveBeenCalled()
})
it('disables Web App management actions without Access Point management', () => {
renderCard(AppModeEnum.CHAT, 'available', undefined, { canManageAccessPoint: false })

View File

@ -1,6 +1,6 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
@ -112,7 +112,6 @@ export function BuiltInAccessPoints({
canManageAccess={canReleaseAndVersion}
canManageAccessPoint={canManageAccessPoint}
showAccessControl={systemFeatures.webapp_auth.enabled}
onAppStateChanged={actions.handleAppStateChanged}
onRefreshApp={actions.refreshAppDetail}
onSaveSiteConfig={actions.saveSiteConfig}
workflow={workflow}
@ -122,7 +121,6 @@ export function BuiltInAccessPoints({
appInfo={appInfo}
availability={appCardAvailability}
canManage={canManageAccessPoint}
onAppStateChanged={actions.handleAppStateChanged}
highlighted={highlightedAccessPoint === 'serviceApi'}
/>
<MCPAccessPointCard
@ -138,7 +136,6 @@ export function BuiltInAccessPoints({
appInfo={appInfo}
availability={triggerAvailability}
canManageAccessPoint={canManageAccessPoint}
onToggleResult={actions.handleResult}
highlighted={highlightedAccessPoint === 'trigger'}
/>
)}

View File

@ -11,19 +11,22 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
import MCPServerModal from '@/app/components/tools/mcp/mcp-server-modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { consoleQuery } from '@/service/client'
import {
useInvalidateMCPServerDetail,
useMCPServerDetail,
useRefreshMCPServerCode,
useUpdateMCPServer,
} from '@/service/use-tools'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from '../shared/access-point-card'
import { AccessPointUrl } from '../shared/access-point-url'
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
import { getPublishedWorkflowNodes, isAdvancedApp } from '../shared/utils'
type MCPAccessPointCardProps = {
@ -49,20 +52,33 @@ export function MCPAccessPointCard({
const workflowApp = appInfo.mode === AppModeEnum.WORKFLOW
const [showServerModal, setShowServerModal] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [pendingStatus, setPendingStatus] = useState<boolean | null>(null)
const basicConfig = appInfo.model_config
const basicAppInputForm = basicConfig?.user_input_form
const { data: detail, isPending: serverDetailLoading } = useMCPServerDetail(
appInfo.id,
Boolean(appInfo.id),
)
const { mutateAsync: updateServer, isPending: statusUpdating } = useUpdateMCPServer()
const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode()
const invalidateServerDetail = useInvalidateMCPServerDetail()
const updateServerMutation = useMutation(
consoleQuery.apps.byAppId.server.put.mutationOptions({
scope: {
id: `app-mcp-toggle:${appInfo.id}`,
},
onSuccess: () => invalidateServerDetail(appInfo.id),
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
}),
)
const { mutateAsync: refreshServerCode, isPending: regenerating } = useRefreshMCPServerCode()
const serverPublished = Boolean(detail?.id)
const serverActivated = detail?.status === 'active'
const activated = pendingStatus ?? serverActivated
const pendingStatus = updateServerMutation.variables?.body.status
const activated =
updateServerMutation.isPending && pendingStatus != null
? pendingStatus === 'active'
: serverActivated
const serverUrl = serverPublished
? `${appInfo.api_base_url.replace(/\/v1$/, '')}/mcp/server/${detail?.server_code}/mcp`
: '***********'
@ -98,26 +114,24 @@ export function MCPAccessPointCard({
)
}, [advancedApp, basicAppInputs, workflowNodes])
const handleStatusChange = async (enabled: boolean) => {
const handleStatusChange = (enabled: boolean) => {
if (!canManageAccessPoint || loading || unavailable) return
if (enabled && !serverPublished) {
setShowServerModal(true)
return
}
setPendingStatus(enabled)
try {
await updateServer({
appID: appInfo.id,
updateServerMutation.mutate({
params: {
app_id: appInfo.id,
},
body: {
id: detail?.id || '',
description: detail?.description || '',
parameters: detail?.parameters || {},
status: enabled ? 'active' : 'inactive',
})
invalidateServerDetail(appInfo.id)
} finally {
setPendingStatus(null)
}
},
})
}
const handleRegenerate = async () => {
@ -134,6 +148,7 @@ export function MCPAccessPointCard({
: activated
? 'inService'
: 'disabled'
const statusLabel = useAccessPointStatusLabel(status)
return (
<>
@ -144,10 +159,10 @@ export function MCPAccessPointCard({
})}
icon="i-custom-vender-integrations-mcp"
status={status}
statusLabel={statusLabel}
highlighted={highlighted}
switchDisabled={!canManageAccessPoint}
switchLabel={t(($) => $['mcp.server.title'], { ns: 'tools' })}
switchLoading={statusUpdating}
onEnabledChange={loading || unavailable ? undefined : handleStatusChange}
actions={
<Button
@ -191,7 +206,6 @@ export function MCPAccessPointCard({
latestParams={latestParams}
onHide={() => {
setShowServerModal(false)
setPendingStatus(null)
invalidateServerDetail(appInfo.id)
}}
appInfo={appInfo}

View File

@ -1,12 +1,13 @@
'use client'
import type { AccessPointAvailability } from '../shared/access-point-status'
import type { AccessPointAppInfo } from '../shared/utils'
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { getAccessPointStatus } from '@/app/components/base/access-point/status'
import { consoleQuery } from '@/service/client'
import { getAccessPointStatus } from '../shared/access-point-status'
import { ServiceApiCardView } from '../shared/service-api-card-view'
import { getBuiltInAccessUrls } from '../shared/utils'
@ -15,7 +16,6 @@ type ServiceApiAccessPointCardProps = {
availability: AccessPointAvailability
canManage: boolean
highlighted?: boolean
onAppStateChanged: () => Promise<void>
}
export function ServiceApiAccessPointCard({
@ -23,27 +23,48 @@ export function ServiceApiAccessPointCard({
availability,
canManage,
highlighted,
onAppStateChanged,
}: ServiceApiAccessPointCardProps) {
const { t } = useTranslation()
const updateApiStatus = useMutation(
const setAppDetail = useAppStore((state) => state.setAppDetail)
const toggleApiMutation = useMutation(
consoleQuery.apps.byAppId.apiEnable.post.mutationOptions({
onSuccess: onAppStateChanged,
scope: {
id: `app-service-api-toggle:${appInfo.id}`,
},
onSuccess: (updatedApp) => {
const currentAppDetail = useAppStore.getState().appDetail
if (!currentAppDetail || currentAppDetail.id !== appInfo.id) return
setAppDetail({
...currentAppDetail,
enable_api: updatedApp.enable_api,
updated_at: updatedApp.updated_at ?? currentAppDetail.updated_at,
})
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
}),
)
const { api: apiUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_api
const pendingEnabled = toggleApiMutation.variables?.body.enable_api
const optimisticEnabled =
toggleApiMutation.isPending && pendingEnabled !== undefined
? pendingEnabled
: appInfo.enable_api
const running = availability === 'available' && optimisticEnabled
const status = getAccessPointStatus(availability, running)
const handleStatusChange = (enabled: boolean) => {
const handleEnabledChange = (enabled: boolean) => {
if (!canManage) return
updateApiStatus.mutate({
params: { app_id: appInfo.id },
body: { enable_api: enabled },
toggleApiMutation.mutate({
params: {
app_id: appInfo.id,
},
body: {
enable_api: enabled,
},
})
}
@ -60,8 +81,7 @@ export function ServiceApiAccessPointCard({
status={status}
highlighted={highlighted}
switchDisabled={!canManage}
switchLoading={updateApiStatus.isPending}
onEnabledChange={availability === 'available' ? handleStatusChange : undefined}
onEnabledChange={availability === 'available' ? handleEnabledChange : undefined}
/>
)
}

View File

@ -5,21 +5,21 @@ import type { TriggerWithProvider } from '@/app/components/workflow/block-select
import type { AppTrigger } from '@/service/use-tools'
import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { Switch } from '@langgenius/dify-ui/switch'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation } from '@tanstack/react-query'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessPointCard, AccessPointEmptyContent } from '@/app/components/base/access-point/card'
import BlockIcon from '@/app/components/workflow/block-icon'
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import {
useAppTriggers,
useInvalidateAppTriggers,
useUpdateTriggerStatus,
} from '@/service/use-tools'
import { consoleQuery } from '@/service/client'
import { useAppTriggers, useInvalidateAppTriggers } from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
import { canFindTool } from '@/utils'
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
function TriggerIcon({
trigger,
@ -52,12 +52,89 @@ function TriggerIcon({
)
}
function TriggerAccessPointItem({
appId,
canManageAccessPoint,
trigger,
triggerPlugins,
}: {
appId: string
canManageAccessPoint: boolean
trigger: AppTrigger
triggerPlugins: TriggerWithProvider[]
}) {
const { t } = useTranslation()
const invalidateTriggers = useInvalidateAppTriggers()
const updateTriggerMutation = useMutation(
consoleQuery.apps.byAppId.triggerEnable.post.mutationOptions({
scope: {
id: `app-trigger-toggle:${appId}:${trigger.id}`,
},
onSuccess: () => invalidateTriggers(appId),
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
}),
)
const pendingEnabled = updateTriggerMutation.variables?.body.enable_trigger
const enabled =
updateTriggerMutation.isPending && pendingEnabled !== undefined
? pendingEnabled
: trigger.status === 'enabled'
const statusLabel = enabled
? t(($) => $['agentDetail.access.status.inService'], {
ns: 'agentV2',
})
: t(($) => $['overview.status.disable'], {
ns: 'appOverview',
})
const handleEnabledChange = (nextEnabled: boolean) => {
if (!canManageAccessPoint) return
updateTriggerMutation.mutate({
params: {
app_id: appId,
},
body: {
trigger_id: trigger.id,
enable_trigger: nextEnabled,
},
})
}
return (
<div className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover">
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
{trigger.title}
</span>
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{trigger.provider_name}
</span>
<span
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
enabled ? 'text-text-success' : 'text-text-tertiary'
}`}
>
<StatusDot size="small" status={enabled ? 'success' : 'disabled'} />
{statusLabel}
</span>
<Switch
checked={enabled}
disabled={!canManageAccessPoint}
aria-label={trigger.title}
onCheckedChange={handleEnabledChange}
/>
</div>
)
}
type TriggerAccessPointCardProps = {
appInfo: AccessPointAppInfo
availability: 'available' | 'loading' | 'unavailable'
canManageAccessPoint: boolean
highlighted?: boolean
onToggleResult: (error: Error | null) => void
}
export function TriggerAccessPointCard({
@ -65,19 +142,17 @@ export function TriggerAccessPointCard({
availability,
canManageAccessPoint,
highlighted,
onToggleResult,
}: TriggerAccessPointCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: response, isLoading } = useAppTriggers(appInfo.id)
const { data: triggerPlugins = [] } = useAllTriggerPlugins()
const { mutateAsync: updateTriggerStatus, isPending: statusUpdating } = useUpdateTriggerStatus()
const invalidateTriggers = useInvalidateAppTriggers()
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
const setTriggerStatuses = useTriggerStatusStore((state) => state.setTriggerStatuses)
const triggers = useMemo(() => response?.data ?? [], [response?.data])
const loading = availability === 'loading' || isLoading
const active = availability === 'available' && !loading
const status = loading ? 'loading' : active ? 'inService' : 'unavailable'
const statusLabel = useAccessPointStatusLabel(status)
const enabledCount = triggers.filter((trigger) => trigger.status === 'enabled').length
useEffect(() => {
@ -94,25 +169,6 @@ export function TriggerAccessPointCard({
)
}, [setTriggerStatuses, triggers])
const toggleTrigger = async (trigger: AppTrigger, enabled: boolean) => {
if (!canManageAccessPoint) return
const status = enabled ? 'enabled' : 'disabled'
setTriggerStatus(trigger.node_id, status)
try {
await updateTriggerStatus({
appId: appInfo.id,
triggerId: trigger.id,
enableTrigger: enabled,
})
invalidateTriggers(appInfo.id)
onToggleResult(null)
} catch (error) {
setTriggerStatus(trigger.node_id, enabled ? 'disabled' : 'enabled')
onToggleResult(error as Error)
}
}
return (
<AccessPointCard
title={t(($) => $['settings.trigger'], { ns: 'common' })}
@ -121,6 +177,7 @@ export function TriggerAccessPointCard({
})}
icon="i-custom-vender-integrations-trigger"
status={status}
statusLabel={statusLabel}
highlighted={highlighted}
showStatus={!active}
>
@ -160,45 +217,15 @@ export function TriggerAccessPointCard({
})}
</div>
<div className="mt-1 flex flex-col gap-1">
{triggers.map((trigger) => {
const enabled = trigger.status === 'enabled'
const statusLabel = enabled
? t(($) => $['agentDetail.access.status.inService'], {
ns: 'agentV2',
})
: t(($) => $['overview.status.disable'], {
ns: 'appOverview',
})
return (
<div
key={trigger.id}
className="flex min-h-11 items-center gap-3 rounded-lg px-2 py-1.5 hover:bg-state-base-hover"
>
<TriggerIcon trigger={trigger} triggerPlugins={triggerPlugins} />
<span className="w-28 shrink-0 truncate system-sm-medium text-text-secondary">
{trigger.title}
</span>
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
{trigger.provider_name}
</span>
<span
className={`flex shrink-0 items-center gap-1 system-xs-semibold-uppercase ${
enabled ? 'text-text-success' : 'text-text-tertiary'
}`}
>
<StatusDot size="small" status={enabled ? 'success' : 'disabled'} />
{statusLabel}
</span>
<Switch
checked={enabled}
disabled={!canManageAccessPoint || statusUpdating}
aria-label={trigger.title}
onCheckedChange={(nextEnabled) => void toggleTrigger(trigger, nextEnabled)}
/>
</div>
)
})}
{triggers.map((trigger) => (
<TriggerAccessPointItem
key={trigger.id}
appId={appInfo.id}
canManageAccessPoint={canManageAccessPoint}
trigger={trigger}
triggerPlugins={triggerPlugins}
/>
))}
</div>
</div>
)}

View File

@ -1,9 +1,9 @@
'use client'
import type { SelectorParam } from 'i18next'
import type { AccessPointAvailability } from '../shared/access-point-status'
import type { AccessPointAppInfo, PublishedWorkflow } from '../shared/utils'
import type { ConfigParams } from '@/app/components/app/overview/settings'
import type { AccessPointAvailability } from '@/app/components/base/access-point/status'
import {
AlertDialog,
AlertDialogActions,
@ -23,16 +23,21 @@ import CustomizeModal from '@/app/components/app/overview/customize'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import SettingsModal from '@/app/components/app/overview/settings'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { getAccessPointStatus } from '@/app/components/base/access-point/status'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
import AppIcon from '@/app/components/base/app-icon'
import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects } from '@/service/access-control/use-app-access-control'
import { consoleQuery } from '@/service/client'
import { AppModeEnum } from '@/types/app'
import { AccessPointCard } from '../shared/access-point-card'
import { getAccessPointStatus } from '../shared/access-point-status'
import { AccessPointUrl } from '../shared/access-point-url'
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
import { getBuiltInAccessUrls, getHiddenStartInputs } from '../shared/utils'
import { WebAppAccessControlEntry } from '../shared/web-app-access-control'
import {
WebAppAccessControlEntry,
WebAppAccessControlEntrySkeleton,
} from '../shared/web-app-access-control'
const ACCESS_MODE_ICON_MAP: Record<AccessMode, string> = {
[AccessMode.ORGANIZATION]: 'i-ri-building-line',
@ -56,7 +61,6 @@ type WebAppAccessPointCardProps = {
canManageAccessPoint: boolean
highlighted?: boolean
showAccessControl: boolean
onAppStateChanged: () => Promise<void>
onRefreshApp: () => Promise<void>
onSaveSiteConfig: (params: ConfigParams) => Promise<void>
workflow: PublishedWorkflow
@ -69,22 +73,34 @@ export function WebAppAccessPointCard({
canManageAccess,
canManageAccessPoint,
highlighted,
onAppStateChanged,
onRefreshApp,
onSaveSiteConfig,
showAccessControl,
workflow,
}: WebAppAccessPointCardProps) {
const { t } = useTranslation()
const setAppDetail = useAppStore((state) => state.setAppDetail)
const [showSettings, setShowSettings] = useState(false)
const [showEmbedded, setShowEmbedded] = useState(false)
const [showCustomize, setShowCustomize] = useState(false)
const [showAccess, setShowAccess] = useState(false)
const [showRegenerate, setShowRegenerate] = useState(false)
const [showWorkflowLaunch, setShowWorkflowLaunch] = useState(false)
const updateSiteStatus = useMutation(
const toggleSiteMutation = useMutation(
consoleQuery.apps.byAppId.siteEnable.post.mutationOptions({
onSuccess: onAppStateChanged,
scope: {
id: `app-web-app-toggle:${appInfo.id}`,
},
onSuccess: (updatedApp) => {
const currentAppDetail = useAppStore.getState().appDetail
if (!currentAppDetail || currentAppDetail.id !== appInfo.id) return
setAppDetail({
...currentAppDetail,
enable_site: updatedApp.enable_site,
updated_at: updatedApp.updated_at ?? currentAppDetail.updated_at,
})
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
@ -93,7 +109,7 @@ export function WebAppAccessPointCard({
const resetSiteAccessToken = useMutation(
consoleQuery.apps.byAppId.site.accessTokenReset.post.mutationOptions({
onSuccess: async () => {
await onAppStateChanged()
await onRefreshApp()
setShowRegenerate(false)
},
onError: () => {
@ -103,7 +119,13 @@ export function WebAppAccessPointCard({
}),
)
const { webApp: webAppUrl } = getBuiltInAccessUrls(appInfo)
const running = availability === 'available' && appInfo.enable_site
const pendingEnabled = toggleSiteMutation.variables?.body.enable_site
const optimisticEnabled =
toggleSiteMutation.isPending && pendingEnabled !== undefined
? pendingEnabled
: appInfo.enable_site
const running = availability === 'available' && optimisticEnabled
const actionsAvailable = running && !toggleSiteMutation.isPending
const supportsEmbedded =
appInfo.mode !== AppModeEnum.COMPLETION && appInfo.mode !== AppModeEnum.WORKFLOW
const hiddenLaunchVariables = getHiddenStartInputs(workflow)
@ -120,22 +142,27 @@ export function WebAppAccessPointCard({
appInfo.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS ||
Boolean(accessSubjects?.groups?.length || accessSubjects?.members?.length)
const handleStatusChange = (enabled: boolean) => {
if (!canManageAccessPoint) return
updateSiteStatus.mutate({
params: { app_id: appInfo.id },
body: { enable_site: enabled },
})
}
const handleRegenerate = () => {
if (!canManageAccessPoint || resetSiteAccessToken.isPending) return
resetSiteAccessToken.mutate({ params: { app_id: appInfo.id } })
}
const handleEnabledChange = (enabled: boolean) => {
if (!canManageAccessPoint) return
toggleSiteMutation.mutate({
params: {
app_id: appInfo.id,
},
body: {
enable_site: enabled,
},
})
}
const status = getAccessPointStatus(availability, running)
const statusLabel = useAccessPointStatusLabel(status)
return (
<>
@ -154,18 +181,18 @@ export function WebAppAccessPointCard({
/>
}
status={status}
statusLabel={statusLabel}
highlighted={highlighted}
switchDisabled={!canManageAccessPoint}
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
switchLoading={updateSiteStatus.isPending}
onEnabledChange={availability === 'available' ? handleStatusChange : undefined}
onEnabledChange={availability === 'available' ? handleEnabledChange : undefined}
actions={
<>
{hiddenLaunchVariables.length > 0 && (
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running}
disabled={!actionsAvailable || !canManageAccessPoint}
onClick={() => setShowWorkflowLaunch(true)}
>
<span aria-hidden className="i-ri-settings-2-line size-4" />
@ -176,7 +203,7 @@ export function WebAppAccessPointCard({
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running || !canManageAccessPoint}
disabled={!actionsAvailable || !canManageAccessPoint}
onClick={() => setShowEmbedded(true)}
>
<span aria-hidden className="i-ri-window-line size-4" />
@ -186,7 +213,7 @@ export function WebAppAccessPointCard({
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running || !canManageAccessPoint}
disabled={!actionsAvailable || !canManageAccessPoint}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
@ -219,7 +246,7 @@ export function WebAppAccessPointCard({
showQrCode
showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
openUrl={webAppUrl}
openUrl={appInfo.enable_site && !toggleSiteMutation.isPending ? webAppUrl : undefined}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
@ -227,16 +254,18 @@ export function WebAppAccessPointCard({
regenerating={resetSiteAccessToken.isPending}
onRegenerate={() => setShowRegenerate(true)}
/>
{showAccessControl && (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={accessIcon}
accessLabel={t(accessLabel, { ns: 'app' })}
available={availability === 'available'}
disabled={!canManageAccess}
onClick={() => setShowAccess(true)}
/>
)}
{showAccessControl &&
(availability === 'available' ? (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={accessIcon}
accessLabel={t(accessLabel, { ns: 'app' })}
disabled={!canManageAccess}
onClick={() => setShowAccess(true)}
/>
) : (
<WebAppAccessControlEntrySkeleton loading={availability === 'loading'} />
))}
</AccessPointCard>
<SettingsModal

View File

@ -1,7 +1,7 @@
'use client'
import { toast } from '@langgenius/dify-ui/toast'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { consoleQuery } from '@/service/client'
@ -21,7 +21,6 @@ export function EnvironmentServiceApiCard({
highlighted,
}: EnvironmentServiceApiCardProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const appMode = useAppStore((state) => state.appDetail?.mode)
const params = {
app_id: appId,
@ -35,16 +34,18 @@ export function EnvironmentServiceApiCard({
const api = apiQuery.data
const apiMutation = useMutation(
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentApi.mutationOptions({
onSuccess: (updatedApi) => {
queryClient.setQueryData(apiQueryOptions.queryKey, updatedApi)
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
scope: {
id: `environment-service-api-toggle:${appId}:${environmentId}`,
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
},
}),
)
const running = Boolean(apiQuery.isSuccess && api?.enabled)
const pendingEnabled = apiMutation.variables?.body.enabled
const optimisticEnabled =
apiMutation.isPending && pendingEnabled !== undefined ? pendingEnabled : Boolean(api?.enabled)
const running = apiQuery.isSuccess && optimisticEnabled
const status = apiQuery.isPending
? 'loading'
: apiQuery.isError
@ -78,7 +79,6 @@ export function EnvironmentServiceApiCard({
highlighted={highlighted}
switchDisabled={!canManageAccessPoint}
onEnabledChange={apiQuery.isSuccess ? handleEnabledChange : undefined}
switchLoading={apiMutation.isPending}
/>
)
}

View File

@ -18,14 +18,18 @@ import { useTranslation } from 'react-i18next'
import CustomizeModal from '@/app/components/app/overview/customize'
import SettingsModal from '@/app/components/app/overview/settings'
import { useStore as useAppStore } from '@/app/components/app/store'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
import AppIcon from '@/app/components/base/app-icon'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { AccessMode, isAccessMode } from '@/models/access-control'
import { consoleQuery } from '@/service/client'
import { AccessPointCard } from '../shared/access-point-card'
import { AccessPointUrl } from '../shared/access-point-url'
import { useAccessPointActions } from '../shared/use-access-point-actions'
import { WebAppAccessControlEntry } from '../shared/web-app-access-control'
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
import {
WebAppAccessControlEntry,
WebAppAccessControlEntrySkeleton,
} from '../shared/web-app-access-control'
import { EnvironmentAccessControl } from './environment-access-control'
import { getEnvironmentWebAppUrl } from './environment-web-app-utils'
@ -94,9 +98,8 @@ export function EnvironmentWebAppCard({
subjectsQuery.data.subjects.length > 0
const siteMutation = useMutation(
consoleQuery.enterprise.appDeploy.accessService.updateEnvironmentSite.mutationOptions({
onSuccess: (updatedSite) => {
queryClient.setQueryData(siteQueryOptions.queryKey, updatedSite)
toast.success(t(($) => $['actionMsg.modifiedSuccessfully'], { ns: 'common' }))
scope: {
id: `environment-web-app-toggle:${appId}:${environmentId}`,
},
onError: () => {
toast.error(t(($) => $['actionMsg.modifiedUnsuccessfully'], { ns: 'common' }))
@ -117,8 +120,12 @@ export function EnvironmentWebAppCard({
},
),
)
const webAppUrl = getEnvironmentWebAppUrl(site)
const running = Boolean(siteQuery.isSuccess && site?.enabled)
const webAppUrl = getEnvironmentWebAppUrl(site, appInfo?.mode)
const pendingEnabled = siteMutation.variables?.body.enabled
const optimisticEnabled =
siteMutation.isPending && pendingEnabled !== undefined ? pendingEnabled : Boolean(site?.enabled)
const running = siteQuery.isSuccess && optimisticEnabled
const actionsAvailable = running && !siteMutation.isPending
const status = siteQuery.isPending
? 'loading'
: siteQuery.isError
@ -126,6 +133,7 @@ export function EnvironmentWebAppCard({
: running
? 'inService'
: 'disabled'
const statusLabel = useAccessPointStatusLabel(status)
const accessLabel =
accessMode === AccessMode.ORGANIZATION
? t(($) => $['accessControlDialog.accessItems.organization'], { ns: 'app' })
@ -170,17 +178,17 @@ export function EnvironmentWebAppCard({
)
}
status={status}
statusLabel={statusLabel}
highlighted={highlighted}
switchDisabled={!canManageAccessPoint}
switchLabel={t(($) => $['overview.appInfo.title'], { ns: 'appOverview' })}
onEnabledChange={siteQuery.isSuccess ? handleEnabledChange : undefined}
switchLoading={siteMutation.isPending}
actions={
<>
<Button
className="flex items-center gap-1 px-3"
variant="secondary"
disabled={!running || !apiQuery.isSuccess || !canManageAccessPoint}
disabled={!actionsAvailable || !apiQuery.isSuccess || !canManageAccessPoint}
onClick={() => setShowCustomize(true)}
>
<span aria-hidden className="i-custom-vender-deploy-code-block size-4" />
@ -213,7 +221,7 @@ export function EnvironmentWebAppCard({
showQrCode
showRegenerate
openLabel={t(($) => $['studio.accessPoint.open'], { ns: 'deployments' })}
openUrl={webAppUrl}
openUrl={site?.enabled && !siteMutation.isPending ? webAppUrl : undefined}
regenerateLabel={t(($) => $['overview.appInfo.regenerate'], {
ns: 'appOverview',
})}
@ -221,16 +229,18 @@ export function EnvironmentWebAppCard({
regenerating={resetAccessTokenMutation.isPending}
onRegenerate={() => setShowRegenerate(true)}
/>
{systemFeatures.webapp_auth.enabled && (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={ACCESS_MODE_ICON_MAP[accessMode]}
accessLabel={accessLabel}
available={siteQuery.isSuccess}
disabled={!canReleaseAndVersion}
onClick={() => setShowAccess(true)}
/>
)}
{systemFeatures.webapp_auth.enabled &&
(siteQuery.isSuccess ? (
<WebAppAccessControlEntry
accessConfigured={accessConfigured}
accessIcon={ACCESS_MODE_ICON_MAP[accessMode]}
accessLabel={accessLabel}
disabled={!canReleaseAndVersion}
onClick={() => setShowAccess(true)}
/>
) : (
<WebAppAccessControlEntrySkeleton loading={siteQuery.isPending} />
))}
</AccessPointCard>
{appInfo && (

View File

@ -1,8 +1,10 @@
import type { EnvironmentSite } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
export function getEnvironmentWebAppUrl(site?: EnvironmentSite) {
export function getEnvironmentWebAppUrl(site?: EnvironmentSite, mode?: string) {
if (!site?.app_base_url || !site.code) return ''
return `${site.app_base_url.replace(/\/$/, '')}${basePath}/env/workflow/${site.code}`
const route = mode === AppModeEnum.ADVANCED_CHAT ? 'chat' : 'workflow'
return `${site.app_base_url.replace(/\/$/, '')}${basePath}/environment/${route}/${site.code}`
}

View File

@ -1,8 +1,9 @@
'use client'
import type { AccessPoint } from '@/app/components/app/deploy/access-point'
import type { AccessPoint } from '@/app/components/app/deploy/utils/access-point'
import { useTranslation } from 'react-i18next'
import { AccessPointCard, AccessPointEmptyContent } from '../shared/access-point-card'
import { AccessPointCard, AccessPointEmptyContent } from '@/app/components/base/access-point/card'
import { useAccessPointStatusLabel } from '../shared/use-access-point-status-label'
import { EnvironmentServiceApiCard } from './environment-service-api-card'
import { EnvironmentWebAppCard } from './environment-web-app-card'
@ -44,6 +45,7 @@ export function DeployedEnvironmentAccessPoints({
highlightedAccessPoint,
}: DeployedEnvironmentAccessPointsProps) {
const { t } = useTranslation()
const unsupportedStatusLabel = useAccessPointStatusLabel('unsupported')
const title = (accessPoint: (typeof UNSUPPORTED_ACCESS_POINTS)[number]) => {
const key = ACCESS_POINT_CONFIG[accessPoint].title
@ -85,6 +87,7 @@ export function DeployedEnvironmentAccessPoints({
description={description(accessPoint)}
icon={ACCESS_POINT_CONFIG[accessPoint].icon}
status="unsupported"
statusLabel={unsupportedStatusLabel}
highlighted={highlightedAccessPoint === accessPoint}
>
<AccessPointEmptyContent>

View File

@ -1,11 +1,18 @@
'use client'
import {
ScrollArea,
ScrollAreaContent,
ScrollAreaScrollbar,
ScrollAreaThumb,
ScrollAreaViewport,
} from '@langgenius/dify-ui/scroll-area'
import { Tabs, TabsList, TabsTab } from '@langgenius/dify-ui/tabs'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs'
import { useTranslation } from 'react-i18next'
import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/access-point'
import { ACCESS_POINT_ORDER } from '@/app/components/app/deploy/utils/access-point'
import { useStore as useAppStore } from '@/app/components/app/store'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
import { userProfileQueryOptions } from '@/features/account-profile/client'
@ -64,7 +71,7 @@ function AccessPointContent({
<header className="flex shrink-0 flex-col gap-3 px-6 pt-3 pb-2">
<div className="flex flex-col gap-0.5">
<div className="flex h-6 items-center">
<h1 className="title-xl-semi-bold text-text-primary">
<h1 id="access-point-title" className="title-xl-semi-bold text-text-primary">
{t(($) => $['appMenus.accessPoint'], { ns: 'common' })}
</h1>
</div>
@ -103,28 +110,41 @@ function AccessPointContent({
)}
</header>
<div
className="min-h-0 flex-1 overflow-y-auto px-6 py-2"
data-environment={selectedEnvironment}
>
{selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? (
<BuiltInAccessPoints
appId={appId}
canDeploy={canDeploy}
canManageAccessPoint={canManageAccessPoint}
canReleaseAndVersion={canReleaseAndVersion}
highlightedAccessPoint={selectedHighlightedAccessPoint}
/>
) : (
<DeployedEnvironmentAccessPoints
appId={appId}
environmentId={selectedEnvironment}
canManageAccessPoint={canManageAccessPoint}
canReleaseAndVersion={canReleaseAndVersion}
highlightedAccessPoint={selectedHighlightedAccessPoint}
/>
)}
</div>
<ScrollArea className="relative min-h-0 flex-1 overflow-hidden">
<ScrollAreaViewport
aria-labelledby="access-point-title"
className="overscroll-contain"
data-environment={selectedEnvironment}
role="region"
style={{ overflowX: 'hidden' }}
>
<ScrollAreaContent
className="min-h-full w-full max-w-full px-6 py-2"
style={{ minWidth: 0 }}
>
{selectedEnvironment === BUILT_IN_ENVIRONMENT_ID ? (
<BuiltInAccessPoints
appId={appId}
canDeploy={canDeploy}
canManageAccessPoint={canManageAccessPoint}
canReleaseAndVersion={canReleaseAndVersion}
highlightedAccessPoint={selectedHighlightedAccessPoint}
/>
) : (
<DeployedEnvironmentAccessPoints
appId={appId}
environmentId={selectedEnvironment}
canManageAccessPoint={canManageAccessPoint}
canReleaseAndVersion={canReleaseAndVersion}
highlightedAccessPoint={selectedHighlightedAccessPoint}
/>
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</main>
)
}
@ -142,7 +162,8 @@ export default function AccessPoint({ appId }: AccessPointProps) {
workspacePermissionKeys,
})
const showEnvironmentTabs =
appDetail?.mode === AppModeEnum.WORKFLOW && capabilities.canViewAccessPoint
(appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.ADVANCED_CHAT) &&
capabilities.canViewAccessPoint
return (
<AccessPointStateBoundary appId={appId} environmentQueryEnabled={showEnvironmentTabs}>

View File

@ -1,16 +1,17 @@
'use client'
import type { ComponentProps } from 'react'
import type { AccessPointStatus } from './access-point-status'
import type { AccessPointStatus } from '@/app/components/base/access-point/status'
import type { AppModeEnum } from '@/types/app'
import { Button, buttonVariants } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { AccessPointCard } from '@/app/components/base/access-point/card'
import { AccessPointUrl } from '@/app/components/base/access-point/url'
import { useDocLink } from '@/context/i18n'
import Link from '@/next/link'
import { AccessPointCard } from './access-point-card'
import { AccessPointUrl } from './access-point-url'
import { ApiSecretKeyButton } from './api-secret-key-button'
import { useAccessPointStatusLabel } from './use-access-point-status-label'
import { getAppApiReferencePath } from './utils'
type ServiceApiCardViewProps = {
@ -40,6 +41,7 @@ export function ServiceApiCardView({
const docLink = useDocLink()
const apiReferencePath = appMode ? getAppApiReferencePath(appMode) : undefined
const apiReferenceUrl = apiReferencePath ? docLink(apiReferencePath) : undefined
const statusLabel = useAccessPointStatusLabel(status)
return (
<AccessPointCard
@ -49,6 +51,7 @@ export function ServiceApiCardView({
})}
icon="i-custom-vender-knowledge-api-aggregate"
status={status}
statusLabel={statusLabel}
highlighted={highlighted}
switchDisabled={switchDisabled}
switchLabel={t(($) => $['overview.apiInfo.title'], { ns: 'appOverview' })}

View File

@ -5,11 +5,9 @@ import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { toast } from '@langgenius/dify-ui/toast'
import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect } from 'react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { fetchAppDetail, updateAppSiteConfig } from '@/service/apps'
import { consoleQuery } from '@/service/client'
import { asyncRunSafe } from '@/utils'
@ -27,41 +25,21 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
}
}, [appId, setAppDetail])
const handleAppStateChanged = useCallback(async () => {
const refresh = refreshAppDetail()
const socket = webSocketClient.getSocket(appId)
if (socket) {
const timestamp = Date.now()
socket.emit('collaboration_event', {
type: 'app_state_update',
data: { timestamp },
timestamp,
})
}
await refresh
}, [appId, refreshAppDetail])
const handleResult = useCallback(
(error: Error | null, message?: I18nKeysByPrefix<'common', 'actionMsg.'>) => {
const type = error ? 'error' : 'success'
const resolvedMessage = message ?? (error ? 'modifiedUnsuccessfully' : 'modifiedSuccessfully')
if (!error) void handleAppStateChanged()
if (!error) {
void refreshAppDetail()
}
toast(t(($) => $[`actionMsg.${resolvedMessage}`], { ns: 'common' }) as string, {
type,
})
},
[handleAppStateChanged, t],
[refreshAppDetail, t],
)
useEffect(() => {
if (!appId) return
return collaborationManager.onAppStateUpdate(refreshAppDetail)
}, [appId, refreshAppDetail])
const saveSiteConfig = useCallback(
async (params: ConfigParams) => {
if (!canManageAccessPoint) return
@ -87,7 +65,6 @@ export function useAccessPointActions(appId: string, canManageAccessPoint: boole
)
return {
handleAppStateChanged,
handleResult,
refreshAppDetail,
saveSiteConfig,

View File

@ -0,0 +1,17 @@
'use client'
import type { AccessPointStatus } from '@/app/components/base/access-point/status'
import { useTranslation } from 'react-i18next'
export function useAccessPointStatusLabel(status: AccessPointStatus) {
const { t } = useTranslation()
const labels: Record<AccessPointStatus, string> = {
disabled: t(($) => $['overview.status.disable'], { ns: 'appOverview' }),
inService: t(($) => $['agentDetail.access.status.inService'], { ns: 'agentV2' }),
loading: t(($) => $.loading, { ns: 'common' }),
unavailable: t(($) => $['health.ENVIRONMENT_STATUS_FAILED'], { ns: 'deployments' }),
unsupported: t(($) => $['studio.accessPoint.notSupported'], { ns: 'deployments' }),
}
return labels[status]
}

View File

@ -6,16 +6,36 @@ type WebAppAccessControlEntryProps = {
accessConfigured: boolean
accessIcon: string
accessLabel: string
available: boolean
disabled: boolean
onClick: () => void
}
export function WebAppAccessControlEntrySkeleton({ loading }: { loading: boolean }) {
const { t } = useTranslation()
return (
<div className="-mt-1 px-4 pb-3">
<div
role={loading ? 'status' : undefined}
aria-label={loading ? t(($) => $.loading, { ns: 'common' }) : undefined}
aria-hidden={loading ? undefined : true}
className="flex h-9 w-full items-center gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2.5"
>
<span aria-hidden className="i-ri-global-line size-4 shrink-0 text-text-disabled" />
<span aria-hidden className="h-2 w-[42%] rounded-full bg-text-quaternary opacity-10" />
<span
aria-hidden
className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-disabled"
/>
</div>
</div>
)
}
export function WebAppAccessControlEntry({
accessConfigured,
accessIcon,
accessLabel,
available,
disabled,
onClick,
}: WebAppAccessControlEntryProps) {
@ -23,38 +43,27 @@ export function WebAppAccessControlEntry({
return (
<div className="-mt-1 px-4 pb-3">
{available ? (
<button
type="button"
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-background-section py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-background-section"
disabled={disabled}
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<span aria-hidden className={`${accessIcon} size-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="system-sm-regular text-text-secondary">{accessLabel}</span>
</div>
<button
type="button"
className="flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg border-[0.5px] border-divider-subtle bg-background-section py-1 pr-2 pl-2.5 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:hover:bg-background-section"
disabled={disabled}
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<span aria-hidden className={`${accessIcon} size-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="system-sm-regular text-text-secondary">{accessLabel}</span>
</div>
{!accessConfigured && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['publishApp.notSet'], { ns: 'app' })}
</span>
)}
<div className="flex size-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
</div>
</button>
) : (
<div className="flex h-9 w-full items-center gap-2 rounded-lg border-[0.5px] border-divider-subtle bg-background-section px-2.5">
<span aria-hidden className="i-ri-global-line size-4 shrink-0 text-text-disabled" />
<span className="h-2 w-[42%] rounded-full bg-text-quaternary opacity-10" />
<span
aria-hidden
className="ml-auto i-ri-arrow-right-s-line size-4 shrink-0 text-text-disabled"
/>
</div>
)}
{!accessConfigured && (
<span className="shrink-0 system-xs-regular text-text-tertiary">
{t(($) => $['publishApp.notSet'], { ns: 'app' })}
</span>
)}
<div className="flex size-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-arrow-right-s-line size-4 text-text-quaternary" />
</div>
</button>
</div>
)
}

View File

@ -1,10 +1,12 @@
import type { AccessControlSubjects } from '../specific-groups-or-members'
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { SubjectType } from '@/models/access-control'
import { AccessMode, SubjectType } from '@/models/access-control'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import AccessControlItem from '../access-control-item'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
const mockUseSearchForWhiteListCandidates = vi.fn()
@ -50,6 +52,20 @@ function ControlledDialog({
return <AddMemberOrGroupDialog subjects={subjects} onChange={handleChange} />
}
function DialogInsideAccessOption() {
return (
<RadioGroup
aria-label="Access"
value={AccessMode.SPECIFIC_GROUPS_MEMBERS}
onValueChange={() => {}}
>
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
<ControlledDialog />
</AccessControlItem>
</RadioGroup>
)
}
describe('AddMemberOrGroupDialog', () => {
const baseGroup = createGroup()
const baseMember = createMember()
@ -142,6 +158,25 @@ describe('AddMemberOrGroupDialog', () => {
expect(memberToggle).toHaveAttribute('aria-pressed', 'true')
})
it('should stay open when expanding a group inside an access option', async () => {
const user = userEvent.setup()
render(<DialogInsideAccessOption />)
const addButton = screen.getByRole('button', { name: 'common.operation.add' })
await user.click(addButton)
await user.click(
screen.getByRole('button', {
name: 'app.accessControlDialog.operateGroupAndMember.expand',
}),
)
expect(addButton).toHaveAttribute('aria-expanded', 'true')
expect(mockUseSearchForWhiteListCandidates).toHaveBeenLastCalledWith(
expect.objectContaining({ groupId: baseGroup.id }),
true,
)
})
it('should show the empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,

View File

@ -113,11 +113,7 @@ export default function AddMemberOrGroupDialog({
? selectedAccessSubjects.groups.some((group) => group.id === subject.subjectId)
: selectedAccessSubjects.members.some((member) => member.id === subject.subjectId)
const statusText = isLoading
? t(($) => $.loading, { ns: 'common' })
: hasResults
? null
: noResultLabel
const statusText = hasResults ? null : noResultLabel
return (
<Popover open={open} onOpenChange={handleOpenChange}>
@ -137,14 +133,16 @@ export default function AddMemberOrGroupDialog({
<PopoverContent
placement="bottom-end"
alignOffset={300}
className="relative flex max-h-100 w-100 flex-col overflow-hidden bg-components-panel-bg-blur p-0 backdrop-blur-[5px]"
className="relative w-100 overflow-hidden bg-components-panel-bg-blur p-0 backdrop-blur-[5px]"
onClick={(event) => event.stopPropagation()}
>
<PopoverTitle className="sr-only">{searchLabel}</PopoverTitle>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<ScrollArea className="max-h-100 overflow-hidden">
<ScrollAreaViewport
ref={scrollRootRef}
role="region"
aria-label={searchLabel}
className="max-h-100 overscroll-contain"
style={{ overflowX: 'hidden' }}
>
<ScrollAreaContent style={{ minWidth: 0 }}>
@ -184,16 +182,15 @@ export default function AddMemberOrGroupDialog({
aria-live="polite"
aria-atomic="true"
className={
statusText ? 'flex min-h-7 items-center justify-center px-2 py-0.5' : 'h-0'
isLoading || statusText
? 'flex min-h-7 items-center justify-center px-2 py-0.5 system-sm-regular text-text-tertiary'
: 'h-0'
}
>
{isLoading ? (
<>
<span className="sr-only">{statusText}</span>
<div className="w-full" aria-hidden="true">
<Loading />
</div>
</>
<div className="w-full" aria-hidden="true">
<Loading />
</div>
) : (
statusText
)}

View File

@ -4,10 +4,10 @@ import type { QueryClient } from '@tanstack/react-query'
import {
DeploymentOperationStatus,
DeploymentOperationType,
DeploymentStatus,
EnvironmentStatus,
EnvVarValueType,
OperatorType,
RuntimeState,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@ -140,11 +140,11 @@ const latestVersion = {
function createDeployment({
deployed = true,
latest = false,
status = DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState = RuntimeState.RUNTIME_STATE_RUNNING,
}: {
deployed?: boolean
latest?: boolean
status?: NonNullable<EnvironmentDeployment['deployment']>['status']
runtimeState?: NonNullable<EnvironmentDeployment['deployment']>['runtimeState']
} = {}): EnvironmentDeployment {
const currentVersion = latest
? {
@ -173,7 +173,7 @@ function createDeployment({
id: 'user-1',
type: OperatorType.OPERATOR_TYPE_ACCOUNT,
},
status,
runtimeState,
versions_behind: latest ? 0 : 1,
},
environment: {
@ -219,7 +219,7 @@ function createFlowQueryClient(environmentId: string, environmentInUse = false)
queryClient.setQueryDefaults(deploymentOptionsQuery.queryKey, { staleTime: Infinity })
queryClient.setQueryData(deploymentOptionsQuery.queryKey, {
credential_slots: [],
environment_variable_slots: [],
environment_variable_groups: [],
})
})
@ -291,7 +291,12 @@ function renderFlow(
{
canViewAccessPoint = true,
isDeploymentError = false,
}: { canViewAccessPoint?: boolean; isDeploymentError?: boolean } = {},
onConfigurationOpenChange = vi.fn(),
}: {
canViewAccessPoint?: boolean
isDeploymentError?: boolean
onConfigurationOpenChange?: (open: boolean) => void
} = {},
) {
const queryClient = createFlowQueryClient(deployment.environment.id)
@ -307,6 +312,7 @@ function renderFlow(
isDeploymentError={isDeploymentError}
isDeploymentLoading={false}
latestVersion={latestVersion}
onConfigurationOpenChange={onConfigurationOpenChange}
onGoToPublish={vi.fn()}
/>,
{ queryClient },
@ -461,11 +467,31 @@ async function expectDeploymentRequest(
)
expect(await deployRequest.json()).toEqual({
credentials: [],
environment_variables: [],
environment_variable_groups: [],
})
}
describe('PublisherEnvironmentFlow', () => {
it('shows the shared loading state while deployment details load', () => {
render(
<PublisherEnvironmentFlow
appId="app-1"
canViewAccessPoint
environmentId="development"
environmentName="Development"
environmentTabs={<div>Environment tabs</div>}
isEnvironmentInUse={false}
isDeploymentError={false}
isDeploymentLoading
latestVersion={null}
onGoToPublish={vi.fn()}
/>,
)
expect(screen.getByText('Environment tabs')).toBeInTheDocument()
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
})
it('formats deployed_at as a Unix timestamp in seconds', () => {
const deployment = createDeployment()
const deployedAt = deployment.deployment?.deployed_at
@ -554,6 +580,7 @@ describe('PublisherEnvironmentFlow', () => {
await user.click(screen.getByRole('button', { name: 'All versions' }))
expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Deploy to Staging' })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Back' }))
@ -561,17 +588,12 @@ describe('PublisherEnvironmentFlow', () => {
expect(screen.getByRole('button', { name: 'All versions' })).toBeInTheDocument()
})
it.each([
DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING,
])(
'disables deployment triggers but keeps environment navigation available while the status is %s',
(status) => {
renderFlow(createDeployment({ deployed: false, status }))
it.each([RuntimeState.RUNTIME_STATE_STARTING, RuntimeState.RUNTIME_STATE_STOPPING])(
'disables deployment triggers but keeps environment navigation available while the state is %s',
(runtimeState) => {
renderFlow(createDeployment({ deployed: false, runtimeState }))
const deployButtonName =
status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING ? 'Deploying...' : 'Deploy latest'
expect(screen.getByRole('button', { name: deployButtonName })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeDisabled()
expect(screen.getByRole('button', { name: 'All versions' })).toBeDisabled()
expect(screen.getByRole('link', { name: 'Access Point' })).toHaveAttribute(
'href',
@ -596,7 +618,7 @@ describe('PublisherEnvironmentFlow', () => {
it('keeps the deployment target and progress controls when a deploying status refresh fails', () => {
const deployment = createDeployment({
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
})
deployment.deployment!.latest_operation = {
activity_at: 1_785_456_000,
@ -638,16 +660,16 @@ describe('PublisherEnvironmentFlow', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYED,
DeploymentStatus.DEPLOYMENT_STATUS_FAILED,
])('shows the undeployed state when terminal status %s has no current version', (status) => {
renderFlow(createDeployment({ deployed: false, status }))
it.each([RuntimeState.RUNTIME_STATE_UNDEPLOYED, RuntimeState.RUNTIME_STATE_ERROR])(
'shows the undeployed state when terminal state %s has no current version',
(runtimeState) => {
renderFlow(createDeployment({ deployed: false, runtimeState }))
expect(screen.getByText('Not deployed yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'All versions' })).toBeEnabled()
})
expect(screen.getByText('Not deployed yet')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Deploy latest' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'All versions' })).toBeEnabled()
},
)
it('deploys the latest version directly and goes back to version selection', async () => {
const user = userEvent.setup()
@ -663,6 +685,29 @@ describe('PublisherEnvironmentFlow', () => {
expect(screen.getByRole('heading', { name: 'Deploy to Staging' })).toBeInTheDocument()
})
it('reports whether deployment configuration is active', async () => {
const user = userEvent.setup()
const onConfigurationOpenChange = vi.fn()
const view = renderFlow(createDeployment(), { onConfigurationOpenChange })
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(true)
await user.click(screen.getByRole('button', { name: 'Back' }))
expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(false)
await user.click(screen.getByRole('button', { name: /Release 6/ }))
expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(true)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(false)
await user.click(screen.getByRole('button', { name: 'Deploy latest' }))
expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(true)
view.unmount()
expect(onConfigurationOpenChange).toHaveBeenLastCalledWith(false)
})
it('hides the environment variables section when deployment options have no slots', async () => {
const user = userEvent.setup()
renderFlow()
@ -691,14 +736,26 @@ describe('PublisherEnvironmentFlow', () => {
)
view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, {
credential_slots: [],
environment_variable_slots: [
environment_variable_groups: [
{
configured_value: 'production',
description: '',
has_configured_value: true,
has_last_deployed_value: false,
key: 'ENVIRONMENT',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
environment_variable_slots: [
{
configured_value: 'production',
description: '',
has_configured_value: true,
has_last_deployed_value: false,
key: 'ENVIRONMENT',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
},
],
from_app: {
app_id: 'app-1',
icon: '💰',
icon_background: '#FDF2FA',
icon_type: 'emoji',
name: 'Finance APP',
workflow_id: latestVersion.id,
},
},
],
})

View File

@ -1,8 +1,5 @@
/* oxlint-disable typescript/no-explicit-any */
import {
DeploymentStatus,
EnvironmentStatus,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { EnvironmentStatus, RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
@ -458,6 +455,8 @@ describe('AppPublisher', () => {
expect(mockUpdateWorkflow).toHaveBeenCalledWith(
{
appId: 'app-1',
appMode: AppModeEnum.WORKFLOW,
url: '/apps/app-1/workflows/workflow-version-5',
title: 'Release 6',
releaseNotes: 'Updated notes',
@ -726,7 +725,7 @@ describe('AppPublisher', () => {
marked_name: 'Release 5',
version: 'v5',
},
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
},
environment: {
description: '',

View File

@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { PublisherPanel } from '../publisher-content/publisher-panel'
vi.mock('../environment-deployment-flow', () => ({
PublisherEnvironmentFlow: ({
onConfigurationOpenChange,
}: {
onConfigurationOpenChange?: (open: boolean) => void
}) => (
<div>
Environment publisher
<button type="button" onClick={() => onConfigurationOpenChange?.(true)}>
Configure deployment
</button>
</div>
),
}))
function PublisherPanelHarness() {
const [open, setOpen] = useState(true)
return (
<>
<button type="button">Outside control</button>
<PublisherPanel
builtInPublisher={{
actions: {
appDetail: null,
appURL: '',
canViewAccessPoint: false,
disabledFunctionButton: false,
workflowToolIsLoading: false,
onConfigureWorkflowTool: vi.fn(),
},
summary: {
formatTimeFromNow: () => '',
handlePublish: vi.fn(),
handleRestore: vi.fn(),
isChatApp: false,
published: false,
upgradeHighlightStyle: {},
},
}}
environmentPublisher={{
appId: 'app-1',
canViewAccessPoint: false,
environmentId: 'staging',
environmentName: 'Staging',
environmentTabs: null,
isEnvironmentInUse: true,
isDeploymentError: false,
isDeploymentLoading: false,
onGoToPublish: vi.fn(),
}}
environmentPublisherKey="staging"
open={open}
showBuiltInPublisher={false}
workflowLaunch={{
hiddenVariables: [],
open: false,
targetUrl: '',
onOpenChange: vi.fn(),
}}
onOpenChange={setOpen}
/>
</>
)
}
describe('PublisherPanel', () => {
it('keeps the publisher open after an outside press when dismissal is prevented', async () => {
const user = userEvent.setup()
render(<PublisherPanelHarness />)
await user.click(screen.getByRole('button', { name: 'Configure deployment' }))
await user.click(screen.getByRole('button', { name: 'Outside control' }))
expect(screen.getByText('Environment publisher')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute(
'aria-expanded',
'true',
)
})
it('keeps the default outside-press dismissal outside deployment configuration', async () => {
const user = userEvent.setup()
render(<PublisherPanelHarness />)
await user.click(screen.getByRole('button', { name: 'Outside control' }))
expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /common\.publish/ })).toHaveAttribute(
'aria-expanded',
'false',
)
})
it('still closes from the trigger and Escape when outside dismissal is prevented', async () => {
const user = userEvent.setup()
render(<PublisherPanelHarness />)
const publishButton = screen.getByRole('button', { name: /common\.publish/ })
await user.click(screen.getByRole('button', { name: 'Configure deployment' }))
await user.click(publishButton)
expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument()
await user.click(publishButton)
expect(screen.getByText('Environment publisher')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Configure deployment' }))
await user.keyboard('{Escape}')
expect(screen.queryByText('Environment publisher')).not.toBeInTheDocument()
})
})

View File

@ -1,6 +1,6 @@
import {
DeploymentOperationStatus,
DeploymentStatus,
RuntimeState,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { QueryClientProvider } from '@tanstack/react-query'
import { screen, render as testingLibraryRender, waitFor } from '@testing-library/react'
@ -32,7 +32,7 @@ type QueryOptions = {
id: string
status: string
}
status: string
runtimeState: string
}
}
}
@ -204,11 +204,11 @@ function renderState(initialOpen = true) {
}
function environmentDeploymentResponse({
deploymentStatus,
runtimeState,
operationId,
operationStatus,
}: {
deploymentStatus: string
runtimeState: string
operationId: string
operationStatus: string
}) {
@ -220,7 +220,7 @@ function environmentDeploymentResponse({
},
deployment: {
current_version:
deploymentStatus === DeploymentStatus.DEPLOYMENT_STATUS_RUNNING
runtimeState === RuntimeState.RUNTIME_STATE_RUNNING
? {
id: 'version-development',
marked_comment: '',
@ -239,7 +239,7 @@ function environmentDeploymentResponse({
status: operationStatus,
type: 'DEPLOYMENT_OPERATION_TYPE_DEPLOY',
},
status: deploymentStatus,
runtimeState,
},
environment: {
description: '',
@ -316,7 +316,7 @@ describe('app publisher environment state', () => {
marked_name: `Release ${input.params.environment_id}`,
version: `2026-07-31.${input.params.environment_id}`,
},
status: 'DEPLOYMENT_STATUS_RUNNING',
runtimeState: 'RUNTIME_STATE_RUNNING',
},
environment: {
description: '',
@ -361,7 +361,7 @@ describe('app publisher environment state', () => {
it('discovers and resumes polling a first deployment while the environment remains not in use', async () => {
const user = userEvent.setup()
const response = environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
})
@ -394,7 +394,7 @@ describe('app publisher environment state', () => {
it('stops automatic status polling while the deployment query is failing', async () => {
const user = userEvent.setup()
const response = environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
})
@ -465,7 +465,7 @@ describe('app publisher environment state', () => {
.mockResolvedValue(appEnvironments(true))
queryMocks.deploymentRequest.mockResolvedValue(
environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
@ -497,7 +497,7 @@ describe('app publisher environment state', () => {
deploymentQueryOptions?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
}),
@ -508,7 +508,7 @@ describe('app publisher environment state', () => {
deploymentQueryOptions?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
@ -525,12 +525,12 @@ describe('app publisher environment state', () => {
async (input: { params: { environment_id: string } }) =>
input.params.environment_id === 'staging'
? environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
})
: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
@ -551,7 +551,7 @@ describe('app publisher environment state', () => {
getDeploymentQueryOptions('staging')?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
}),
@ -577,7 +577,7 @@ describe('app publisher environment state', () => {
getDeploymentQueryOptions('development')?.refetchInterval?.({
state: {
data: environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
operationId: 'operation-development',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_SUCCEEDED,
}),
@ -608,7 +608,7 @@ describe('app publisher environment state', () => {
queryMocks.environmentRequest.mockResolvedValue(appEnvironments(true))
queryMocks.deploymentRequest.mockResolvedValue(
environmentDeploymentResponse({
deploymentStatus: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
operationId: 'operation-staging',
operationStatus: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
}),

View File

@ -2,10 +2,11 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { DeploymentVersion } from '@/app/components/app/deploy/version'
import type { DeploymentVersion } from '@/app/components/app/deploy/utils/version'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/environment-deployment-flow'
import { EnvironmentDeploymentFlow } from '@/app/components/app/deploy/shared/environment-deployment-flow'
import Loading from '@/app/components/base/loading'
import {
publisherEnvironmentDeploymentPollingAtom,
startPublisherEnvironmentDeploymentPollingAtom,
@ -24,6 +25,7 @@ type PublisherEnvironmentFlowProps = {
isDeploymentError: boolean
isDeploymentLoading: boolean
latestVersion?: DeploymentVersion | null
onConfigurationOpenChange?: (open: boolean) => void
onGoToPublish: () => void
}
@ -38,6 +40,7 @@ export function PublisherEnvironmentFlow({
isDeploymentError,
isDeploymentLoading,
latestVersion,
onConfigurationOpenChange,
onGoToPublish,
}: PublisherEnvironmentFlowProps) {
const { t } = useTranslation()
@ -48,19 +51,16 @@ export function PublisherEnvironmentFlow({
return (
<div aria-busy={isDeploymentLoading} className="flex min-h-40 flex-col gap-3 p-4">
{environmentTabs}
<div
role={isDeploymentError ? 'alert' : 'status'}
className="flex flex-1 items-center justify-center gap-2 system-sm-regular text-text-tertiary"
>
{isDeploymentLoading ? (
<>
<span aria-hidden className="i-ri-loader-2-line size-4 animate-spin" />
{t(($) => $.loading, { ns: 'common' })}
</>
) : (
t(($) => $['common.loadFailed'], { ns: 'deployments' })
)}
</div>
{isDeploymentLoading ? (
<Loading className="flex-1" />
) : (
<div
role="alert"
className="flex flex-1 items-center justify-center system-sm-regular text-text-tertiary"
>
{t(($) => $['common.loadFailed'], { ns: 'deployments' })}
</div>
)}
</div>
)
}
@ -72,6 +72,7 @@ export function PublisherEnvironmentFlow({
disabled={deploymentPolling?.environmentId === environmentId}
environmentId={environmentId}
environmentName={environmentName}
onConfigurationOpenChange={onConfigurationOpenChange}
onDeploymentStarted={(operationId) => {
startDeploymentPolling({ environmentId, operationId })
}}

View File

@ -1,4 +1,4 @@
import type { DeploymentVersion } from '@/app/components/app/deploy/version'
import type { DeploymentVersion } from '@/app/components/app/deploy/utils/version'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { PublisherDeployingMarker } from '../publisher-deploying-marker'

View File

@ -1,7 +1,10 @@
import type { EnvironmentDeployment } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { ReactNode } from 'react'
import type { DeploymentVersion } from '@/app/components/app/deploy/version'
import { DeploymentStatus } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { DeploymentVersion } from '@/app/components/app/deploy/utils/version'
import {
DeploymentOperationStatus,
DeploymentOperationType,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { Button } from '@langgenius/dify-ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
@ -35,8 +38,11 @@ export function PublisherEnvironmentSummarySection({
const { formatTimeFromNow } = useFormatTimeFromNow()
const deploymentState = deployment?.deployment
const deployedVersion = deploymentState?.current_version
const isDeploying = deploymentState?.status === DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING
const deployingVersion = deploymentState?.latest_operation?.target_version
const latestOperation = deploymentState?.latest_operation
const isDeploying =
latestOperation?.type === DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_DEPLOY &&
latestOperation.status === DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS
const deployingVersion = latestOperation?.target_version
const deployingVersionName = deployingVersion
? getWorkflowVersionName(
deployingVersion,

View File

@ -4,7 +4,7 @@ import { DeploymentOperationStatus } from '@dify/contracts/enterprise-app-deploy
import { useQueryClient } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useRef } from 'react'
import { isEnvironmentDeploymentInProgress } from '@/app/components/app/deploy/state'
import { shouldPollEnvironmentDeployment } from '@/app/components/app/deploy/utils/environment-deployment'
import { consoleQuery } from '@/service/client'
import {
appPublisherOpenAtom,
@ -44,7 +44,7 @@ export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?:
polling.environmentId !== environment.id ||
operation?.id !== polling.operationId ||
!isDeploymentOperationTerminal(operation.status) ||
isEnvironmentDeploymentInProgress(deployment)
shouldPollEnvironmentDeployment(deployment)
)
return
@ -72,7 +72,7 @@ export function useRefreshAppEnvironmentsAfterPublisherDeploymentPolling(appId?:
return
}
if (isEnvironmentDeploymentInProgress(deployment)) {
if (shouldPollEnvironmentDeployment(deployment)) {
if (operationKey) operationsNeedingEnvironmentRefreshRef.current.add(operationKey)
return
}

View File

@ -22,7 +22,9 @@ export function AppPublisher(props: AppPublisherProps) {
resourceMaintainer: appDetail?.maintainer,
workspacePermissionKeys,
})
const supportsMultiEnvironment = appDetail?.mode === AppModeEnum.WORKFLOW && canDeploy
const supportsMultiEnvironment =
(appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.ADVANCED_CHAT) &&
canDeploy
return (
<AppPublisherStateBoundary

View File

@ -2,7 +2,7 @@ import type { AppPublisherProps } from '../types'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue, useSetAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toDeploymentVersion } from '@/app/components/app/deploy/version'
import { toDeploymentVersion } from '@/app/components/app/deploy/utils/version'
import { useStore as useAppStore } from '@/app/components/app/store'
import { WorkflowToolDrawer } from '@/app/components/tools/workflow-tool'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
@ -115,6 +115,7 @@ export function PublisherContent({
const marketplace = useMarketplacePublish(appDetail?.id)
const versionInfo = useVersionInfo({
appId: appDetail?.id,
appMode: appDetail?.mode,
publishedWorkflow: publish.publishedWorkflow,
onClosePublisher: closePublisher,
})

View File

@ -1,7 +1,9 @@
import type { PopoverProps } from '@langgenius/dify-ui/popover'
import type { ComponentProps } from 'react'
import type { AppPublisherProps } from '../../types'
import { Button } from '@langgenius/dify-ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { WorkflowLaunchDialog } from '@/app/components/app/overview/workflow-launch-dialog'
import { BuiltInPublisher } from '../../built-in-publisher'
@ -9,7 +11,10 @@ import { PublisherEnvironmentFlow } from '../../environment-deployment-flow'
type PublisherPanelProps = Pick<AppPublisherProps, 'crossAxisOffset' | 'disabled'> & {
builtInPublisher: ComponentProps<typeof BuiltInPublisher>
environmentPublisher: ComponentProps<typeof PublisherEnvironmentFlow>
environmentPublisher: Omit<
ComponentProps<typeof PublisherEnvironmentFlow>,
'onConfigurationOpenChange'
>
environmentPublisherKey: string
open: boolean
showBuiltInPublisher: boolean
@ -29,9 +34,21 @@ export function PublisherPanel({
workflowLaunch,
}: PublisherPanelProps) {
const { t } = useTranslation()
const [deploymentConfigurationOpen, setDeploymentConfigurationOpen] = useState(false)
const handleOpenChange: NonNullable<PopoverProps['onOpenChange']> = (nextOpen, eventDetails) => {
const isOutsideDismiss =
eventDetails.reason === 'outside-press' || eventDetails.reason === 'focus-out'
if (!nextOpen && deploymentConfigurationOpen && isOutsideDismiss) {
eventDetails.cancel()
return
}
if (!nextOpen) setDeploymentConfigurationOpen(false)
onOpenChange(nextOpen)
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger
render={
<Button variant="primary" className="py-2 pr-2 pl-3" disabled={disabled}>
@ -50,7 +67,11 @@ export function PublisherPanel({
{showBuiltInPublisher ? (
<BuiltInPublisher {...builtInPublisher} />
) : (
<PublisherEnvironmentFlow key={environmentPublisherKey} {...environmentPublisher} />
<PublisherEnvironmentFlow
key={environmentPublisherKey}
{...environmentPublisher}
onConfigurationOpenChange={setDeploymentConfigurationOpen}
/>
)}
</div>
</PopoverContent>

View File

@ -1,4 +1,5 @@
import type { WorkflowResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AppModeEnum } from '@/types/app'
import { toast } from '@langgenius/dify-ui/toast'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -12,10 +13,12 @@ type VersionInfoUpdate = {
export function useVersionInfo({
appId,
appMode,
publishedWorkflow,
onClosePublisher,
}: {
appId?: string
appMode?: AppModeEnum
publishedWorkflow?: WorkflowResponse | null
onClosePublisher: () => void
}) {
@ -36,6 +39,8 @@ export function useVersionInfo({
updateWorkflow(
{
appId,
appMode,
url: `/apps/${appId}/workflows/${params.id}`,
title: params.title,
releaseNotes: params.releaseNotes,

View File

@ -6,7 +6,7 @@ import { skipToken } from '@tanstack/react-query'
import { atom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { selectAtom, useHydrateAtoms } from 'jotai/utils'
import { isEnvironmentDeploymentInProgress } from '@/app/components/app/deploy/state'
import { shouldPollEnvironmentDeployment } from '@/app/components/app/deploy/utils/environment-deployment'
import { consoleQuery } from '@/service/client'
export const BUILT_IN_ENVIRONMENT_ID = 'built-in'
@ -316,7 +316,7 @@ const selectedEnvironmentDeploymentQueryAtom = atomWithQuery((get) => {
const deployment = query.state.data?.environment_deployment
if (polling?.environmentId !== environmentId)
return isEnvironmentDeploymentInProgress(deployment)
return shouldPollEnvironmentDeployment(deployment)
? PUBLISHER_DEPLOYMENT_POLLING_INTERVAL
: false
@ -324,7 +324,7 @@ const selectedEnvironmentDeploymentQueryAtom = atomWithQuery((get) => {
const operationFinished =
operation?.id === polling.operationId && isDeploymentOperationTerminal(operation.status)
return operationFinished && !isEnvironmentDeploymentInProgress(deployment)
return operationFinished && !shouldPollEnvironmentDeployment(deployment)
? false
: PUBLISHER_DEPLOYMENT_POLLING_INTERVAL
},

View File

@ -4,19 +4,20 @@ import type {
EnvironmentDeployment,
EnvironmentDeploymentOperation,
GetWorkflowDeploymentOptionsResponse,
WorkflowReference,
WorkflowVersion,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { QueryClient } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import type { ComponentProps, ReactElement } from 'react'
import {
DeploymentOperationStatus,
DeploymentOperationType,
DeploymentStatus,
EnvironmentStatus,
EnvVarValueSource,
EnvVarValueType,
OperatorType,
PluginCategory,
RuntimeState,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { toast } from '@langgenius/dify-ui/toast'
import { act, screen, waitFor, within } from '@testing-library/react'
@ -30,7 +31,12 @@ import { createConsoleQueryClient, renderWithConsoleQuery } from '@/test/console
import { AppACLPermission } from '@/utils/permission'
import AppDeploy from '..'
import { EnvironmentTable } from '../environment-table'
import { AppDeployStateBoundary, getEnvironmentDeploymentActions } from '../state'
import { RuntimeStateIndicator } from '../shared/runtime-state'
import { AppDeployStateBoundary } from '../state'
import {
getEnvironmentDeploymentActions,
shouldPollEnvironmentDeployment,
} from '../utils/environment-deployment'
const APP_ID = 'app-1'
const ACTIVITY_AT = 1_784_941_200
@ -115,6 +121,24 @@ const SUCCESSFUL_WORKFLOW_DEPLOYMENT_PRECHECK = {
unsupported_nodes: [],
}
const ROOT_WORKFLOW_REFERENCE: WorkflowReference = {
app_id: APP_ID,
icon: '💰',
icon_background: '#FDF2FA',
icon_type: 'emoji',
name: 'Finance APP',
workflow_id: 'workflow-version-6',
}
const SUBWORKFLOW_REFERENCE: WorkflowReference = {
app_id: 'app-workflow-tool',
icon: '🐍',
icon_background: '#F3FEE7',
icon_type: 'emoji',
name: 'Workflow as Tool',
workflow_id: 'workflow-tool',
}
const WORKFLOW_DEPLOYMENT_OPTIONS: GetWorkflowDeploymentOptionsResponse = {
credential_slots: [
{
@ -151,32 +175,64 @@ const WORKFLOW_DEPLOYMENT_OPTIONS: GetWorkflowDeploymentOptionsResponse = {
category: PluginCategory.PLUGIN_CATEGORY_TOOL,
last_deployed_credential_id: 'github-oauth',
provider_id: 'github',
workflow_as_tool_dependency: {
paths: [{ workflows: [ROOT_WORKFLOW_REFERENCE, SUBWORKFLOW_REFERENCE] }],
},
},
],
environment_variable_slots: [
environment_variable_groups: [
{
configured_value: '2',
description: 'Server port',
has_configured_value: true,
has_last_deployed_value: true,
key: 'PORT',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER,
environment_variable_slots: [
{
configured_value: 2,
description: 'Server port',
has_configured_value: true,
has_last_deployed_value: true,
key: 'PORT',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER,
},
{
configured_value: 'sk-123************bc',
description: 'API credential',
has_configured_value: true,
has_last_deployed_value: true,
key: 'API_KEY',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET,
},
{
description: '',
has_configured_value: false,
has_last_deployed_value: true,
key: 'name',
last_deployed_value: 'environment variable 01',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
},
],
from_app: ROOT_WORKFLOW_REFERENCE,
},
{
configured_value: 'sk-123************bc',
description: 'API credential',
has_configured_value: true,
has_last_deployed_value: true,
key: 'API_KEY',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET,
},
{
description: '',
has_configured_value: false,
has_last_deployed_value: true,
key: 'name',
last_deployed_value: 'environment variable 01',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_STRING,
environment_variable_slots: [
{
configured_value: 8080,
description: 'Workflow tool port',
has_configured_value: true,
has_last_deployed_value: false,
key: 'PORT',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER,
},
{
configured_value: 'sk-child********bc',
description: 'Workflow tool API credential',
has_configured_value: true,
has_last_deployed_value: false,
key: 'API_KEY',
value_type: EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET,
},
],
from_workflow_as_tool: {
paths: [{ workflows: [ROOT_WORKFLOW_REFERENCE, SUBWORKFLOW_REFERENCE] }],
workflow: SUBWORKFLOW_REFERENCE,
},
},
],
}
@ -208,7 +264,7 @@ function environmentDeployment({
id,
latestOperation,
name,
status,
runtimeState,
versionsBehind,
}: {
access?: EnvironmentDeployment['access']
@ -216,7 +272,7 @@ function environmentDeployment({
id: string
latestOperation?: EnvironmentDeploymentOperation
name: string
status: NonNullable<EnvironmentDeployment['deployment']>['status']
runtimeState: NonNullable<EnvironmentDeployment['deployment']>['runtimeState']
versionsBehind?: number
}): EnvironmentDeployment {
return {
@ -226,7 +282,7 @@ function environmentDeployment({
deployed_at: currentVersion ? ACTIVITY_AT : undefined,
deployed_by: currentVersion ? OPERATOR : undefined,
latest_operation: latestOperation,
status,
runtimeState,
versions_behind: versionsBehind,
},
environment: {
@ -247,7 +303,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.sprint42,
}),
name: 'Staging',
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
}),
environmentDeployment({
currentVersion: VERSIONS.sprint42,
@ -257,7 +313,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.sprint42,
}),
name: 'Canary',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 0,
}),
environmentDeployment({
@ -268,7 +324,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.version02,
}),
name: 'Pre-release',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 1,
}),
environmentDeployment({
@ -279,7 +335,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.hotfix,
}),
name: 'Prod',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 1,
}),
environmentDeployment({
@ -292,7 +348,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.sprint42,
}),
name: 'EU-Prod',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 2,
}),
environmentDeployment({
@ -304,7 +360,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.qa,
}),
name: 'QA',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 0,
}),
environmentDeployment({
@ -316,7 +372,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.qa,
}),
name: 'Sandbox',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 0,
}),
environmentDeployment({
@ -328,7 +384,7 @@ const APP_ENVIRONMENT_DEPLOYMENTS: EnvironmentDeployment[] = [
targetVersion: VERSIONS.sprint42,
}),
name: 'Preview',
status: DeploymentStatus.DEPLOYMENT_STATUS_FAILED,
runtimeState: RuntimeState.RUNTIME_STATE_UNDEPLOYED,
}),
]
@ -367,7 +423,7 @@ const ACTION_MATRIX_CASES: Array<{
currentVersion: VERSIONS.sprint42,
id: 'latest',
name: 'Latest',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 0,
}),
},
@ -383,7 +439,7 @@ const ACTION_MATRIX_CASES: Array<{
currentVersion: VERSIONS.version02,
id: 'behind',
name: 'Behind',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 1,
}),
},
@ -393,11 +449,17 @@ const ACTION_MATRIX_CASES: Array<{
{ disabled: true, kind: 'redeploy' },
{ disabled: true, kind: 'undeploy' },
],
name: 'deploying',
name: 'upgrading while the current version keeps running',
row: environmentDeployment({
currentVersion: VERSIONS.beta,
id: 'deploying',
latestOperation: deploymentOperation({
id: 'deploying',
status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
targetVersion: VERSIONS.sprint42,
}),
name: 'Deploying',
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
}),
},
{
@ -410,8 +472,13 @@ const ACTION_MATRIX_CASES: Array<{
row: environmentDeployment({
currentVersion: VERSIONS.qa,
id: 'undeploying',
latestOperation: deploymentOperation({
id: 'undeploying',
status: DeploymentOperationStatus.DEPLOYMENT_OPERATION_STATUS_IN_PROGRESS,
type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY,
}),
name: 'Undeploying',
status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STOPPING,
}),
},
{
@ -428,7 +495,7 @@ const ACTION_MATRIX_CASES: Array<{
targetVersion: VERSIONS.sprint42,
}),
name: 'Failed',
status: DeploymentStatus.DEPLOYMENT_STATUS_FAILED,
runtimeState: RuntimeState.RUNTIME_STATE_UNDEPLOYED,
}),
},
{
@ -447,7 +514,7 @@ const ACTION_MATRIX_CASES: Array<{
targetVersion: VERSIONS.sprint42,
}),
name: 'Running failed',
status: DeploymentStatus.DEPLOYMENT_STATUS_RUNNING,
runtimeState: RuntimeState.RUNTIME_STATE_RUNNING,
versionsBehind: 2,
}),
},
@ -461,7 +528,16 @@ const ACTION_MATRIX_CASES: Array<{
currentVersion: VERSIONS.qa,
id: 'invalid',
name: 'Invalid',
status: DeploymentStatus.DEPLOYMENT_STATUS_INVALID,
runtimeState: RuntimeState.RUNTIME_STATE_ERROR,
}),
},
{
actions: [{ disabled: false, kind: 'changeVersion' }],
name: 'invalid without a current version',
row: environmentDeployment({
id: 'invalid-without-version',
name: 'Invalid without version',
runtimeState: RuntimeState.RUNTIME_STATE_ERROR,
}),
},
{
@ -474,7 +550,7 @@ const ACTION_MATRIX_CASES: Array<{
currentVersion: VERSIONS.qa,
id: 'unknown',
name: 'Unknown',
status: DeploymentStatus.DEPLOYMENT_STATUS_UNSPECIFIED,
runtimeState: RuntimeState.RUNTIME_STATE_UNKNOWN,
}),
},
]
@ -611,10 +687,14 @@ function render(
appEnvironments = APP_ENVIRONMENTS,
environmentDeployments = APP_ENVIRONMENT_DEPLOYMENTS,
publishedWorkflowVersions = PUBLISHED_WORKFLOW_VERSIONS,
seedAppEnvironments = true,
seedLatestPublishedWorkflow = true,
}: {
appEnvironments?: AppEnvironment[]
environmentDeployments?: EnvironmentDeployment[]
publishedWorkflowVersions?: WorkflowResponse[]
seedAppEnvironments?: boolean
seedLatestPublishedWorkflow?: boolean
} = {},
) {
const queryClient = createConsoleQueryClient()
@ -624,16 +704,20 @@ function render(
queryClient.setQueryDefaults(appEnvironmentDeploymentsQueryOptions.queryKey, {
staleTime: Infinity,
})
queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, {
data: appEnvironments,
})
if (seedAppEnvironments) {
queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, {
data: appEnvironments,
})
}
queryClient.setQueryData(appEnvironmentDeploymentsQueryOptions.queryKey, {
environment_deployments: environmentDeployments,
})
queryClient.setQueryData(
latestPublishedWorkflowQuery.queryKey,
mockBuiltInEnvironment.publishedWorkflow,
)
if (seedLatestPublishedWorkflow) {
queryClient.setQueryData(
latestPublishedWorkflowQuery.queryKey,
mockBuiltInEnvironment.publishedWorkflow,
)
}
queryClient.setQueryData(appWorkflowVersionsQuery.queryKey, {
pageParams: [1],
pages: [
@ -650,12 +734,32 @@ function render(
return renderWithConsoleQuery(ui, { queryClient })
}
function environmentTableProps(
overrides: Partial<ComponentProps<typeof EnvironmentTable>> = {},
): ComponentProps<typeof EnvironmentTable> {
return {
appId: APP_ID,
canViewAccessPoint: true,
onChangeVersion: vi.fn(),
onDeployLatest: vi.fn(),
onDeployToEnvironment: vi.fn(),
onRedeploy: vi.fn(),
onUndeploy: vi.fn(),
...overrides,
}
}
let appPermissionKeys: string[] = [AppACLPermission.AccessPointView, AppACLPermission.Deploy]
let appDetailAvailable = true
const mockConsoleState = vi.hoisted(() => ({
workspacePermissionKeys: [] as string[],
}))
const mockDocLink = vi.hoisted(() => vi.fn((path: string) => `https://docs.example.com${path}`))
const mockGetEnterpriseDocUrl = vi.hoisted(() =>
vi.fn(
(path: string, docLanguage: string) =>
`https://enterprise-docs.example.com/${docLanguage}${path}`,
),
)
vi.mock('react-i18next', async () => {
const { createReactI18nextMock } = await import('@/test/i18n-mock')
@ -666,14 +770,23 @@ vi.mock('react-i18next', async () => {
'deployments.studio.undeployConfirmDesc':
'The app will stop running in this environment, and all of its access points will become unavailable.',
'deployments.studio.undeployConfirmTitle': 'Undeploy {{versionName}} from {{envName}}',
'deployments.status.RUNTIME_INSTANCE_STATUS_DEPLOYING': 'Deploying',
'deployments.status.RUNTIME_INSTANCE_STATUS_FAILED': 'Deploy failed',
'deployments.status.RUNTIME_INSTANCE_STATUS_INVALID': 'Invalid',
'deployments.status.RUNTIME_INSTANCE_STATUS_READY': 'Running',
'deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYED': 'Not deployed',
'deployments.status.RUNTIME_INSTANCE_STATUS_UNDEPLOYING': 'Undeploying',
'deployments.status.RUNTIME_INSTANCE_STATUS_UNSPECIFIED': 'Unknown',
'deployments.studio.activity.deploySucceeded': 'Deploy {{target}} succeeded',
'deployments.studio.activity.meta': '{{name}} · {{time}}',
'deployments.studio.versionValue': 'Version value',
'deployments.studio.environmentsInUse': '{{used}} of {{total}} environments in use',
'deployments.studio.environmentVariablesDescription':
"Use the value from the version you're deploying, keep the last deployed value, or enter a custom one.",
'deployments.studio.precheck.from': 'From',
'deployments.studio.precheck.nodeCount_other': '{{count}} nodes',
'deployments.studio.updatedAtBy': 'Updated at {{time}} by {{name}}',
'workflow.common.workflowAsTool': 'Workflow as Tool',
'workflow.common.publishedBy': 'Published {{time}} by {{author}}',
})
})
@ -732,7 +845,8 @@ vi.mock('@/context/permission-state', async () => {
})
vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
getEnterpriseDocUrl: mockGetEnterpriseDocUrl,
useLocale: () => 'en-US',
}))
vi.mock('@langgenius/dify-ui/toast', () => ({
@ -784,7 +898,7 @@ describe('AppDeploy', () => {
expect(screen.getByRole('link', { name: 'common.operation.learnMore' })).toHaveAttribute(
'href',
'https://docs.example.com/use/deploy/overview',
'https://enterprise-docs.example.com/en/use/deploy/overview',
)
})
@ -795,6 +909,86 @@ describe('AppDeploy', () => {
},
)
it.each([
[RuntimeState.RUNTIME_STATE_UNSPECIFIED, 'Unknown'],
[RuntimeState.RUNTIME_STATE_UNDEPLOYED, 'Not deployed'],
[RuntimeState.RUNTIME_STATE_RUNNING, 'Running'],
[RuntimeState.RUNTIME_STATE_STARTING, 'Deploying'],
[RuntimeState.RUNTIME_STATE_STOPPING, 'Undeploying'],
[RuntimeState.RUNTIME_STATE_ERROR, 'Invalid'],
[RuntimeState.RUNTIME_STATE_UNKNOWN, 'Unknown'],
] as const)('renders runtime state %s as %s', (runtimeState, label) => {
render(<RuntimeStateIndicator runtimeState={runtimeState} />)
expect(screen.getByText(label)).toBeInTheDocument()
})
it.each([RuntimeState.RUNTIME_STATE_STARTING, RuntimeState.RUNTIME_STATE_STOPPING])(
'continues polling transitional state %s without a latest operation',
(runtimeState) => {
expect(
shouldPollEnvironmentDeployment(
environmentDeployment({
id: 'transitioning',
name: 'Transitioning',
runtimeState,
}),
),
).toBe(true)
},
)
it('disables Deploy latest and retries after the latest workflow request fails', async () => {
const user = userEvent.setup()
let requestCount = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!new URL(request.url).pathname.endsWith('/apps/app-1/workflows/publish'))
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
requestCount += 1
if (requestCount === 1) {
return new Response(JSON.stringify({ message: 'Failed to load the latest workflow' }), {
headers: { 'Content-Type': 'application/json' },
status: 500,
})
}
return new Response(JSON.stringify(mockBuiltInEnvironment.publishedWorkflow), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
})
render(<AppDeploy />, { seedLatestPublishedWorkflow: false })
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('deployments.studio.latestVersionLoadFailed')
const deployLatestButtons = screen.getAllByRole('button', {
name: 'deployments.studio.deployLatest',
})
expect(deployLatestButtons.length).toBeGreaterThan(0)
for (const button of deployLatestButtons) expect(button).toBeDisabled()
await user.click(within(alert).getByRole('button', { name: 'common.operation.retry' }))
await waitFor(() => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
const stagingRow = within(screen.getByRole('row', { name: /Staging/ }))
const preReleaseRow = within(screen.getByRole('row', { name: /Pre-release/ }))
await waitFor(() => {
expect(
preReleaseRow.getByRole('button', { name: 'deployments.studio.deployLatest' }),
).toBeEnabled()
})
expect(
stagingRow.getByRole('button', { name: 'deployments.studio.deployLatest' }),
).toBeDisabled()
expect(requestCount).toBe(2)
})
it('renders version, status, activity, and access from the deployment contract', () => {
render(<AppDeploy />)
@ -963,6 +1157,42 @@ describe('AppDeploy', () => {
expect(screen.queryByRole('menuitem')).not.toBeInTheDocument()
})
it('shows a retry entry when the environment list fails to load', async () => {
const user = userEvent.setup()
let requestCount = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!new URL(request.url).pathname.endsWith('/enterprise/app-deploy/apps/app-1/environments'))
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
requestCount += 1
if (requestCount === 1) {
return new Response(JSON.stringify({ message: 'Failed to load environments' }), {
headers: { 'Content-Type': 'application/json' },
status: 500,
})
}
return new Response(JSON.stringify({ data: APP_ENVIRONMENTS }), {
headers: { 'Content-Type': 'application/json' },
status: 200,
})
})
render(<AppDeploy />, { seedAppEnvironments: false })
await waitFor(() => expect(requestCount).toBe(1))
expect(screen.queryByText(/environments in use/)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
const menu = await screen.findByRole('menu')
expect(within(menu).getByRole('alert')).toHaveTextContent('deployments.common.loadFailed')
await user.click(within(menu).getByRole('button', { name: 'common.operation.retry' }))
expect(await screen.findByText('8 of 12 environments in use')).toBeInTheDocument()
expect(requestCount).toBe(2)
})
it('opens the selected environment version picker from the deploy menu', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
@ -970,9 +1200,14 @@ describe('AppDeploy', () => {
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
await user.click(await screen.findByRole('menuitem', { name: /Dev/ }))
const dialog = await screen.findByRole('dialog', {
name: 'deployments.versions.deployTo:{"name":"Dev"}',
})
const dialog = await screen.findByRole(
'dialog',
{
name: 'deployments.versions.deployTo:{"name":"Dev"}',
},
{ timeout: 3000 },
)
expect(dialog).toHaveClass('h-[min(44rem,calc(100dvh-32px))]')
expect(within(dialog).getByText('deployments.studio.chooseVersionToDeploy')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: /Release 7/ })).toBeEnabled()
expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeEnabled()
@ -1015,11 +1250,21 @@ describe('AppDeploy', () => {
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
expect(configurationDialog).not.toHaveClass('h-[min(44rem,calc(100dvh-32px))]')
expect(within(configurationDialog).getByText('Release 6')).toBeInTheDocument()
expect(within(configurationDialog).getByText('Dev')).toBeInTheDocument()
expect(
within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }),
).toHaveTextContent('Enterprise deployment key')
expect(
within(configurationDialog).queryByRole('button', { name: /Moonshot: From/ }),
).not.toBeInTheDocument()
expect(
within(configurationDialog).getByRole('button', {
name: 'Github: From Workflow as Tool',
}),
).toBeInTheDocument()
expect(within(configurationDialog).getByText('From Workflow as Tool')).toBeInTheDocument()
expect(
within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }),
).toBeEnabled()
@ -1029,19 +1274,35 @@ describe('AppDeploy', () => {
),
).toBeInTheDocument()
const portSource = within(configurationDialog).getByRole('combobox', { name: /PORT/ })
const rootVariables = within(
within(configurationDialog).getByRole('group', { name: 'Finance APP' }),
)
const subworkflowVariables = within(
within(configurationDialog).getByRole('group', { name: 'Workflow as Tool' }),
)
const portSource = rootVariables.getByRole('combobox', { name: /PORT/ })
expect(portSource).toHaveTextContent('Version value')
const portInput = within(configurationDialog).getByRole('textbox', { name: 'PORT' })
const portInput = rootVariables.getByRole('textbox', { name: 'PORT' })
expect(portInput).toBeDisabled()
expect(portInput).toHaveAttribute('placeholder', '2')
expect(within(configurationDialog).getByRole('textbox', { name: 'API_KEY' })).toHaveAttribute(
expect(rootVariables.getByRole('textbox', { name: 'API_KEY' })).toHaveAttribute(
'placeholder',
'sk-123************bc',
)
expect(within(configurationDialog).getByRole('textbox', { name: 'name' })).toHaveAttribute(
expect(rootVariables.getByRole('textbox', { name: 'name' })).toHaveAttribute(
'placeholder',
'environment variable 01',
)
expect(subworkflowVariables.getByRole('textbox', { name: 'PORT' })).toHaveAttribute(
'placeholder',
'8080',
)
await user.hover(subworkflowVariables.getByRole('button', { name: 'Workflow as Tool' }))
const sourcePreview = await screen.findByRole('dialog', { name: 'Workflow as Tool' })
expect(
within(sourcePreview).getByRole('link', { name: /Finance APP.*Workflow as Tool/ }),
).toHaveAttribute('href', '/app/app-workflow-tool/workflow')
await user.click(portSource)
expect(
@ -1077,6 +1338,150 @@ describe('AppDeploy', () => {
).toBeInTheDocument()
})
it('requires a value after refreshed deployment options reconcile the source to custom', async () => {
const user = userEvent.setup()
const deploymentRequests: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.includes('/deployment:deploy'))
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
deploymentRequests.push(request.clone())
return new Response(JSON.stringify({ message: 'Stop after capturing the request' }), {
headers: { 'Content-Type': 'application/json' },
status: 502,
})
})
const view = render(<AppDeploy />)
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
await user.click(screen.getByRole('menuitem', { name: /Dev/ }))
const versionDialog = await screen.findByRole('dialog', {
name: 'deployments.versions.deployTo:{"name":"Dev"}',
})
await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ }))
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
const rootVariables = within(
within(configurationDialog).getByRole('group', { name: 'Finance APP' }),
)
await user.click(rootVariables.getByRole('combobox', { name: /PORT/ }))
await user.click(
await screen.findByRole('option', {
name: 'deployments.deployDrawer.envVarSource.lastDeployment',
}),
)
const deploymentOptionsQuery = workflowDeploymentOptionsQueryOptions(
'workflow-version-6',
'dev',
)
act(() => {
view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, {
...WORKFLOW_DEPLOYMENT_OPTIONS,
environment_variable_groups: WORKFLOW_DEPLOYMENT_OPTIONS.environment_variable_groups.map(
(group) => ({
...group,
environment_variable_slots: group.from_app
? group.environment_variable_slots.map((slot) =>
slot.key === 'PORT'
? {
...slot,
has_configured_value: false,
has_last_deployed_value: false,
}
: slot,
)
: group.environment_variable_slots,
}),
),
})
})
await waitFor(() => {
expect(rootVariables.getByRole('combobox', { name: /PORT/ })).toHaveTextContent(
'deployments.deployDrawer.envVarSource.literal',
)
})
const customPortInput = rootVariables.getByRole('spinbutton', { name: 'PORT' })
expect(customPortInput).toBeEnabled()
await user.click(
within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }),
)
expect(deploymentRequests).toHaveLength(0)
expect(toast.error).toHaveBeenCalledWith('Finance APP · PORT: workflow.env.modal.valueRequired')
await user.type(customPortInput, '3000')
await user.click(
within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }),
)
await waitFor(() => expect(deploymentRequests).toHaveLength(1))
const body = await deploymentRequests[0]!.json()
expect(body.environment_variable_groups[0].environment_variables).toContainEqual({
key: 'PORT',
value: 3000,
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM,
})
})
it('shows a toast instead of submitting when a credential is missing', async () => {
const user = userEvent.setup()
const deploymentRequests: Request[] = []
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.includes('/deployment:deploy'))
throw new Error(`Unexpected request: ${request.method} ${request.url}`)
deploymentRequests.push(request.clone())
return new Response(JSON.stringify({ message: 'Stop after capturing the request' }), {
headers: { 'Content-Type': 'application/json' },
status: 502,
})
})
const view = render(<AppDeploy />)
const deploymentOptionsQuery = workflowDeploymentOptionsQueryOptions(
'workflow-version-6',
'dev',
)
act(() => {
view.queryClient.setQueryData(deploymentOptionsQuery.queryKey, {
...WORKFLOW_DEPLOYMENT_OPTIONS,
credential_slots: WORKFLOW_DEPLOYMENT_OPTIONS.credential_slots.map((slot) =>
slot.provider_id === 'moonshot'
? { ...slot, last_deployed_credential_id: undefined }
: slot,
),
})
})
await user.click(screen.getByRole('button', { name: 'common.appMenus.deploy' }))
await user.click(screen.getByRole('menuitem', { name: /Dev/ }))
const versionDialog = await screen.findByRole('dialog', {
name: 'deployments.versions.deployTo:{"name":"Dev"}',
})
await user.click(within(versionDialog).getByRole('button', { name: /Release 6/ }))
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
const deployButton = within(configurationDialog).getByRole('button', {
name: 'common.appMenus.deploy',
})
expect(
within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }),
).toHaveTextContent('deployments.deployDrawer.selectCredential')
expect(deployButton).toBeEnabled()
await user.click(deployButton)
expect(deploymentRequests).toHaveLength(0)
expect(toast.error).toHaveBeenCalledWith('Moonshot: deployments.deployDrawer.selectCredential')
})
it('deploys the selected workflow configuration and refreshes the deployment list', async () => {
const user = userEvent.setup()
const requests: Request[] = []
@ -1113,7 +1518,7 @@ describe('AppDeploy', () => {
targetVersion: workflowVersion('Release 6', 'workflow-version-6'),
}),
name: 'Dev',
status: DeploymentStatus.DEPLOYMENT_STATUS_DEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STARTING,
}),
],
}),
@ -1154,15 +1559,18 @@ describe('AppDeploy', () => {
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
const rootVariables = within(
within(configurationDialog).getByRole('group', { name: 'Finance APP' }),
)
await user.click(within(configurationDialog).getByRole('combobox', { name: 'Moonshot' }))
await user.click(await screen.findByRole('option', { name: 'Development key' }))
await user.click(within(configurationDialog).getByRole('combobox', { name: /PORT/ }))
await user.click(rootVariables.getByRole('combobox', { name: /PORT/ }))
await user.click(
await screen.findByRole('option', {
name: 'deployments.deployDrawer.envVarSource.literal',
}),
)
await user.type(within(configurationDialog).getByRole('spinbutton', { name: 'PORT' }), '3000')
await user.type(rootVariables.getByRole('spinbutton', { name: 'PORT' }), '3000')
await user.click(
within(configurationDialog).getByRole('button', { name: 'common.appMenus.deploy' }),
)
@ -1199,19 +1607,37 @@ describe('AppDeploy', () => {
provider_id: 'github',
},
],
environment_variables: [
environment_variable_groups: [
{
key: 'PORT',
value: '3000',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM,
environment_variables: [
{
key: 'PORT',
value: 3000,
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CUSTOM,
},
{
key: 'API_KEY',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED,
},
{
key: 'name',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED,
},
],
workflow_id: 'workflow-version-6',
},
{
key: 'API_KEY',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED,
},
{
key: 'name',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED,
environment_variables: [
{
key: 'PORT',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED,
},
{
key: 'API_KEY',
value_source: EnvVarValueSource.ENV_VAR_VALUE_SOURCE_CONFIGURED,
},
],
workflow_id: 'workflow-tool',
},
],
})
@ -1262,7 +1688,7 @@ describe('AppDeploy', () => {
type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY,
}),
name: 'Canary',
status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STOPPING,
})
: deployment,
),
@ -1402,7 +1828,7 @@ describe('AppDeploy', () => {
type: DeploymentOperationType.DEPLOYMENT_OPERATION_TYPE_UNDEPLOY,
}),
name: 'Canary',
status: DeploymentStatus.DEPLOYMENT_STATUS_UNDEPLOYING,
runtimeState: RuntimeState.RUNTIME_STATE_STOPPING,
}),
],
})
@ -1510,6 +1936,45 @@ describe('AppDeploy', () => {
).toBeInTheDocument()
})
it('keeps deployment configuration and entered values open after an outside press', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
const preReleaseRow = within(screen.getByRole('row', { name: /Pre-release/ }))
await user.click(
preReleaseRow.getByRole('button', {
name: 'deployments.studio.deployLatest',
}),
)
const configurationDialog = await screen.findByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
})
const rootVariables = within(
within(configurationDialog).getByRole('group', { name: 'Finance APP' }),
)
await user.click(rootVariables.getByRole('combobox', { name: /PORT/ }))
await user.click(
await screen.findByRole('option', {
name: 'deployments.deployDrawer.envVarSource.literal',
}),
)
const customPortInput = rootVariables.getByRole('spinbutton', { name: 'PORT' })
await user.type(customPortInput, '3000')
await user.click(document.body)
expect(configurationDialog).toBeInTheDocument()
expect(customPortInput).toHaveValue(3000)
await user.click(within(configurationDialog).getByRole('button', { name: 'Cancel' }))
expect(
screen.queryByRole('dialog', {
name: 'deployments.studio.deployConfiguration',
}),
).not.toBeInTheDocument()
})
it('opens the failed version configuration for retry without a version-selection step', async () => {
const user = userEvent.setup()
render(<AppDeploy />)
@ -1571,7 +2036,10 @@ describe('AppDeploy', () => {
const dialog = await screen.findByRole('dialog', {
name: 'deployments.studio.changeVersion · Canary',
})
expect(within(dialog).getByRole('button', { name: /Sprint-42/ })).toBeDisabled()
const versionList = within(dialog).getByRole('region', {
name: 'deployments.studio.changeVersion · Canary',
})
expect(within(versionList).getByRole('button', { name: /Sprint-42/ })).toBeDisabled()
expect(within(dialog).getByText('deployments.studio.current')).toBeInTheDocument()
})
@ -1579,7 +2047,7 @@ describe('AppDeploy', () => {
const user = userEvent.setup()
render(
<AppDeployStateBoundary appId={APP_ID}>
<EnvironmentTable appId={APP_ID} canViewAccessPoint />
<EnvironmentTable {...environmentTableProps()} />
</AppDeployStateBoundary>,
{
appEnvironments: APP_ENVIRONMENTS.map((environment) => ({
@ -1640,10 +2108,14 @@ describe('AppDeploy', () => {
queryClient.setQueryData(appEnvironmentsQueryOptions.queryKey, {
data: APP_ENVIRONMENTS,
})
queryClient.setQueryData(
latestPublishedWorkflowQuery.queryKey,
mockBuiltInEnvironment.publishedWorkflow,
)
renderWithConsoleQuery(
<AppDeployStateBoundary appId={APP_ID}>
<EnvironmentTable appId={APP_ID} canViewAccessPoint />
<EnvironmentTable {...environmentTableProps()} />
</AppDeployStateBoundary>,
{ queryClient },
)
@ -1745,7 +2217,7 @@ describe('AppDeploy', () => {
const stagingRow = within(screen.getByRole('row', { name: /Staging/ }))
expect(
stagingRow.getByRole('button', {
name: 'deployments.studio.changeVersion',
name: 'deployments.studio.deployLatest',
}),
).toBeDisabled()
@ -1756,7 +2228,7 @@ describe('AppDeploy', () => {
)
const menuItems = within(await screen.findByRole('menu')).getAllByRole('menuitem')
expect(menuItems).toHaveLength(2)
expect(menuItems).toHaveLength(1)
for (const item of menuItems) expect(item).toHaveAttribute('aria-disabled', 'true')
})
@ -1765,7 +2237,7 @@ describe('AppDeploy', () => {
const onUndeploy = vi.fn()
render(
<AppDeployStateBoundary appId={APP_ID}>
<EnvironmentTable appId={APP_ID} canViewAccessPoint onUndeploy={onUndeploy} />
<EnvironmentTable {...environmentTableProps({ onUndeploy })} />
</AppDeployStateBoundary>,
)
@ -1802,7 +2274,7 @@ describe('AppDeploy', () => {
const onUndeploy = vi.fn()
render(
<AppDeployStateBoundary appId={APP_ID}>
<EnvironmentTable appId={APP_ID} canViewAccessPoint onUndeploy={onUndeploy} />
<EnvironmentTable {...environmentTableProps({ onUndeploy })} />
</AppDeployStateBoundary>,
)

View File

@ -1,4 +1,4 @@
import { toDeploymentVersion } from '../version'
import { toDeploymentVersion } from '../utils/version'
describe('toDeploymentVersion', () => {
it('maps workflow metadata into the shared deployment version shape', () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -2,117 +2,122 @@
import type { WorkflowVersion } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { Node } from '@/app/components/workflow/types'
import { DeploymentStatus as DeploymentStatusEnum } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { RuntimeState } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { useQuery } from '@tanstack/react-query'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStore as useAppStore } from '@/app/components/app/store'
import { BlockEnum, isTriggerNode } from '@/app/components/workflow/types'
import useTimestamp from '@/hooks/use-timestamp'
import { useMCPServerDetail } from '@/service/use-tools'
import { appWorkflowQueryOptions } from '@/service/workflow-queries'
import { ACCESS_POINT_ORDER, getAccessPointHref } from '../access-point'
import { AccessPointIcon } from '../shared/access-point-icon'
import { DeploymentStatus } from '../shared/deployment-status'
import { RuntimeStateIndicator } from '../shared/runtime-state'
import { VersionLabel } from '../shared/version-label'
import { ACCESS_POINT_ORDER, getAccessPointHref } from '../utils/access-point'
function Divider() {
return <div className="i-custom-vender-deploy-line-5 h-10 w-3" />
}
export function BuiltInEnvironmentCard({ canViewAccessPoint }: { canViewAccessPoint: boolean }) {
const { t } = useTranslation('deployments')
const { formatTime } = useTimestamp()
const appDetail = useAppStore((state) => state.appDetail)
const appId = appDetail?.id ?? ''
const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId || null))
const { data: mcpServerDetail } = useMCPServerDetail(appId, Boolean(appId))
const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes)
? (publishedWorkflow.graph.nodes as Node[])
: []
const hasStartNode = publishedNodes.some((node) => node.data.type === BlockEnum.Start)
const hasTriggerNode = publishedNodes.some((node) => isTriggerNode(node.data.type))
const serviceModeAvailable = Boolean(publishedWorkflow && hasStartNode && !hasTriggerNode)
const activeAccessPoints = {
mcp: serviceModeAvailable && mcpServerDetail?.status === 'active',
serviceApi: serviceModeAvailable && Boolean(appDetail?.enable_api),
trigger: Boolean(publishedWorkflow && hasTriggerNode),
webApp: serviceModeAvailable && Boolean(appDetail?.enable_site),
}
const publishedBy = publishedWorkflow?.created_by?.name ?? appDetail?.author_name ?? '--'
const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy
const publishedVersion: WorkflowVersion | undefined = publishedWorkflow
? {
created_at: publishedWorkflow.created_at,
created_by: publishedWorkflow.created_by ?? undefined,
id: publishedWorkflow.id,
marked_comment: publishedWorkflow.marked_comment,
marked_name: publishedWorkflow.marked_name,
version: publishedWorkflow.version,
version_number: publishedWorkflow.version_number ?? undefined,
}
: undefined
export const BuiltInEnvironmentCard = memo(
({ canViewAccessPoint }: { canViewAccessPoint: boolean }) => {
const { t } = useTranslation('deployments')
const { formatTime } = useTimestamp()
const appDetail = useAppStore((state) => state.appDetail)
const appId = appDetail?.id ?? ''
const { data: publishedWorkflow } = useQuery(appWorkflowQueryOptions(appId || null))
const { data: mcpServerDetail } = useMCPServerDetail(appId, Boolean(appId))
const publishedNodes = Array.isArray(publishedWorkflow?.graph.nodes)
? (publishedWorkflow.graph.nodes as Node[])
: []
const hasStartNode = publishedNodes.some((node) => node.data.type === BlockEnum.Start)
const hasTriggerNode = publishedNodes.some((node) => isTriggerNode(node.data.type))
const serviceModeAvailable = Boolean(publishedWorkflow && hasStartNode && !hasTriggerNode)
const activeAccessPoints = {
mcp: serviceModeAvailable && mcpServerDetail?.status === 'active',
serviceApi: serviceModeAvailable && Boolean(appDetail?.enable_api),
trigger: Boolean(publishedWorkflow && hasTriggerNode),
webApp: serviceModeAvailable && Boolean(appDetail?.enable_site),
}
const publishedBy = publishedWorkflow?.created_by?.name ?? appDetail?.author_name ?? '--'
const updatedBy = publishedWorkflow?.updated_by?.name ?? publishedBy
const publishedVersion: WorkflowVersion | undefined = publishedWorkflow
? {
created_at: publishedWorkflow.created_at,
created_by: publishedWorkflow.created_by ?? undefined,
id: publishedWorkflow.id,
marked_comment: publishedWorkflow.marked_comment,
marked_name: publishedWorkflow.marked_name,
version: publishedWorkflow.version,
version_number: publishedWorkflow.version_number ?? undefined,
}
: undefined
return (
<section
aria-labelledby="built-in-environment-title"
className="shrink-0 rounded-[14px] bg-background-section p-1"
>
<div className="flex flex-col gap-2.5 rounded-[10px] border-[0.5px] border-divider-subtle bg-components-panel-bg p-4">
{/* Icon */}
<div className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-regular">
<span aria-hidden className="i-ri-instance-line size-5 text-text-secondary" />
</div>
{/* Info */}
<div className="flex items-center gap-10">
<div className="flex flex-col gap-1">
<h2 id="built-in-environment-title" className="system-md-semibold text-text-primary">
{t(($) => $['studio.builtInTitle'])}
</h2>
<p className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['studio.builtInDescription'])}
</p>
return (
<section
aria-labelledby="built-in-environment-title"
className="shrink-0 rounded-[14px] bg-background-section p-1"
>
<div className="flex flex-col gap-2.5 rounded-[10px] border-[0.5px] border-divider-subtle bg-components-panel-bg p-4">
{/* Icon */}
<div className="flex size-9 items-center justify-center rounded-lg border-[0.5px] border-divider-regular">
<span aria-hidden className="i-ri-instance-line size-5 text-text-secondary" />
</div>
<Divider />
<div className="flex flex-col gap-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.liveVersion'])}
{/* Info */}
<div className="flex items-center gap-10">
<div className="flex flex-col gap-1">
<h2 id="built-in-environment-title" className="system-md-semibold text-text-primary">
{t(($) => $['studio.builtInTitle'])}
</h2>
<p className="truncate system-xs-regular text-text-tertiary">
{t(($) => $['studio.builtInDescription'])}
</p>
</div>
<VersionLabel version={publishedVersion} isLatest />
</div>
<Divider />
<div className="flex flex-col gap-1">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.accessPoints'])}
<Divider />
<div className="flex flex-col gap-1">
<div className="system-2xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.liveVersion'])}
</div>
<VersionLabel version={publishedVersion} isLatest />
</div>
<div className="flex items-center gap-1">
{ACCESS_POINT_ORDER.map((accessPoint) => (
<AccessPointIcon
key={accessPoint}
accessPoint={accessPoint}
active={activeAccessPoints[accessPoint]}
href={
canViewAccessPoint
? getAccessPointHref(appId, 'built-in', accessPoint)
: undefined
}
/>
))}
<Divider />
<div className="flex flex-col gap-1">
<div className="system-xs-medium-uppercase text-text-tertiary">
{t(($) => $['studio.accessPoints'])}
</div>
<div className="flex items-center gap-1">
{ACCESS_POINT_ORDER.map((accessPoint) => (
<AccessPointIcon
key={accessPoint}
accessPoint={accessPoint}
active={activeAccessPoints[accessPoint]}
href={
canViewAccessPoint
? getAccessPointHref(appId, 'built-in', accessPoint)
: undefined
}
/>
))}
</div>
</div>
</div>
</div>
</div>
{/* Status and updated time */}
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
<DeploymentStatus status={DeploymentStatusEnum.DEPLOYMENT_STATUS_RUNNING} />
<p className="truncate system-xs-regular text-text-tertiary">
{publishedWorkflow
? t(($) => $['studio.updatedAtBy'], {
name: updatedBy,
time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'),
})
: '--'}
</p>
</div>
</section>
)
}
{/* Status and updated time */}
<div className="flex items-center justify-between gap-4 px-4 py-2.5">
<RuntimeStateIndicator runtimeState={RuntimeState.RUNTIME_STATE_RUNNING} />
<p className="truncate system-xs-regular text-text-tertiary">
{publishedWorkflow
? t(($) => $['studio.updatedAtBy'], {
name: updatedBy,
time: formatTime(publishedWorkflow.updated_at, 'MM-DD HH:mm'),
})
: '--'}
</p>
</div>
</section>
)
},
)
BuiltInEnvironmentCard.displayName = 'BuiltInEnvironmentCard'

View File

@ -1,262 +0,0 @@
'use client'
import type { Dispatch, SetStateAction } from 'react'
import type { DeploymentVersion } from '../../version'
import type { DeploymentDialogRequest } from '../types'
import type { DeploymentConfigurationQueryState } from './use-deployment-configuration-queries'
import type { DeploymentConfigurationValues } from './use-deployment-configuration-values'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { CredentialField } from './credential-field'
import { DeploymentPrecheckAlert } from './deployment-precheck-alert'
import { EnvironmentVariableField } from './environment-variable-field'
import {
credentialSlotKey,
defaultCredentialId,
defaultEnvironmentVariableSelection,
} from './workflow-deployment-input'
function SectionHeading({ title, description }: { title: string; description: string }) {
return (
<div className="flex min-w-0 flex-col">
<h3 className="system-md-semibold text-text-primary">{title}</h3>
<p className="system-xs-regular text-text-tertiary">{description}</p>
</div>
)
}
function errorMessage(error: unknown, fallback: string) {
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string' &&
error.message.trim()
) {
return error.message.trim()
}
return fallback
}
function ConfigurationError({ messages }: { messages: string[] }) {
const { t } = useTranslation('common')
return (
<div role="alert" className="flex gap-2 text-text-destructive">
<span aria-hidden className="mt-0.5 i-ri-error-warning-fill size-4 shrink-0" />
<div className="min-w-0">
<p className="system-sm-semibold">{t(($) => $.error)}</p>
<ul className="mt-1 list-disc space-y-1 pl-4 system-xs-regular">
{messages.map((message) => (
<li key={message} className="wrap-break-word">
{message}
</li>
))}
</ul>
</div>
</div>
)
}
function ConfigurationLoading({ label }: { label: string }) {
return (
<div role="status" className="flex items-center justify-center gap-2 py-8 text-text-tertiary">
<span
aria-hidden
className="i-ri-loader-2-line size-4 animate-spin motion-reduce:animate-none"
/>
<span className="system-xs-regular">{label}</span>
</div>
)
}
export function DeploymentConfigurationContent({
compact = false,
onValuesChange,
queryState,
request,
values,
version,
}: {
compact?: boolean
onValuesChange: Dispatch<SetStateAction<DeploymentConfigurationValues>>
queryState: DeploymentConfigurationQueryState
request: DeploymentDialogRequest
values: DeploymentConfigurationValues
version: DeploymentVersion
}) {
const { t } = useTranslation('deployments')
const { t: tCommon } = useTranslation('common')
const horizontalPaddingClassName = compact ? 'px-4' : 'px-6'
const {
deploymentOptions,
deploymentOptionsError,
isLoadingDeploymentOptions,
isPrecheckBlocked,
isPrechecking,
precheck,
precheckError,
} = queryState
const unsupportedNodes = precheck?.unsupported_nodes ?? []
const showPrecheckAlert = !isPrechecking && !precheckError && isPrecheckBlocked
const showConfiguration = Boolean(deploymentOptions)
const credentialSlots = deploymentOptions?.credential_slots ?? []
const hasCredentialSlots = credentialSlots.length > 0
const environmentVariableSlots = deploymentOptions?.environment_variable_slots ?? []
return (
<>
<div
className={cn(
'shrink-0 pt-2',
showPrecheckAlert ? 'pb-0' : 'border-b border-divider-regular pb-4',
horizontalPaddingClassName,
)}
>
<div className="flex items-center justify-between gap-3 rounded-xl bg-background-section p-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span aria-hidden className="i-ri-stack-line size-3.5 shrink-0 text-text-tertiary" />
<span className="truncate system-sm-medium text-text-secondary">{version.name}</span>
</div>
<span
aria-hidden
className="i-ri-arrow-right-line size-3.5 shrink-0 text-text-tertiary"
/>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2">
<span aria-hidden className="i-ri-instance-line size-3.5 shrink-0 text-text-tertiary" />
<span className="truncate system-sm-medium text-text-secondary">
{request.environment}
</span>
</div>
</div>
</div>
<div
aria-busy={isPrechecking || isLoadingDeploymentOptions}
className="min-h-0 flex-1 overflow-y-auto"
>
{isPrechecking && (
<ConfigurationLoading label={t(($) => $['versions.checkingReleaseContent'])} />
)}
{!isPrechecking && precheckError && (
<div className={cn('py-4', horizontalPaddingClassName)}>
<ConfigurationError
messages={[
errorMessage(
precheckError,
tCommon(($) => $.error),
),
]}
/>
</div>
)}
{showPrecheckAlert && (
<div
className={cn('border-b border-divider-regular pt-2 pb-4', horizontalPaddingClassName)}
>
<DeploymentPrecheckAlert nodes={unsupportedNodes} />
</div>
)}
{isLoadingDeploymentOptions && <ConfigurationLoading label={tCommon(($) => $.loading)} />}
{!isLoadingDeploymentOptions && deploymentOptionsError && (
<div className={cn('py-4', horizontalPaddingClassName)}>
<ConfigurationError
messages={[
errorMessage(
deploymentOptionsError,
t(($) => $['deployDrawer.bindingOptionsFailed']),
),
]}
/>
</div>
)}
{showConfiguration && (
<>
{hasCredentialSlots && (
<section className={cn('flex flex-col gap-4 py-4', horizontalPaddingClassName)}>
<SectionHeading
title={t(($) => $['deployDrawer.runtimeCredentials'])}
description={t(($) => $['deployDrawer.bindingSelectionHint'])}
/>
{credentialSlots.map((slot) => {
const slotKey = credentialSlotKey(slot)
return (
<CredentialField
key={slotKey}
slot={slot}
value={values.credentials[slotKey] ?? defaultCredentialId(slot)}
onChange={(value) =>
onValuesChange((current) => ({
...current,
credentials: {
...current.credentials,
[slotKey]: value,
},
}))
}
/>
)
})}
</section>
)}
{environmentVariableSlots.length > 0 ? (
<section
className={cn(
'flex flex-col gap-4 py-4',
hasCredentialSlots && 'border-t border-divider-regular',
horizontalPaddingClassName,
)}
>
<SectionHeading
title={t(($) => $['deployDrawer.envVars'])}
description={t(($) => $['studio.environmentVariablesDescription'])}
/>
{environmentVariableSlots.map((slot) => {
const selection =
values.environmentVariables[slot.key] ??
defaultEnvironmentVariableSelection(slot)
return (
<EnvironmentVariableField
key={slot.key}
slot={slot}
source={selection.source}
customValue={selection.customValue}
onSourceChange={(source) =>
onValuesChange((current) => ({
...current,
environmentVariables: {
...current.environmentVariables,
[slot.key]: {
...selection,
source,
},
},
}))
}
onCustomValueChange={(customValue) =>
onValuesChange((current) => ({
...current,
environmentVariables: {
...current.environmentVariables,
[slot.key]: {
...selection,
customValue,
},
},
}))
}
/>
)
})}
</section>
) : null}
</>
)}
</div>
</>
)
}

View File

@ -1,63 +0,0 @@
'use client'
import type { UnsupportedNode } from '@dify/contracts/enterprise-app-deploy/types.gen'
import type { Emoji } from '@/app/components/tools/types'
import { useTranslation } from 'react-i18next'
import BlockIcon from '@/app/components/workflow/block-icon'
import { BlockEnum } from '@/app/components/workflow/types'
import { useGetProviderIcon } from './use-provider-icon'
const WORKFLOW_BLOCK_TYPES = new Set<string>(Object.values(BlockEnum))
function isWorkflowBlockType(type: string): type is BlockEnum {
return WORKFLOW_BLOCK_TYPES.has(type)
}
function UnsupportedNodeIcon({ node, icon }: { node: UnsupportedNode; icon?: string | Emoji }) {
if (!isWorkflowBlockType(node.type)) {
return (
<span className="flex size-4 shrink-0 items-center justify-center rounded-[5px] bg-components-icon-bg-midnight-solid text-white shadow-xs">
<span aria-hidden className="i-ri-node-tree size-3" />
</span>
)
}
return <BlockIcon type={node.type} size="xs" toolIcon={icon} />
}
export function DeploymentPrecheckAlert({ nodes }: { nodes: UnsupportedNode[] }) {
const { t } = useTranslation('deployments')
const getProviderIcon = useGetProviderIcon(nodes)
return (
<div
role="alert"
className="relative flex flex-col gap-2 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-xs backdrop-blur-[5px]"
>
<div className="pointer-events-none absolute inset-0 bg-linear-to-r from-components-badge-status-light-warning-halo to-background-gradient-mask-transparent opacity-40" />
<span
aria-hidden
className="relative i-ri-alert-fill size-4 shrink-0 text-text-warning-secondary"
/>
<div className="relative flex min-w-0 flex-col gap-1">
<p className="system-sm-medium text-text-primary">{t(($) => $['studio.precheck.title'])}</p>
<p className="system-xs-regular text-text-secondary">
{t(($) => $['studio.precheck.description'])}
</p>
<ul className="flex flex-col gap-2 py-1">
{nodes.map((node) => (
<li key={node.id} className="flex min-w-0 items-center gap-2">
<UnsupportedNodeIcon node={node} icon={getProviderIcon(node)} />
<span className="min-w-0 truncate system-xs-medium text-text-secondary">
{node.title}
</span>
</li>
))}
</ul>
<p className="system-xs-regular text-text-secondary">
{t(($) => $['studio.precheck.supportMessage'])}
</p>
</div>
</div>
)
}

View File

@ -1,129 +0,0 @@
'use client'
import type {
EnvironmentVariableSlot,
EnvVarValueSource,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import {
EnvVarValueSource as EnvVarValueSourceEnum,
EnvVarValueType,
} from '@dify/contracts/enterprise-app-deploy/types.gen'
import { Input } from '@langgenius/dify-ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectTrigger,
} from '@langgenius/dify-ui/select'
import { useTranslation } from 'react-i18next'
export function EnvironmentVariableField({
slot,
source,
customValue,
onSourceChange,
onCustomValueChange,
}: {
slot: EnvironmentVariableSlot
source: EnvVarValueSource
customValue: string
onSourceChange: (source: EnvVarValueSource) => void
onCustomValueChange: (value: string) => void
}) {
const { t } = useTranslation('deployments')
const sourceLabels: Partial<Record<EnvVarValueSource, string>> = {
[EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED]: t(($) => $['studio.versionValue']),
[EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM]: t(
($) => $['deployDrawer.envVarSource.literal'],
),
[EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]: t(
($) => $['deployDrawer.envVarSource.lastDeployment'],
),
}
const availableSources = [
...(slot.has_last_deployed_value
? [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]
: []),
...(slot.has_configured_value ? [EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED] : []),
EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM,
]
const valueTypeLabel =
slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER
? t(($) => $['deployDrawer.envVarType.number'])
: slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET
? t(($) => $['deployDrawer.envVarType.secret'])
: t(($) => $['deployDrawer.envVarType.string'])
const inputId = `deployment-env-${slot.key}`
const editable = source === EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CUSTOM
const inputType =
editable && slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET
? 'password'
: editable && slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_NUMBER
? 'number'
: 'text'
const sourceLabel = sourceLabels[source] ?? t(($) => $['deployDrawer.envVarSource.literal'])
// Secret values arrive masked, so these are safe to show as-is.
const sourceValues: Partial<Record<EnvVarValueSource, string>> = {
[EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_CONFIGURED]: slot.configured_value,
[EnvVarValueSourceEnum.ENV_VAR_VALUE_SOURCE_LAST_DEPLOYED]: slot.last_deployed_value,
}
const placeholder = editable ? undefined : (sourceValues[source] ?? sourceLabel)
return (
<div className="flex flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 grow items-center gap-1">
<span
aria-hidden
className="i-custom-vender-line-others-env size-4 shrink-0 text-util-colors-violet-violet-600"
/>
<label htmlFor={inputId} className="truncate system-sm-medium text-text-primary">
{slot.key}
</label>
<span className="shrink-0 system-xs-regular text-text-tertiary">{valueTypeLabel}</span>
{slot.value_type === EnvVarValueType.ENV_VAR_VALUE_TYPE_SECRET && (
<span aria-hidden className="i-ri-lock-2-line size-3 shrink-0 text-text-tertiary" />
)}
</div>
<Select
value={source}
onValueChange={(nextSource) => {
if (nextSource) onSourceChange(nextSource)
}}
>
<SelectTrigger
aria-label={t(($) => $['deployDrawer.envVarSource.ariaLabel'], {
key: slot.key,
})}
size="small"
className="h-7 w-auto max-w-48 shrink-0 border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 shadow-xs backdrop-blur-[5px] hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover focus-visible:bg-components-button-secondary-bg"
>
{sourceLabel}
</SelectTrigger>
<SelectContent placement="bottom-end" className="w-52">
{availableSources.map((option) => (
<SelectItem key={option} value={option}>
<SelectItemText>{sourceLabels[option]}</SelectItemText>
<SelectItemIndicator />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Input
id={inputId}
type={inputType}
value={editable ? customValue : ''}
placeholder={placeholder}
disabled={!editable}
autoComplete="off"
onChange={(event) => onCustomValueChange(event.target.value)}
/>
{slot.description && (
<p className="system-xs-regular text-text-tertiary">{slot.description}</p>
)}
</div>
)
}

View File

@ -1,23 +0,0 @@
'use client'
import type { EnvVarValueSource } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { useState } from 'react'
type EnvironmentVariableSelection = {
customValue: string
source: EnvVarValueSource
}
export type DeploymentConfigurationValues = {
credentials: Record<string, string>
environmentVariables: Record<string, EnvironmentVariableSelection>
}
export function useDeploymentConfigurationValues() {
const [values, setValues] = useState<DeploymentConfigurationValues>({
credentials: {},
environmentVariables: {},
})
return [values, setValues] as const
}

Some files were not shown because too many files have changed in this diff Show More