feat(openapi,cli): workspace switch + member management (#36651)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
L1nSn0w 2026-05-27 11:05:47 +08:00 committed by GitHub
parent 5c5a6e83e5
commit 6e1e0d9439
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 4134 additions and 175 deletions

View File

@ -37,6 +37,13 @@ from controllers.openapi._models import (
DeviceMutateRequest,
DeviceMutateResponse,
DevicePollRequest,
MemberActionResponse,
MemberInvitePayload,
MemberInviteResponse,
MemberListQuery,
MemberListResponse,
MemberResponse,
MemberRoleUpdatePayload,
MessageMetadata,
PermittedExternalAppsListQuery,
PermittedExternalAppsListResponse,
@ -63,6 +70,9 @@ register_schema_models(
DevicePollRequest,
DeviceLookupQuery,
DeviceMutateRequest,
MemberInvitePayload,
MemberListQuery,
MemberRoleUpdatePayload,
PermittedExternalAppsListQuery,
)
register_response_schema_models(
@ -86,6 +96,10 @@ register_response_schema_models(
WorkspaceSummaryResponse,
WorkspaceListResponse,
WorkspaceDetailResponse,
MemberResponse,
MemberListResponse,
MemberInviteResponse,
MemberActionResponse,
DeviceCodeResponse,
DeviceLookupResponse,
DeviceMutateResponse,

View File

@ -6,7 +6,7 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from libs.helper import UUIDStrOrEmpty, uuid_value
from libs.helper import EmailStr, UUIDStrOrEmpty, uuid_value
from models.model import AppMode
# Server-side cap on `limit` query param for /openapi/v1/* list endpoints.
@ -342,3 +342,61 @@ class ApprovalGrantClaimsPayload(BaseModel):
user_code: str = Field(min_length=1, max_length=32)
nonce: str = Field(min_length=1, max_length=128)
csrf_token: str = Field(min_length=1, max_length=128)
# Closed enum for invite/update-role payloads. Owner is intentionally not
# assignable through these endpoints — ownership transfer goes through the
# console's three-step email-verification flow.
MemberAssignableRole = Literal["normal", "admin"]
class MemberResponse(BaseModel):
id: str
name: str
email: str
role: str
status: str
avatar: str | None = None
class MemberListResponse(BaseModel):
page: int
limit: int
total: int
has_more: bool
data: list[MemberResponse]
class MemberListQuery(BaseModel):
"""Strict (extra='forbid')."""
model_config = ConfigDict(extra="forbid")
page: int = Field(1, ge=1)
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
class MemberInvitePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr
role: MemberAssignableRole
class MemberRoleUpdatePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
role: MemberAssignableRole
class MemberInviteResponse(BaseModel):
result: Literal["success"] = "success"
email: str
role: str
member_id: str
invite_url: str
tenant_id: str
class MemberActionResponse(BaseModel):
result: Literal["success"] = "success"

View File

@ -0,0 +1,77 @@
"""Workspace role gate.
Layered on top of `validate_bearer` + `accept_subjects(SubjectType.ACCOUNT)`
for routes whose access depends on the caller's `TenantAccountJoin.role`
in the workspace named by the `workspace_id` path parameter.
Usage::
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
class Members(Resource):
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role() # any member
def get(self, workspace_id: str): ...
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def post(self, workspace_id: str): ...
Non-member callers get 404 (matching `GET /openapi/v1/workspaces/<id>`)
so workspace IDs do not leak across tenants. A member without one of the
allowed roles gets 403.
"""
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import TypeVar
from werkzeug.exceptions import Forbidden, NotFound
from extensions.ext_database import db
from libs.oauth_bearer import try_get_auth_ctx
from models.account import TenantAccountRole
from services.account_service import TenantService
F = TypeVar("F", bound=Callable[..., object])
def require_workspace_role(*allowed_roles: TenantAccountRole) -> Callable[[F], F]:
"""Gate a route on the caller's role in ``workspace_id``.
Pass no roles to require only membership. Pass one or more roles to
require the caller's role be in that set.
"""
allowed = frozenset(allowed_roles)
def deco(fn: F) -> F:
@wraps(fn)
def wrapper(*args: object, **kwargs: object) -> object:
ctx = try_get_auth_ctx()
if ctx is None or ctx.account_id is None:
raise RuntimeError(
"require_workspace_role called without account-bearer context; "
"stack validate_bearer + accept_subjects(SubjectType.ACCOUNT) above it"
)
workspace_id = kwargs.get("workspace_id")
if not workspace_id:
raise RuntimeError("require_workspace_role expects a 'workspace_id' route parameter")
role = TenantService.get_account_role_in_tenant(db.session, str(ctx.account_id), str(workspace_id))
if role is None:
raise NotFound("workspace not found")
if allowed and role not in allowed:
raise Forbidden("insufficient workspace role")
return fn(*args, **kwargs)
return wrapper # type: ignore[return-value]
return deco

View File

@ -1,20 +1,41 @@
"""User-scoped workspace reads under /openapi/v1/workspaces. Bearer-authed
counterparts to the cookie-authed /console/api/workspaces endpoints.
"""User-scoped workspace reads and member management under /openapi/v1/workspaces.
Account bearers (dfoa_) see every tenant they're a member of. External
SSO bearers (dfoe_) have no account_id and so see an empty list that
matches /openapi/v1/account.
Bearer-authed counterparts to the cookie-authed /console/api/workspaces
endpoints. Account bearers (dfoa_) see every tenant they're a member of.
External SSO bearers (dfoe_) have no account_id and so see an empty list
that matches /openapi/v1/account.
Member-management endpoints are gated by both `accept_subjects` (SSO out)
and `require_workspace_role` (membership / role lookup against the path's
``workspace_id``).
"""
from __future__ import annotations
from itertools import starmap
from urllib import parse
from flask import jsonify, make_response, request
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from pydantic import BaseModel, ValidationError
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from configs import dify_config
from controllers.common.schema import query_params_from_model
from controllers.openapi import openapi_ns
from controllers.openapi._models import WorkspaceDetailResponse, WorkspaceListResponse, WorkspaceSummaryResponse
from controllers.openapi._models import (
MemberActionResponse,
MemberInvitePayload,
MemberInviteResponse,
MemberListQuery,
MemberListResponse,
MemberResponse,
MemberRoleUpdatePayload,
WorkspaceDetailResponse,
WorkspaceListResponse,
WorkspaceSummaryResponse,
)
from controllers.openapi.auth.role_gate import require_workspace_role
from controllers.openapi.auth.surface_gate import accept_subjects
from extensions.ext_database import db
from libs.oauth_bearer import (
@ -23,8 +44,105 @@ from libs.oauth_bearer import (
get_auth_ctx,
validate_bearer,
)
from models import Tenant, TenantAccountJoin
from services.account_service import TenantService
from models import Account, Tenant, TenantAccountJoin
from models.account import TenantAccountRole, TenantStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import (
AccountAlreadyInTenantError,
AccountNotLinkTenantError,
AccountRegisterError,
CannotOperateSelfError,
MemberNotInTenantError,
NoPermissionError,
RoleAlreadyAssignedError,
)
from services.feature_service import FeatureService
def _validate_body[M: BaseModel](model: type[M]) -> M:
"""Validate JSON body against ``model``. Validation errors → HTTP 400.
The workspace spec is explicit that bad email / unknown role payloads
are 400, not Pydantic's default 422 — handle uniformly here.
"""
body = request.get_json(silent=True) or {}
try:
return model.model_validate(body)
except ValidationError as exc:
raise BadRequest(str(exc))
def _member_response(account: Account) -> MemberResponse:
return MemberResponse(
id=str(account.id),
name=account.name,
email=account.email,
role=account.role.value if account.role else "",
status=account.status.value if account.status else "",
avatar=account.avatar,
)
def _load_tenant(workspace_id: str) -> Tenant:
tenant = TenantService.get_tenant_by_id(db.session, workspace_id)
if tenant is None or tenant.status != TenantStatus.NORMAL:
raise NotFound("workspace not found")
return tenant
def _load_account(account_id: object) -> Account:
"""Load the caller's Account. Missing == auth wiring bug, not user error."""
account = AccountService.get_account_by_id(db.session, str(account_id)) if account_id else None
if account is None:
raise RuntimeError("authenticated account_id has no Account row")
return account
def _quota_error(*, code: str, message: str, hint: str) -> Forbidden:
"""Build a 403 with envelope ``{code, message, hint}``.
CLI ``error-mapper`` reads ``message`` and ``hint`` off the wire body
verbatim the structured envelope lets it surface remediation guidance
(e.g. "upgrade your plan") without the CLI needing to know edition
semantics.
"""
err = Forbidden(message)
err.response = make_response(
jsonify({"code": code, "message": message, "hint": hint}),
403,
)
return err
def _check_member_invite_quota(tenant_id: str) -> None:
"""Edition-aware member-count gate for invite.
Both branches self-disable on CE because ``FeatureService.get_features``
leaves ``billing.enabled`` and ``workspace_members.enabled`` False by
default; SaaS billing API and EE license activation are what flip them on.
Mirrors the two checks the console invite path performs (decorator at
``console/wraps.py:106`` for billing + inline at
``console/workspace/members.py:130`` for license).
"""
features = FeatureService.get_features(tenant_id)
if features.billing.enabled:
members = features.members
if 0 < members.limit <= members.size:
raise _quota_error(
code="members.limit_exceeded",
message="Subscription member limit reached.",
hint="Upgrade your plan to invite more members or remove an existing member first.",
)
if features.workspace_members.enabled:
if not features.workspace_members.is_available(1):
raise _quota_error(
code="workspace_members.license_exceeded",
message="Workspace member license capacity reached.",
hint="Contact your workspace administrator to expand the license seat count.",
)
@openapi_ns.route("/workspaces")
@ -57,6 +175,187 @@ class WorkspaceByIdApi(Resource):
return _workspace_detail(tenant, membership).model_dump(mode="json"), 200
@openapi_ns.route("/workspaces/<string:workspace_id>/switch")
class WorkspaceSwitchApi(Resource):
"""Server-side switch — equivalent to the console's POST /workspaces/switch.
CLI `difyctl use workspace <id>` calls this; it does NOT mutate
``hosts.yml`` on its own. Failure here must abort the local write so
that ``hosts.yml`` never diverges from the server's ``current`` state.
"""
@openapi_ns.response(200, "Workspace detail", openapi_ns.models[WorkspaceDetailResponse.__name__])
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role()
def post(self, workspace_id: str):
ctx = get_auth_ctx()
account = _load_account(ctx.account_id)
try:
TenantService.switch_tenant(account, workspace_id)
except AccountNotLinkTenantError:
# Membership existed at gate time but Tenant.status != NORMAL or
# the row was just removed — treat as not-found.
raise NotFound("workspace not found")
row = TenantService.find_workspace_for_account(db.session, str(ctx.account_id), workspace_id)
if row is None:
raise NotFound("workspace not found")
tenant, membership = row
return _workspace_detail(tenant, membership).model_dump(mode="json"), 200
@openapi_ns.route("/workspaces/<string:workspace_id>/members")
class WorkspaceMembersApi(Resource):
"""List + invite members.
GET is any-member. POST requires admin/owner owner can never be
assigned through invite (ownership transfer is console-only).
"""
@openapi_ns.doc(params=query_params_from_model(MemberListQuery))
@openapi_ns.response(200, "Member list", openapi_ns.models[MemberListResponse.__name__])
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role()
def get(self, workspace_id: str):
try:
query = MemberListQuery.model_validate(request.args.to_dict(flat=True))
except ValidationError as exc:
raise BadRequest(str(exc))
tenant = _load_tenant(workspace_id)
# Members per workspace are bounded by SaaS plan caps (≤50) or EE
# license seats (low thousands worst-case), so we materialize and
# slice in-memory rather than push pagination into the service —
# matches how the rest of the service exposes member lists.
members = TenantService.get_tenant_members(tenant)
total = len(members)
start = (query.page - 1) * query.limit
page_items = members[start : start + query.limit]
return MemberListResponse(
page=query.page,
limit=query.limit,
total=total,
has_more=query.page * query.limit < total,
data=[_member_response(m) for m in page_items],
).model_dump(mode="json"), 200
@openapi_ns.expect(openapi_ns.models[MemberInvitePayload.__name__])
@openapi_ns.response(201, "Member invited", openapi_ns.models[MemberInviteResponse.__name__])
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def post(self, workspace_id: str):
payload = _validate_body(MemberInvitePayload)
ctx = get_auth_ctx()
inviter = _load_account(ctx.account_id)
tenant = _load_tenant(workspace_id)
_check_member_invite_quota(str(tenant.id))
try:
token = RegisterService.invite_new_member(
tenant=tenant,
email=payload.email,
language=None,
role=payload.role,
inviter=inviter,
)
except AccountAlreadyInTenantError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
raise BadRequest(str(exc))
except AccountRegisterError as exc:
raise BadRequest(str(exc))
normalized_email = payload.email.lower()
member = AccountService.get_account_by_email_with_case_fallback(normalized_email)
if member is None:
# invite_new_member just created or fetched this account.
raise RuntimeError("invited member missing from DB after invite")
encoded_email = parse.quote(normalized_email)
invite_url = f"{dify_config.CONSOLE_WEB_URL}/activate?email={encoded_email}&token={token}"
return MemberInviteResponse(
email=normalized_email,
role=payload.role,
member_id=str(member.id),
invite_url=invite_url,
tenant_id=str(tenant.id),
).model_dump(mode="json"), 201
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>")
class WorkspaceMemberApi(Resource):
"""Remove a member.
Self-removal and owner-removal are explicitly rejected by the service
layer (CannotOperateSelfError, NoPermissionError) both surface as
400 per the spec, with the service's message preserved.
"""
@openapi_ns.response(200, "Member removed", openapi_ns.models[MemberActionResponse.__name__])
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def delete(self, workspace_id: str, member_id: str):
ctx = get_auth_ctx()
operator = _load_account(ctx.account_id)
tenant = _load_tenant(workspace_id)
member = AccountService.get_account_by_id(db.session, member_id)
if member is None:
raise NotFound("member not found")
try:
TenantService.remove_member_from_tenant(tenant, member, operator)
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
raise BadRequest(str(exc))
except MemberNotInTenantError as exc:
raise NotFound(str(exc))
return MemberActionResponse().model_dump(mode="json"), 200
@openapi_ns.route("/workspaces/<string:workspace_id>/members/<string:member_id>/role")
class WorkspaceMemberRoleApi(Resource):
"""Change a member's role.
Owner cannot be assigned here (closed enum). Admin cannot demote the
standing owner (service NoPermissionError 400, per spec).
"""
@openapi_ns.expect(openapi_ns.models[MemberRoleUpdatePayload.__name__])
@openapi_ns.response(200, "Role updated", openapi_ns.models[MemberActionResponse.__name__])
@validate_bearer(accept=ACCEPT_USER_ANY)
@accept_subjects(SubjectType.ACCOUNT)
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def put(self, workspace_id: str, member_id: str):
payload = _validate_body(MemberRoleUpdatePayload)
ctx = get_auth_ctx()
operator = _load_account(ctx.account_id)
tenant = _load_tenant(workspace_id)
member = AccountService.get_account_by_id(db.session, member_id)
if member is None:
raise NotFound("member not found")
try:
TenantService.update_member_role(tenant, member, payload.role, operator)
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
raise BadRequest(str(exc))
except MemberNotInTenantError as exc:
raise NotFound(str(exc))
except RoleAlreadyAssignedError as exc:
raise BadRequest(str(exc))
return MemberActionResponse().model_dump(mode="json"), 200
def _workspace_summary(tenant: Tenant, membership: TenantAccountJoin) -> WorkspaceSummaryResponse:
return WorkspaceSummaryResponse(
id=str(tenant.id),

View File

@ -323,6 +323,85 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
### /workspaces/{workspace_id}/members
#### GET
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| limit | query | | No | integer |
| page | query | | No | integer |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member list | [MemberListResponse](#memberlistresponse) |
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [MemberInvitePayload](#memberinvitepayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) |
### /workspaces/{workspace_id}/members/{member_id}
#### DELETE
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| member_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member removed | [MemberActionResponse](#memberactionresponse) |
### /workspaces/{workspace_id}/members/{member_id}/role
#### PUT
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| member_id | path | | Yes | string |
| workspace_id | path | | Yes | string |
| payload | body | | Yes | [MemberRoleUpdatePayload](#memberroleupdatepayload) |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Role updated | [MemberActionResponse](#memberactionresponse) |
### /workspaces/{workspace_id}/switch
#### POST
##### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| workspace_id | path | | Yes | string |
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
---
### Models
@ -526,6 +605,66 @@ mode is a closed enum.
| ---- | ---- | ----------- | -------- |
| JsonValue | | | |
#### MemberActionResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| result | string | | No |
#### MemberInvitePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
| role | string | *Enum:* `"admin"`, `"normal"` | Yes |
#### MemberInviteResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| email | string | | Yes |
| invite_url | string | | Yes |
| member_id | string | | Yes |
| result | string | | No |
| role | string | | Yes |
| tenant_id | string | | Yes |
#### MemberListQuery
Strict (extra='forbid').
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| limit | integer | | No |
| page | integer | | No |
#### MemberListResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| data | [ [MemberResponse](#memberresponse) ] | | Yes |
| has_more | boolean | | Yes |
| limit | integer | | Yes |
| page | integer | | Yes |
| total | integer | | Yes |
#### MemberResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| avatar | string | | No |
| email | string | | Yes |
| id | string | | Yes |
| name | string | | Yes |
| role | string | | Yes |
| status | string | | Yes |
#### MemberRoleUpdatePayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| role | string | *Enum:* `"admin"`, `"normal"` | Yes |
#### MessageMetadata
| Name | Type | Description | Required |

View File

@ -1287,6 +1287,34 @@ class TenantService:
).scalar_one_or_none()
return row is not None
@staticmethod
def get_account_role_in_tenant(
session: Session | scoped_session,
account_id: uuid.UUID | str | None,
tenant_id: str,
) -> TenantAccountRole | None:
"""Return the caller's role in ``tenant_id``, or ``None`` if not a member.
Backs ``controllers.openapi.auth.role_gate.require_workspace_role``:
the gate maps ``None`` to 404 (non-member no cross-tenant ID leak)
and an out-of-set role to 403, so it never touches the ORM itself.
``None``/empty ``account_id`` short-circuits to ``None`` so SSO
bearers (no account) collapse to the non-member path. Mirrors the
session-injection style of :meth:`account_belongs_to_tenant` rather
than :meth:`get_user_role`, which loads full ``Account``/``Tenant``
objects against the Flask-scoped ``db.session``.
"""
if not account_id:
return None
role = session.execute(
select(TenantAccountJoin.role).where(
TenantAccountJoin.tenant_id == tenant_id,
TenantAccountJoin.account_id == account_id,
)
).scalar_one_or_none()
return TenantAccountRole(role) if role is not None else None
@staticmethod
def get_tenant_by_id(session: Session | scoped_session, tenant_id: str) -> Tenant | None:
"""Plain ``session.get(Tenant, tenant_id)`` — no status filter.

View File

@ -0,0 +1,330 @@
"""Role-gate tests.
The decorator wraps `validate_bearer` + `accept_subjects` and must:
- 404 when caller is not a member of ``workspace_id`` (parity with
`GET /openapi/v1/workspaces/<id>`; prevents tenant-id existence leak)
- 403 when caller IS a member but their role is not in the allowed set
- pass through when role matches (or when no role restriction given)
- raise RuntimeError on missing auth context / account_id / workspace_id
those are wiring bugs, not user-driven failures
Identity is read from the openapi auth ContextVar the slot
`validate_bearer` publishes so these tests seed it via `_seed`
(``set_auth_ctx``), NOT ``flask.g``. `test_seeding_only_flask_g_*`
locks in that ``g`` is *not* a valid identity source.
"""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from datetime import UTC, datetime
from unittest.mock import patch
import pytest
from flask import Flask
from werkzeug.exceptions import Forbidden, NotFound
from controllers.openapi.auth.role_gate import require_workspace_role
from libs.oauth_bearer import AuthContext, Scope, SubjectType, reset_auth_ctx, set_auth_ctx
from models.account import TenantAccountRole
# Tokens from `_seed`'s `set_auth_ctx` calls, drained after each test so a
# published identity can't leak into the next (the ContextVar is module-global
# and worker threads are reused). Seed via `_seed(...)`, never `flask.g`.
_seed_tokens: list = []
def _seed(ctx: AuthContext) -> None:
_seed_tokens.append(set_auth_ctx(ctx))
@pytest.fixture(autouse=True)
def _reset_auth_ctx():
yield
while _seed_tokens:
reset_auth_ctx(_seed_tokens.pop())
def _account_ctx(account_id: uuid.UUID | None = None) -> AuthContext:
return AuthContext(
subject_type=SubjectType.ACCOUNT,
subject_email="user@example.com",
subject_issuer="dify:account",
account_id=account_id or uuid.uuid4(),
client_id="difyctl",
scopes=frozenset({Scope.FULL}),
token_id=uuid.uuid4(),
source="oauth_account",
expires_at=datetime.now(UTC),
token_hash="h1",
verified_tenants={},
)
def _sso_ctx() -> AuthContext:
return AuthContext(
subject_type=SubjectType.EXTERNAL_SSO,
subject_email="sso@partner.com",
subject_issuer="https://idp.partner.com",
account_id=None,
client_id="difyctl",
scopes=frozenset({Scope.APPS_RUN}),
token_id=uuid.uuid4(),
source="oauth_external_sso",
expires_at=datetime.now(UTC),
token_hash="h2",
verified_tenants={},
)
@contextmanager
def _stub_role(role: TenantAccountRole | None):
"""Stub the service-layer membership lookup the gate delegates to.
The gate no longer issues SQL itself it calls
``TenantService.get_account_role_in_tenant`` and acts purely on the
returned role (``None`` non-member). These tests pin that behaviour;
the query itself is covered in ``TestTenantService``.
"""
with patch(
"controllers.openapi.auth.role_gate.TenantService.get_account_role_in_tenant",
return_value=role,
) as mocked:
yield mocked
# ---------------------------------------------------------------------------
# Non-member → 404
# ---------------------------------------------------------------------------
def test_non_member_gets_404():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
_seed(_account_ctx())
with _stub_role(None):
with pytest.raises(NotFound):
view(workspace_id=workspace_id)
# ---------------------------------------------------------------------------
# Member with insufficient role → 403
# ---------------------------------------------------------------------------
def test_normal_member_blocked_when_admin_required():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
_seed(_account_ctx())
with _stub_role(TenantAccountRole.NORMAL):
with pytest.raises(Forbidden):
view(workspace_id=workspace_id)
def test_editor_blocked_when_admin_required():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
_seed(_account_ctx())
with _stub_role(TenantAccountRole.EDITOR):
with pytest.raises(Forbidden):
view(workspace_id=workspace_id)
# ---------------------------------------------------------------------------
# Member with allowed role → pass
# ---------------------------------------------------------------------------
def test_admin_passes_when_admin_required():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
_seed(_account_ctx())
with _stub_role(TenantAccountRole.ADMIN):
assert view(workspace_id=workspace_id) == "ok"
def test_owner_passes_when_admin_required():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role(TenantAccountRole.OWNER, TenantAccountRole.ADMIN)
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/members"):
_seed(_account_ctx())
with _stub_role(TenantAccountRole.OWNER):
assert view(workspace_id=workspace_id) == "ok"
# ---------------------------------------------------------------------------
# Membership-only (no role restriction)
# ---------------------------------------------------------------------------
def test_membership_only_passes_for_any_role():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
for role in (
TenantAccountRole.OWNER,
TenantAccountRole.ADMIN,
TenantAccountRole.EDITOR,
TenantAccountRole.NORMAL,
TenantAccountRole.DATASET_OPERATOR,
):
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
_seed(_account_ctx())
with _stub_role(role):
assert view(workspace_id=workspace_id) == "ok"
def test_membership_only_still_404s_non_member():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
_seed(_account_ctx())
with _stub_role(None):
with pytest.raises(NotFound):
view(workspace_id=workspace_id)
# ---------------------------------------------------------------------------
# Lookup is scoped to the caller's account_id and the URL workspace_id
# ---------------------------------------------------------------------------
def test_lookup_is_scoped_to_caller_and_workspace():
"""The decorator must delegate the lookup keyed on
`(caller's account_id, URL workspace_id)` — otherwise a member of
workspace A could quietly hit endpoints for workspace B. Assert the
exact arguments handed to the service; the SQL those arguments compile
to is pinned in ``TestTenantService.test_get_account_role_in_tenant_*``.
"""
app = Flask(__name__)
account_id = uuid.uuid4()
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
_seed(_account_ctx(account_id=account_id))
with _stub_role(TenantAccountRole.NORMAL) as mocked:
view(workspace_id=workspace_id)
_session, passed_account_id, passed_workspace_id = mocked.call_args.args
assert passed_account_id == str(account_id)
assert passed_workspace_id == workspace_id
# ---------------------------------------------------------------------------
# Wiring bugs surface as RuntimeError (loud), not 403 (silent)
# ---------------------------------------------------------------------------
def test_missing_auth_ctx_is_runtime_error():
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
with pytest.raises(RuntimeError):
view(workspace_id=workspace_id)
def test_seeding_only_flask_g_does_not_satisfy_gate():
"""Regression — pins the identity source to the ContextVar, not ``flask.g``.
Production fills the ContextVar (``validate_bearer`` ``set_auth_ctx``)
and never touches ``g.auth_ctx``. An earlier revision of this gate read
``g.auth_ctx``, so every real request raised RuntimeError 500 while the
suite stayed green (it seeded ``g`` directly). Here we seed ONLY ``g`` and
leave the ContextVar empty: the gate must still raise, proving it does not
accept ``g`` as an identity source. Reading ``g`` again would let the
membership lookup run (stubbed to succeed) and this would fail.
"""
from flask import g
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
g.auth_ctx = _account_ctx() # the wrong slot — must be ignored
with _stub_role(TenantAccountRole.OWNER):
with pytest.raises(RuntimeError):
view(workspace_id=workspace_id)
def test_sso_caller_is_runtime_error():
"""External SSO context has account_id=None — the caller stacked the
role gate without `accept_subjects(SubjectType.ACCOUNT)`. That's a
wiring bug, surface it as RuntimeError rather than 404 the SSO user."""
app = Flask(__name__)
workspace_id = str(uuid.uuid4())
@require_workspace_role()
def view(workspace_id: str) -> str:
return "ok"
with app.test_request_context(f"/openapi/v1/workspaces/{workspace_id}/switch"):
_seed(_sso_ctx())
with pytest.raises(RuntimeError):
view(workspace_id=workspace_id)
def test_missing_workspace_id_kwarg_is_runtime_error():
app = Flask(__name__)
@require_workspace_role()
def view() -> str:
return "ok"
with app.test_request_context("/openapi/v1/foo"):
_seed(_account_ctx())
with pytest.raises(RuntimeError):
view()

View File

@ -0,0 +1,918 @@
"""Member endpoints under /openapi/v1/workspaces/<id>/...
Coverage:
- Route registration (5 endpoints across 4 URL patterns)
- Body validation lands at 400 (per spec not Pydantic's default 422)
- Domain exception HTTP code mapping is preserved with the service's
original message (so CLI users see what the console user sees)
- Response shape matches the Pydantic models
Auth-pipeline plumbing is bypassed via the `bypass_pipeline` fixture from
conftest.py; the bearer identity is seeded into the openapi auth ContextVar
via `_seed` (the slot `validate_bearer` publishes), and the role gate's DB
lookup is mocked. Tests that exercise endpoint *bodies* skip the decorators
via ``__wrapped__`` since those layers are covered in `auth/test_role_gate.py`.
"""
from __future__ import annotations
import builtins
import json
import sys
import uuid
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
import pytest
from flask import Flask
from flask.views import MethodView
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from controllers.openapi import bp as openapi_bp
from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload
from controllers.openapi.workspaces import (
WorkspaceMemberApi,
WorkspaceMemberRoleApi,
WorkspaceMembersApi,
WorkspaceSwitchApi,
)
from libs.oauth_bearer import AuthContext, Scope, SubjectType, reset_auth_ctx, set_auth_ctx
from models.account import AccountStatus, TenantAccountRole
from services.errors.account import (
AccountAlreadyInTenantError,
AccountNotLinkTenantError,
AccountRegisterError,
CannotOperateSelfError,
MemberNotInTenantError,
NoPermissionError,
RoleAlreadyAssignedError,
)
if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
# Tokens from `_seed`'s `set_auth_ctx` calls, drained after each test so a
# published identity can't leak into the next (the ContextVar is module-global
# and worker threads are reused). Seed via `_seed(...)`, never `flask.g` —
# production fills the ContextVar, nothing fills `g.auth_ctx`.
_seed_tokens: list = []
def _seed(ctx: AuthContext) -> None:
_seed_tokens.append(set_auth_ctx(ctx))
@pytest.fixture(autouse=True)
def _reset_auth_ctx():
yield
while _seed_tokens:
reset_auth_ctx(_seed_tokens.pop())
@pytest.fixture
def openapi_app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(openapi_bp)
return app
def _rule(app: Flask, path: str):
return next(r for r in app.url_map.iter_rules() if r.rule == path)
def _auth_ctx(account_id: uuid.UUID | None = None) -> AuthContext:
return AuthContext(
subject_type=SubjectType.ACCOUNT,
subject_email="caller@example.com",
subject_issuer="dify:account",
account_id=account_id or uuid.uuid4(),
client_id="difyctl",
scopes=frozenset({Scope.FULL}),
token_id=uuid.uuid4(),
source="oauth_account",
expires_at=datetime.now(UTC),
token_hash="h",
verified_tenants={},
)
def _account(account_id: str = "acct-1", email: str = "u@example.com") -> SimpleNamespace:
return SimpleNamespace(
id=account_id,
name="User",
email=email,
status=AccountStatus.ACTIVE,
avatar=None,
)
def _tenant(tenant_id: str = "ws-1") -> SimpleNamespace:
return SimpleNamespace(
id=tenant_id,
name="WS",
status="normal",
created_at=datetime(2026, 5, 18, tzinfo=UTC),
)
def _tenant_service(**overrides) -> SimpleNamespace:
"""TenantService double for the workspaces module.
Read getters (`get_tenant_by_id`, `find_workspace_for_account`) delegate
to the session they're handed, so tests keep driving entity loads through
``mock_db.session.get`` / ``.execute`` and their existing side_effect
ordering the SQL those methods run is covered in test_account_service.py.
Domain mutators default to no-op Mocks; override per test as needed.
"""
methods: dict = {
"switch_tenant": Mock(),
"get_tenant_members": Mock(return_value=[]),
"remove_member_from_tenant": Mock(),
"update_member_role": Mock(),
"get_tenant_by_id": lambda session, tenant_id: session.get(None, tenant_id),
"find_workspace_for_account": lambda session, account_id, workspace_id: session.execute(None).first(),
}
methods.update(overrides)
return SimpleNamespace(**methods)
def _account_service(**overrides) -> SimpleNamespace:
"""AccountService double; ``get_account_by_id`` delegates to the injected
session (see :func:`_tenant_service`)."""
methods: dict = {
"get_account_by_id": lambda session, account_id: session.get(None, account_id),
}
methods.update(overrides)
return SimpleNamespace(**methods)
# ---------------------------------------------------------------------------
# Route registration
# ---------------------------------------------------------------------------
def test_switch_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/switch")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceSwitchApi
assert "POST" in rule.methods
def test_members_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMembersApi
assert "GET" in rule.methods
assert "POST" in rule.methods
def test_member_by_id_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberApi
assert "DELETE" in rule.methods
def test_member_role_route_registered(openapi_app: Flask):
rule = _rule(openapi_app, "/openapi/v1/workspaces/<string:workspace_id>/members/<string:member_id>/role")
assert openapi_app.view_functions[rule.endpoint].view_class is WorkspaceMemberRoleApi
assert "PUT" in rule.methods
# ---------------------------------------------------------------------------
# Payload validation lands at 400
# ---------------------------------------------------------------------------
def test_invite_payload_rejects_unknown_role():
with pytest.raises(ValidationError):
MemberInvitePayload.model_validate({"email": "u@example.com", "role": "owner"})
def test_invite_payload_rejects_bad_email():
with pytest.raises(ValidationError):
MemberInvitePayload.model_validate({"email": "not-an-email", "role": "normal"})
def test_invite_payload_rejects_extra_field():
with pytest.raises(ValidationError):
MemberInvitePayload.model_validate({"email": "u@example.com", "role": "normal", "extra": "x"})
def test_role_payload_rejects_owner():
with pytest.raises(ValidationError):
MemberRoleUpdatePayload.model_validate({"role": "owner"})
def test_role_payload_rejects_extra_field():
with pytest.raises(ValidationError):
MemberRoleUpdatePayload.model_validate({"role": "normal", "extra": "x"})
def test_validate_body_helper_maps_validation_error_to_400(app, monkeypatch):
"""`_validate_body` is the centralized 400-mapper for invalid request bodies."""
from controllers.openapi.workspaces import _validate_body
with app.test_request_context(
"/openapi/v1/workspaces/ws-1/members",
method="POST",
data=json.dumps({"email": "u@example.com", "role": "owner"}),
content_type="application/json",
):
with pytest.raises(BadRequest):
_validate_body(MemberInvitePayload)
# ---------------------------------------------------------------------------
# Switch endpoint behavior
# ---------------------------------------------------------------------------
def test_switch_returns_workspace_detail_with_current_true(app, bypass_pipeline, monkeypatch):
"""Happy path: switch service is called, then the workspace+membership
row is re-queried so the returned `current` reflects post-commit state.
"""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceSwitchApi()
mock_db = MagicMock()
mock_db.session.get.return_value = _account(account_id=str(acct_id))
membership = SimpleNamespace(role=TenantAccountRole.OWNER, current=True)
mock_db.session.execute.return_value.first.return_value = (_tenant(ws_id), membership)
switch_mock = Mock()
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(switch_tenant=switch_mock),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
assert status == 200
assert body["id"] == ws_id
assert body["current"] is True
assert switch_mock.called
def test_switch_404s_when_service_raises_account_not_link_tenant(app, bypass_pipeline, monkeypatch):
"""If switch_tenant raises (e.g. Tenant.status != NORMAL), the body
surfaces as NotFound, not 500."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceSwitchApi()
mock_db = MagicMock()
mock_db.session.get.return_value = _account(account_id=str(acct_id))
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(switch_tenant=Mock(side_effect=AccountNotLinkTenantError(""))),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(NotFound):
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
# ---------------------------------------------------------------------------
# Members list
# ---------------------------------------------------------------------------
def test_members_list_returns_normalized_rows(app, bypass_pipeline, monkeypatch):
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
member = SimpleNamespace(
id="m-1",
name="Mia",
email="mia@example.com",
status=AccountStatus.ACTIVE,
avatar=None,
role=TenantAccountRole.ADMIN,
)
mock_db = MagicMock()
mock_db.session.get.return_value = _tenant(ws_id)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(get_tenant_members=Mock(return_value=[member])),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
assert status == 200
assert body["page"] == 1
assert body["limit"] == 20
assert body["total"] == 1
assert body["has_more"] is False
assert body["data"][0]["email"] == "mia@example.com"
assert body["data"][0]["role"] == "admin"
assert body["data"][0]["status"] == "active"
def test_members_list_paginates_with_query_params(app, bypass_pipeline, monkeypatch):
"""`?page=2&limit=2` slices service output and reports total/has_more."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
members = [
SimpleNamespace(
id=f"m-{i}",
name=f"User {i}",
email=f"u{i}@example.com",
status=AccountStatus.ACTIVE,
avatar=None,
role=TenantAccountRole.NORMAL,
)
for i in range(5)
]
mock_db = MagicMock()
mock_db.session.get.return_value = _tenant(ws_id)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(get_tenant_members=Mock(return_value=members)),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?page=2&limit=2"):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
assert status == 200
assert body["page"] == 2
assert body["limit"] == 2
assert body["total"] == 5
assert body["has_more"] is True
assert [d["id"] for d in body["data"]] == ["m-2", "m-3"]
def test_members_list_rejects_unknown_query_param(app, bypass_pipeline, monkeypatch):
"""Strict (`extra='forbid'`) — typos like `?pg=2` surface as 400."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
mock_db = MagicMock()
mock_db.session.get.return_value = _tenant(ws_id)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members?pg=2"):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(BadRequest):
api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
# ---------------------------------------------------------------------------
# Invite endpoint
# ---------------------------------------------------------------------------
def test_invite_happy_path_returns_invite_url_and_member_id(app, bypass_pipeline, monkeypatch):
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
invited = _account(account_id="new-1", email="new@example.com")
mock_db = MagicMock()
# session.get is called twice: once for inviter Account, once for Tenant
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"RegisterService",
SimpleNamespace(invite_new_member=Mock(return_value="tok-123")),
)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"AccountService",
_account_service(get_account_by_email_with_case_fallback=Mock(return_value=invited)),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members",
method="POST",
data=json.dumps({"email": "NEW@example.com", "role": "normal"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
assert status == 201
assert body["result"] == "success"
assert body["email"] == "new@example.com"
assert body["role"] == "normal"
assert body["member_id"] == "new-1"
assert "token=tok-123" in body["invite_url"]
assert "email=new%40example.com" in body["invite_url"]
assert body["tenant_id"] == ws_id
def _features(
*,
billing_enabled: bool = False,
members_size: int = 0,
members_limit: int = 0,
workspace_members_enabled: bool = False,
workspace_members_size: int = 0,
workspace_members_limit: int = 0,
) -> SimpleNamespace:
"""Build a feature object matching the surface `_check_member_invite_quota`
reads: `.billing.enabled`, `.members.{size,limit}`,
`.workspace_members.{enabled, is_available(N)}`.
Defaults model CE (both flags off, both caps inert).
"""
def _is_available(n: int) -> bool:
return workspace_members_size + n <= workspace_members_limit
return SimpleNamespace(
billing=SimpleNamespace(enabled=billing_enabled),
members=SimpleNamespace(size=members_size, limit=members_limit),
workspace_members=SimpleNamespace(
enabled=workspace_members_enabled,
size=workspace_members_size,
limit=workspace_members_limit,
is_available=_is_available,
),
)
def _invite_request(app, ws_id: str, acct_id: uuid.UUID):
return app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members",
method="POST",
data=json.dumps({"email": "new@example.com", "role": "normal"}),
content_type="application/json",
)
def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
"""SaaS billing plan member cap → 403 with `members.limit_exceeded`.
Verifies the envelope shape the CLI error-mapper relies on (code +
message + hint on the wire body).
"""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
invite_mock = Mock()
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"RegisterService",
SimpleNamespace(invite_new_member=invite_mock),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"FeatureService",
SimpleNamespace(
get_features=Mock(
return_value=_features(billing_enabled=True, members_size=10, members_limit=10),
),
),
)
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(Forbidden) as exc_info:
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
body = exc_info.value.response.json
assert body["code"] == "members.limit_exceeded"
assert "Subscription member limit" in body["message"]
assert body["hint"]
invite_mock.assert_not_called()
def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch):
"""EE License workspace_members cap → 403 with `workspace_members.license_exceeded`.
Note: billing.enabled is False (EE without SaaS billing); only the
license cap fires.
"""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
invite_mock = Mock()
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"RegisterService",
SimpleNamespace(invite_new_member=invite_mock),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"FeatureService",
SimpleNamespace(
get_features=Mock(
return_value=_features(
workspace_members_enabled=True,
workspace_members_size=5,
workspace_members_limit=5,
),
),
),
)
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(Forbidden) as exc_info:
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
body = exc_info.value.response.json
assert body["code"] == "workspace_members.license_exceeded"
assert "license" in body["message"].lower()
assert body["hint"]
invite_mock.assert_not_called()
def test_invite_ce_passes_when_both_caps_disabled(app, bypass_pipeline, monkeypatch):
"""CE deployment (no billing, no license) → quota gate is a no-op,
invite proceeds normally."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
invited = _account(account_id="new-1", email="new@example.com")
mock_db = MagicMock()
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"RegisterService",
SimpleNamespace(invite_new_member=Mock(return_value="tok-ce")),
)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"AccountService",
_account_service(get_account_by_email_with_case_fallback=Mock(return_value=invited)),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"FeatureService",
SimpleNamespace(get_features=Mock(return_value=_features())), # all defaults
)
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
assert status == 201
assert body["email"] == "new@example.com"
def test_invite_400_when_already_in_tenant(app, bypass_pipeline, monkeypatch):
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"RegisterService",
SimpleNamespace(invite_new_member=Mock(side_effect=AccountAlreadyInTenantError("already in tenant"))),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members",
method="POST",
data=json.dumps({"email": "u@example.com", "role": "normal"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(BadRequest):
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
# ---------------------------------------------------------------------------
# Delete member
# ---------------------------------------------------------------------------
def test_delete_member_happy_path(app, bypass_pipeline, monkeypatch):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
_account(account_id=str(acct_id)), # operator
_tenant(ws_id), # tenant
_account(account_id=member_id), # target member
]
remove_mock = Mock()
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(remove_member_from_tenant=remove_mock),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
method="DELETE",
):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.delete.__wrapped__.__wrapped__.__wrapped__(
api,
workspace_id=ws_id,
member_id=member_id,
)
assert status == 200
assert body == {"result": "success"}
assert remove_mock.called
@pytest.mark.parametrize(
("exc", "expected"),
[
(CannotOperateSelfError("cannot operate self"), BadRequest),
(NoPermissionError("no permission"), BadRequest),
(MemberNotInTenantError("not in tenant"), NotFound),
],
)
def test_delete_member_exception_mapping(app, bypass_pipeline, monkeypatch, exc, expected):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
_account(account_id=str(acct_id)),
_tenant(ws_id),
_account(account_id=member_id),
]
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(remove_member_from_tenant=Mock(side_effect=exc)),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
method="DELETE",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(expected):
api.delete.__wrapped__.__wrapped__.__wrapped__(
api,
workspace_id=ws_id,
member_id=member_id,
)
def test_delete_member_404_when_member_missing(app, bypass_pipeline, monkeypatch):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
_account(account_id=str(acct_id)),
_tenant(ws_id),
None, # member not found
]
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}",
method="DELETE",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(NotFound):
api.delete.__wrapped__.__wrapped__.__wrapped__(
api,
workspace_id=ws_id,
member_id=member_id,
)
# ---------------------------------------------------------------------------
# Update role
# ---------------------------------------------------------------------------
def test_update_role_happy_path(app, bypass_pipeline, monkeypatch):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberRoleApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
_account(account_id=str(acct_id)),
_tenant(ws_id),
_account(account_id=member_id),
]
update_mock = Mock()
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(update_member_role=update_mock),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
method="PUT",
data=json.dumps({"role": "admin"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
body, status = api.put.__wrapped__.__wrapped__.__wrapped__(
api,
workspace_id=ws_id,
member_id=member_id,
)
assert status == 200
assert body == {"result": "success"}
args = update_mock.call_args.args
assert args[2] == "admin"
@pytest.mark.parametrize(
("exc", "expected"),
[
(CannotOperateSelfError("cannot operate self"), BadRequest),
(NoPermissionError("no permission"), BadRequest),
(RoleAlreadyAssignedError("already"), BadRequest),
(MemberNotInTenantError("not in tenant"), NotFound),
],
)
def test_update_role_exception_mapping(app, bypass_pipeline, monkeypatch, exc, expected):
ws_id, member_id = str(uuid.uuid4()), str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMemberRoleApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [
_account(account_id=str(acct_id)),
_tenant(ws_id),
_account(account_id=member_id),
]
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(update_member_role=Mock(side_effect=exc)),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members/{member_id}/role",
method="PUT",
data=json.dumps({"role": "admin"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(expected):
api.put.__wrapped__.__wrapped__.__wrapped__(
api,
workspace_id=ws_id,
member_id=member_id,
)
# ---------------------------------------------------------------------------
# Role gate composition — non-member sees 404 even with valid bearer
# ---------------------------------------------------------------------------
def test_non_member_caller_gets_404_on_switch(app, bypass_pipeline, monkeypatch):
"""End-to-end: caller has valid account bearer but no membership in
the requested workspace. The role gate must short-circuit to 404
before any TenantService method is touched."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceSwitchApi()
mock_db = MagicMock()
mock_db.session.execute.return_value.scalar_one_or_none.return_value = None
switch_mock = Mock()
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(switch_tenant=switch_mock),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
monkeypatch.setattr(sys.modules["controllers.openapi.auth.role_gate"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/switch", method="POST"):
_seed(_auth_ctx(account_id=acct_id))
# Strip only the bearer + surface-gate wrappers; keep the role gate.
# Decorator stack (innermost → outermost):
# role_gate → accept_subjects → validate_bearer
# So `post.__wrapped__` unwraps validate_bearer; we then unwrap
# accept_subjects to land on the role-gate wrapper.
gated = api.post.__wrapped__.__wrapped__
with pytest.raises(NotFound):
gated(api, workspace_id=ws_id)
switch_mock.assert_not_called()
# ---------------------------------------------------------------------------
# _load_tenant rejects archived tenant
# ---------------------------------------------------------------------------
def test_load_tenant_rejects_archived_workspace(app, bypass_pipeline, monkeypatch):
"""Member management against an archived workspace → 404."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
archived = SimpleNamespace(id=ws_id, name="WS", status="archive", created_at=datetime(2026, 5, 18, tzinfo=UTC))
mock_db = MagicMock()
mock_db.session.get.return_value = archived
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"TenantService",
_tenant_service(),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(f"/openapi/v1/workspaces/{ws_id}/members"):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(NotFound):
api.get.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)
# ---------------------------------------------------------------------------
# Invite catches AccountRegisterError
# ---------------------------------------------------------------------------
def test_invite_400_when_register_error(app, bypass_pipeline, monkeypatch):
"""AccountRegisterError (frozen email, workspace creation blocked) → 400."""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
mock_db = MagicMock()
mock_db.session.get.side_effect = [_account(account_id=str(acct_id)), _tenant(ws_id)]
monkeypatch.setattr(
sys.modules["controllers.openapi.workspaces"],
"RegisterService",
SimpleNamespace(
invite_new_member=Mock(side_effect=AccountRegisterError("Workspace is not allowed to create.")),
),
)
monkeypatch.setattr(sys.modules["controllers.openapi.workspaces"], "db", mock_db)
with app.test_request_context(
f"/openapi/v1/workspaces/{ws_id}/members",
method="POST",
data=json.dumps({"email": "frozen@example.com", "role": "normal"}),
content_type="application/json",
):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(BadRequest):
api.post.__wrapped__.__wrapped__.__wrapped__(api, workspace_id=ws_id)

View File

@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch
import pytest
from configs import dify_config
from models.account import Account, AccountStatus, TenantStatus
from models.account import Account, AccountStatus, TenantAccountRole, TenantStatus
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import (
AccountAlreadyInTenantError,
@ -567,6 +567,52 @@ class TestTenantService:
with pytest.raises(exception_type):
callable_func(*args, **kwargs)
# ==================== get_account_role_in_tenant Tests ====================
# Backs `require_workspace_role`: None => non-member (gate maps to 404),
# otherwise the caller's role (gate maps an out-of-set role to 403).
def test_get_account_role_in_tenant_returns_role_for_member(self):
"""A row in TenantAccountJoin yields the caller's role."""
mock_session = MagicMock()
mock_session.execute.return_value.scalar_one_or_none.return_value = TenantAccountRole.ADMIN
role = TenantService.get_account_role_in_tenant(mock_session, "account-1", "tenant-1")
assert role == TenantAccountRole.ADMIN
def test_get_account_role_in_tenant_returns_none_for_non_member(self):
"""No join row => None, so the gate cannot leak the workspace's existence."""
mock_session = MagicMock()
mock_session.execute.return_value.scalar_one_or_none.return_value = None
role = TenantService.get_account_role_in_tenant(mock_session, "account-1", "tenant-1")
assert role is None
def test_get_account_role_in_tenant_short_circuits_empty_account_id(self):
"""None/empty account_id (SSO bearer, missing identity) returns None
without ever touching the session."""
mock_session = MagicMock()
assert TenantService.get_account_role_in_tenant(mock_session, None, "tenant-1") is None
mock_session.execute.assert_not_called()
def test_get_account_role_in_tenant_query_is_scoped(self):
"""The lookup must filter on BOTH tenant_id and account_id — otherwise
a member of workspace A could read their role for workspace B. Compile
the statement and assert both identifiers appear in the WHERE clause."""
account_id = "11111111-1111-1111-1111-111111111111"
tenant_id = "22222222-2222-2222-2222-222222222222"
mock_session = MagicMock()
mock_session.execute.return_value.scalar_one_or_none.return_value = TenantAccountRole.NORMAL
TenantService.get_account_role_in_tenant(mock_session, account_id, tenant_id)
stmt = mock_session.execute.call_args.args[0]
compiled = str(stmt.compile(compile_kwargs={"literal_binds": True}))
assert account_id in compiled
assert tenant_id in compiled
# ==================== Tenant Creation Tests ====================
def test_create_owner_tenant_if_not_exist_new_user(

View File

@ -103,7 +103,7 @@ import { ErrorCode } from '../../errors/codes.js'
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'workspace id required',
hint: 'pass --workspace or run \'difyctl auth use <id>\'',
hint: 'pass --workspace or run \'difyctl use workspace <id>\'',
})
```

View File

@ -8,8 +8,15 @@ export class AccountSessionsClient {
this.http = http
}
async list(): Promise<SessionListResponse> {
return this.http.get('account/sessions').json<SessionListResponse>()
async list(q?: { page?: number, limit?: number }): Promise<SessionListResponse> {
const params = new URLSearchParams()
if (q?.page !== undefined)
params.set('page', String(q.page))
if (q?.limit !== undefined)
params.set('limit', String(q.limit))
const hasParams = Array.from(params.keys()).length > 0
const opts = hasParams ? { searchParams: params } : undefined
return this.http.get('account/sessions', opts).json<SessionListResponse>()
}
async revoke(sessionId: string): Promise<void> {

280
cli/src/api/members.test.ts Normal file
View File

@ -0,0 +1,280 @@
import type { AddressInfo } from 'node:net'
import { Buffer } from 'node:buffer'
import * as http from 'node:http'
import { afterEach, describe, expect, it } from 'vitest'
import { isBaseError } from '../errors/base.js'
import { createClient } from '../http/client.js'
import { MembersClient } from './members.js'
type StubServer = {
url: string
lastRequest: { method?: string, url?: string, body?: string }
stop: () => Promise<void>
}
function jsonResponder(
status: number,
body: unknown,
captured: StubServer['lastRequest'],
): http.RequestListener {
return (req, res) => {
captured.method = req.method
captured.url = req.url
const chunks: Buffer[] = []
req.on('data', c => chunks.push(c))
req.on('end', () => {
captured.body = Buffer.concat(chunks).toString('utf8')
const payload = JSON.stringify(body)
res.writeHead(status, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(payload),
})
res.end(payload)
})
}
}
function startServer(handler: http.RequestListener): Promise<StubServer> {
const captured: StubServer['lastRequest'] = {}
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => handler(req, res))
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo
resolve({
url: `http://127.0.0.1:${addr.port}`,
lastRequest: captured,
stop: () =>
new Promise<void>((res, rej) => server.close(err => (err ? rej(err) : res()))),
})
})
server.on('error', reject)
})
}
function makeClient(host: string): MembersClient {
return new MembersClient(createClient({ host, bearer: 'dfoa_test' }))
}
describe('MembersClient.list', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(
jsonResponder(
200,
{
page: 1,
limit: 20,
total: 1,
has_more: false,
data: [
{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' },
],
},
captured,
),
)
stub.lastRequest = captured
const result = await makeClient(stub.url).list('ws-1')
expect(captured.method).toBe('GET')
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
expect(result.data[0].email).toBe('mia@e.com')
})
it('URL-encodes workspace id', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, captured),
)
stub.lastRequest = captured
await makeClient(stub.url).list('ws with space')
expect(captured.url).toBe('/openapi/v1/workspaces/ws%20with%20space/members')
})
it('forwards page/limit as query params', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, captured),
)
stub.lastRequest = captured
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members?page=2&limit=50')
})
it('propagates server 403 as HTTPError', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(403, { error: 'forbidden' }, captured))
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 403,
)
})
it('propagates 404 as classified BaseError', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(404, { error: 'not found' }, captured))
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 404,
)
})
})
describe('MembersClient.invite', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('POSTs JSON body and returns parsed invite response', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(
jsonResponder(
201,
{
result: 'success',
email: 'new@e.com',
role: 'normal',
member_id: 'acct-9',
invite_url: 'https://console.example.com/activate?email=new&token=tok',
tenant_id: 'ws-1',
},
captured,
),
)
stub.lastRequest = captured
const result = await makeClient(stub.url).invite('ws-1', {
email: 'new@e.com',
role: 'normal',
})
expect(captured.method).toBe('POST')
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members')
expect(JSON.parse(captured.body ?? '{}')).toEqual({
email: 'new@e.com',
role: 'normal',
})
expect(result.member_id).toBe('acct-9')
expect(result.invite_url).toContain('token=tok')
})
it('propagates 400 (already in tenant) as HTTPError', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(400, { error: 'already in tenant' }, captured))
await expect(
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400)
})
})
describe('MembersClient.remove', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('DELETEs member by id and returns success', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(200, { result: 'success' }, captured))
stub.lastRequest = captured
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
expect(captured.method).toBe('DELETE')
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1')
expect(result.result).toBe('success')
})
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(400, { error: 'cannot operate self' }, captured))
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 400,
)
})
})
describe('MembersClient.updateRole', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('PUTs role payload to /role subresource', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(200, { result: 'success' }, captured))
stub.lastRequest = captured
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
expect(captured.method).toBe('PUT')
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/members/m-1/role')
expect(JSON.parse(captured.body ?? '{}')).toEqual({ role: 'admin' })
expect(result.result).toBe('success')
})
it('propagates 400 (admin cannot demote owner)', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(400, { error: 'no permission' }, captured))
await expect(
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
).rejects.toSatisfy(err => isBaseError(err) && err.httpStatus === 400)
})
})
describe('WorkspacesClient.switch (integration with stub)', () => {
let stub: StubServer
afterEach(async () => {
await stub?.stop()
})
it('POSTs /workspaces/<id>/switch and returns workspace detail', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(
jsonResponder(
200,
{
id: 'ws-1',
name: 'Workspace 1',
role: 'owner',
status: 'normal',
current: true,
created_at: '2026-05-18T00:00:00Z',
},
captured,
),
)
stub.lastRequest = captured
const { WorkspacesClient } = await import('./workspaces.js')
const client = new WorkspacesClient(createClient({ host: stub.url, bearer: 'dfoa_test' }))
const result = await client.switch('ws-1')
expect(captured.method).toBe('POST')
expect(captured.url).toBe('/openapi/v1/workspaces/ws-1/switch')
expect(result.current).toBe(true)
})
it('propagates 404 (non-member)', async () => {
const captured: StubServer['lastRequest'] = {}
stub = await startServer(jsonResponder(404, { error: 'not found' }, captured))
const { WorkspacesClient } = await import('./workspaces.js')
const client = new WorkspacesClient(createClient({ host: stub.url, bearer: 'dfoa_test' }))
await expect(client.switch('ws-x')).rejects.toSatisfy(
err => isBaseError(err) && err.httpStatus === 404,
)
})
})

61
cli/src/api/members.ts Normal file
View File

@ -0,0 +1,61 @@
import type {
MemberActionResponse,
MemberInvitePayload,
MemberInviteResponse,
MemberListResponse,
MemberRoleUpdatePayload,
} from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
/**
* Thin client for /openapi/v1/workspaces/<id>/members.
*
* Errors are surfaced as ky HTTPErrors with the server's status code
* (400/403/404/422). The CLI's AuthedCommand base layer maps those to
* user-visible messages clients never swallow status codes here.
*/
export class MembersClient {
private readonly http: KyInstance
constructor(http: KyInstance) {
this.http = http
}
async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise<MemberListResponse> {
const params = new URLSearchParams()
if (q?.page !== undefined)
params.set('page', String(q.page))
if (q?.limit !== undefined)
params.set('limit', String(q.limit))
const hasParams = Array.from(params.keys()).length > 0
const opts = hasParams ? { searchParams: params } : undefined
return this.http
.get(`workspaces/${encodeURIComponent(workspaceId)}/members`, opts)
.json<MemberListResponse>()
}
async invite(workspaceId: string, payload: MemberInvitePayload): Promise<MemberInviteResponse> {
return this.http
.post(`workspaces/${encodeURIComponent(workspaceId)}/members`, { json: payload })
.json<MemberInviteResponse>()
}
async remove(workspaceId: string, memberId: string): Promise<MemberActionResponse> {
return this.http
.delete(`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}`)
.json<MemberActionResponse>()
}
async updateRole(
workspaceId: string,
memberId: string,
payload: MemberRoleUpdatePayload,
): Promise<MemberActionResponse> {
return this.http
.put(
`workspaces/${encodeURIComponent(workspaceId)}/members/${encodeURIComponent(memberId)}/role`,
{ json: payload },
)
.json<MemberActionResponse>()
}
}

View File

@ -1,4 +1,4 @@
import type { WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
export class WorkspacesClient {
@ -11,4 +11,19 @@ export class WorkspacesClient {
async list(): Promise<WorkspaceListResponse> {
return this.http.get('workspaces').json<WorkspaceListResponse>()
}
/**
* Server-side workspace switch via OpenAPI POST
* `/workspaces/{id}/switch` the bearer-authed equivalent of the
* console's POST `/workspaces/switch`. The server updates the caller's
* `current` tenant_account_join row. Callers MUST refresh their local
* `hosts.yml` only after this resolves never fall back to a local
* write if the request fails, or `hosts.yml` will drift from the
* server's state.
*/
async switch(workspaceId: string): Promise<WorkspaceDetailResponse> {
return this.http
.post(`workspaces/${encodeURIComponent(workspaceId)}/switch`)
.json<WorkspaceDetailResponse>()
}
}

View File

@ -1,15 +1,17 @@
import type { SessionListResponse, SessionRow } from '@dify/contracts/api/openapi/types.gen'
import type { DifyMock } from '../../../../../test/fixtures/dify-mock/server.js'
import type { AccountSessionsClient } from '../../../../api/account-sessions.js'
import type { HostsBundle } from '../../../../auth/hosts.js'
import type { TokenStore } from '../../../../auth/store.js'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { startMock } from '../../../../../test/fixtures/dify-mock/server.js'
import { saveHosts } from '../../../../auth/hosts.js'
import { createClient } from '../../../../http/client.js'
import { bufferStreams } from '../../../../io/streams.js'
import { runDevicesList, runDevicesRevoke } from './devices.js'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
class MemStore implements TokenStore {
readonly entries = new Map<string, string>()
@ -187,3 +189,47 @@ describe('runDevicesRevoke', () => {
.toThrow(/specify a device label/)
})
})
describe('listAllSessions', () => {
const row = (id: string, label = `dev-${id}`): SessionRow => ({
id,
prefix: 'dfoa_xxx',
client_id: 'difyctl',
device_label: label,
created_at: null,
last_used_at: null,
expires_at: null,
})
function stubClient(pages: readonly SessionListResponse[]): { client: AccountSessionsClient, list: ReturnType<typeof vi.fn> } {
const list = vi.fn(async (q?: { page?: number, limit?: number }) => {
const page = q?.page ?? 1
const env = pages[page - 1]
if (env === undefined)
throw new Error(`stub: no page ${page}`)
return env
})
return { client: { list } as unknown as AccountSessionsClient, list }
}
it('exhausts pages until has_more=false', async () => {
const { client, list } = stubClient([
{ page: 1, limit: 200, total: 250, has_more: true, data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)) },
{ page: 2, limit: 200, total: 250, has_more: false, data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)) },
])
const all = await listAllSessions(client)
expect(all.length).toBe(250)
expect(list).toHaveBeenCalledTimes(2)
expect(list).toHaveBeenNthCalledWith(1, { page: 1, limit: 200 })
expect(list).toHaveBeenNthCalledWith(2, { page: 2, limit: 200 })
})
it('single page (has_more=false): one call', async () => {
const { client, list } = stubClient([
{ page: 1, limit: 200, total: 3, has_more: false, data: [row('a'), row('b'), row('c')] },
])
const all = await listAllSessions(client)
expect(all.length).toBe(3)
expect(list).toHaveBeenCalledTimes(1)
})
})

View File

@ -11,28 +11,64 @@ import { BaseError } from '../../../../errors/base.js'
import { ErrorCode } from '../../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../../io/color.js'
import { runWithSpinner } from '../../../../io/spinner.js'
import { LIMIT_DEFAULT, LIMIT_MAX, parseLimit } from '../../../../limit/limit.js'
export type DevicesListOptions = {
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly http: KyInstance
readonly json?: boolean
readonly page?: number
readonly limitRaw?: string
readonly envLookup?: (k: string) => string | undefined
}
export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
const b = requireLogin(opts.bundle)
const sessions = new AccountSessionsClient(opts.http)
const env = await runWithSpinner(
const env = opts.envLookup ?? ((k: string) => process.env[k])
const limit = resolveLimit(opts.limitRaw, env)
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
const envelope = await runWithSpinner(
{ io: opts.io, label: 'Fetching devices' },
() => sessions.list(),
() => sessions.list({ page, limit }),
)
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify(env)}\n`)
opts.io.out.write(`${JSON.stringify(envelope)}\n`)
return
}
opts.io.out.write(renderTable(env.data, b.token_id ?? ''))
opts.io.out.write(renderTable(envelope.data, b.token_id ?? ''))
}
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
if (raw !== undefined && raw !== '')
return parseLimit(raw, '--limit')
const envValue = env('DIFY_LIMIT')
if (envValue !== undefined && envValue !== '')
return parseLimit(envValue, 'DIFY_LIMIT')
return LIMIT_DEFAULT
}
/**
* Fetches every session across all pages. Used by revoke paths so that a
* session sitting on page 2+ is still findable / revocable. Uses the max
* page size (LIMIT_MAX) to minimize round-trips.
*/
export async function listAllSessions(client: AccountSessionsClient): Promise<readonly SessionRow[]> {
const out: SessionRow[] = []
let page = 1
// Hard guard against a misbehaving server that lies about has_more.
const MAX_PAGES = 100
while (page <= MAX_PAGES) {
const env = await client.list({ page, limit: LIMIT_MAX })
out.push(...env.data)
if (!env.has_more)
return out
page++
}
return out
}
export type DevicesRevokeOptions = {
@ -58,8 +94,8 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
}
const sessions = new AccountSessionsClient(opts.http)
const env = await sessions.list()
const { ids, selfHit } = pickTargets(env.data, opts, b.token_id ?? '')
const rows = await listAllSessions(sessions)
const { ids, selfHit } = pickTargets(rows, opts, b.token_id ?? '')
if (ids.length === 0) {
opts.io.out.write('no sessions to revoke\n')
return

View File

@ -9,17 +9,27 @@ export default class DevicesList extends DifyCommand {
static override examples = [
'<%= config.bin %> auth devices list',
'<%= config.bin %> auth devices list --json',
'<%= config.bin %> auth devices list --page 2 --limit 50',
]
static override flags = {
'http-retry': httpRetryFlag,
'json': Flags.boolean({ description: 'emit JSON', default: false }),
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
}
async run(argv: string[]): Promise<void> {
const { flags } = this.parse(DevicesList, argv)
const format = flags.json ? 'json' : ''
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
await runDevicesList({ io: ctx.io, bundle: ctx.bundle, http: ctx.http, json: flags.json })
await runDevicesList({
io: ctx.io,
bundle: ctx.bundle,
http: ctx.http,
json: flags.json,
page: flags.page,
limitRaw: flags.limit,
})
}
}

View File

@ -1,25 +0,0 @@
import { loadHosts } from '../../../auth/hosts.js'
import { resolveConfigDir } from '../../../config/dir.js'
import { Args } from '../../../framework/flags.js'
import { realStreams } from '../../../io/streams.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { runUse } from './use.js'
export default class Use extends DifyCommand {
static override description = 'Switch the active workspace for the current host'
static override examples = [
'<%= config.bin %> auth use ws-abc123',
]
static override args = {
workspaceId: Args.string({ description: 'workspace id to activate', required: true }),
}
async run(argv: string[]): Promise<void> {
const { args } = this.parse(Use, argv)
const configDir = resolveConfigDir()
const bundle = await loadHosts(configDir)
await runUse({ configDir, io: realStreams(), bundle, workspaceId: args.workspaceId })
}
}

View File

@ -1,71 +0,0 @@
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { bufferStreams } from '../../../io/streams.js'
import { runUse } from './use.js'
function accountBundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
token_id: 'tok-1',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Other', role: 'normal' },
],
}
}
describe('runUse', () => {
let configDir: string
beforeEach(async () => {
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-'))
})
afterEach(async () => {
await rm(configDir, { recursive: true, force: true })
})
it('switches workspace + persists hosts.yml', async () => {
const io = bufferStreams()
const b = accountBundle()
await saveHosts(configDir, b)
const next = await runUse({ configDir, io, bundle: b, workspaceId: 'ws-2' })
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Other', role: 'normal' })
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(io.outBuf()).toContain('Switched to workspace Other (ws-2)')
})
it('not-logged-in: throws NotLoggedIn', async () => {
const io = bufferStreams()
await expect(runUse({ configDir, io, bundle: undefined, workspaceId: 'ws-1' }))
.rejects
.toThrow(/not logged in/)
})
it('sso: throws workspace-unavailable', async () => {
const io = bufferStreams()
const b: HostsBundle = {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoe_test' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
}
await expect(runUse({ configDir, io, bundle: b, workspaceId: 'ws-1' }))
.rejects
.toThrow(/workspace context unavailable/)
})
it('unknown workspace: throws UsageMissingArg', async () => {
const io = bufferStreams()
await expect(runUse({ configDir, io, bundle: accountBundle(), workspaceId: 'ws-bogus' }))
.rejects
.toThrow(/ws-bogus.*not found/)
})
})

View File

@ -1,49 +0,0 @@
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import { saveHosts } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
export type UseOptions = {
readonly configDir: string
readonly io: IOStreams
readonly bundle: HostsBundle | undefined
readonly workspaceId: string
}
export async function runUse(opts: UseOptions): Promise<HostsBundle> {
const cs = colorScheme(colorEnabled(opts.io.isErrTTY))
const b = opts.bundle
if (b === undefined || b.tokens?.bearer === undefined || b.tokens.bearer === '') {
throw new BaseError({
code: ErrorCode.NotLoggedIn,
message: 'not logged in',
hint: 'run \'difyctl auth login\'',
})
}
if (b.external_subject !== undefined) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: 'workspace context unavailable for external SSO sessions',
hint: 'external SSO subjects don\'t carry tenant memberships in difyctl',
})
}
const found = (b.available_workspaces ?? []).find(w => w.id === opts.workspaceId)
if (found === undefined) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: `workspace "${opts.workspaceId}" not found in available_workspaces; run 'difyctl auth status' to list`,
})
}
const next: HostsBundle = { ...b, workspace: pickWorkspace(found) }
await saveHosts(opts.configDir, next)
opts.io.out.write(`${cs.successIcon()} Switched to workspace ${found.name} (${found.id})\n`)
return next
}
function pickWorkspace(w: Workspace): Workspace {
return { id: w.id, name: w.name, role: w.role }
}

View File

@ -0,0 +1,23 @@
import type { MemberInviteResponse } from '@dify/contracts/api/openapi/types.gen'
export class InviteOutput {
readonly response: MemberInviteResponse
readonly textLine: string
constructor(response: MemberInviteResponse, textLine: string) {
this.response = response
this.textLine = textLine
}
text(): string {
return this.textLine
}
json(): MemberInviteResponse {
return this.response
}
name(): string {
return this.response.member_id
}
}

View File

@ -0,0 +1,40 @@
import { Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runCreateMember } from './run.js'
export default class CreateMember extends DifyCommand {
static override description = 'Invite a member to the active (or specified) workspace by email'
static override examples = [
'<%= config.bin %> create member --email user@example.com --role normal',
'<%= config.bin %> create member --email user@example.com --role admin -w ws-1',
'<%= config.bin %> create member --email user@example.com --role normal -o json',
]
static override flags = {
'email': Flags.string({ description: 'invitee email address', required: true }),
'role': Flags.string({
description: 'role to assign (normal|admin); owner is not assignable here',
required: true,
}),
'workspace': Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
}
async run(argv: string[]) {
const { flags } = this.parse(CreateMember, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runCreateMember(
{ email: flags.email, role: flags.role, workspace: flags.workspace, format },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}
}

View File

@ -0,0 +1,102 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { runCreateMember } from './run.js'
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'inviter@example.com', name: 'Inviter' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
function fakeClient() {
return {
invite: vi.fn((_ws: string, body: { email: string, role: string }) =>
Promise.resolve({
result: 'success' as const,
email: body.email.toLowerCase(),
role: body.role,
member_id: 'acct-new',
invite_url: 'https://console.example.com/activate?email=x&token=tok',
tenant_id: 'ws-1',
})),
}
}
describe('runCreateMember', () => {
it('happy path: POSTs invite, returns InviteOutput with text/json/name', async () => {
const client = fakeClient()
const result = await runCreateMember(
{ email: 'new@example.com', role: 'normal' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.invite).toHaveBeenCalledWith('ws-1', { email: 'new@example.com', role: 'normal' })
expect(result.data.text()).toMatch(/Invited new@example\.com as normal/)
expect(result.data.name()).toBe('acct-new')
expect(result.data.json()).toMatchObject({
email: 'new@example.com',
role: 'normal',
member_id: 'acct-new',
invite_url: 'https://console.example.com/activate?email=x&token=tok',
tenant_id: 'ws-1',
})
expect(result.workspaceId).toBe('ws-1')
})
it('rejects unknown role before any HTTP call', async () => {
const client = fakeClient()
await expect(
runCreateMember(
{ email: 'new@example.com', role: 'owner' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
),
).rejects.toThrow(/invalid --role/)
expect(client.invite).not.toHaveBeenCalled()
})
it('rejects empty email', async () => {
const client = fakeClient()
await expect(
runCreateMember(
{ email: '', role: 'normal' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
),
).rejects.toThrow(/--email is required/)
expect(client.invite).not.toHaveBeenCalled()
})
it('-w flag overrides resolved workspace', async () => {
const client = fakeClient()
await runCreateMember(
{ email: 'new@example.com', role: 'admin', workspace: 'ws-9' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.invite).toHaveBeenCalledWith('ws-9', { email: 'new@example.com', role: 'admin' })
})
})

View File

@ -0,0 +1,75 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { InviteOutput } from './handlers.js'
export type CreateMemberOptions = {
readonly email: string
readonly role: string
readonly workspace?: string
readonly format?: string
}
export type CreateMemberDeps = {
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly membersFactory?: (http: KyInstance) => MembersClient
}
export type CreateMemberResult = {
readonly data: InviteOutput
readonly workspaceId: string
}
// `owner` is intentionally absent — ownership transfer is console-only.
const ASSIGNABLE_ROLES = new Set(['normal', 'admin'])
export async function runCreateMember(
opts: CreateMemberOptions,
deps: CreateMemberDeps,
): Promise<CreateMemberResult> {
if (opts.email === undefined || opts.email === '') {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: '--email is required',
})
}
if (!ASSIGNABLE_ROLES.has(opts.role)) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `invalid --role "${opts.role}"`,
hint: 'expected: normal | admin (ownership transfer is console-only)',
})
}
const env = deps.envLookup ?? ((k: string) => process.env[k])
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
const io = deps.io ?? nullStreams()
const cs = colorScheme(colorEnabled(io.isErrTTY))
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
bundle: deps.bundle,
})
const response = await runWithSpinner(
{ io, label: `Inviting ${opts.email}` },
() => factory(deps.http).invite(wsId, {
email: opts.email,
role: opts.role as 'normal' | 'admin',
}),
)
const textLine = `${cs.successIcon()} Invited ${response.email} as ${response.role}\n`
return { data: new InviteOutput(response, textLine), workspaceId: wsId }
}

View File

@ -0,0 +1,26 @@
export type DeletedMemberPayload = {
readonly id: string
readonly deleted: true
}
export class DeleteMemberOutput {
readonly payload: DeletedMemberPayload
readonly textLine: string
constructor(memberId: string, textLine: string) {
this.payload = { id: memberId, deleted: true }
this.textLine = textLine
}
text(): string {
return this.textLine
}
json(): DeletedMemberPayload {
return this.payload
}
name(): string {
return this.payload.id
}
}

View File

@ -0,0 +1,40 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runDeleteMember } from './run.js'
export default class DeleteMember extends DifyCommand {
static override description = 'Remove a member from the active (or specified) workspace'
static override examples = [
'<%= config.bin %> delete member acct-1',
'<%= config.bin %> delete member acct-1 -w ws-1',
'<%= config.bin %> delete member acct-1 -o json',
]
static override args = {
memberId: Args.string({ description: 'account id of the member to remove', required: true }),
}
static override flags = {
'workspace': Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
}
async run(argv: string[]) {
const { args, flags } = this.parse(DeleteMember, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runDeleteMember(
{ memberId: args.memberId, workspace: flags.workspace, format, yes: flags.yes },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}
}

View File

@ -0,0 +1,72 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { runDeleteMember } from './run.js'
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
function fakeClient() {
return {
remove: vi.fn(() => Promise.resolve({ result: 'success' as const })),
}
}
describe('runDeleteMember', () => {
it('happy path: DELETE, returns DeleteMemberOutput with text/json/name', async () => {
const client = fakeClient()
const result = await runDeleteMember(
{ memberId: 'acct-2' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.remove).toHaveBeenCalledExactlyOnceWith('ws-1', 'acct-2')
expect(result.data.text()).toMatch(/Removed acct-2/)
expect(result.data.name()).toBe('acct-2')
expect(result.data.json()).toEqual({ id: 'acct-2', deleted: true })
expect(result.workspaceId).toBe('ws-1')
})
it('-w flag overrides resolved workspace', async () => {
const client = fakeClient()
await runDeleteMember(
{ memberId: 'acct-2', workspace: 'ws-9' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.remove).toHaveBeenCalledWith('ws-9', 'acct-2')
})
it('rejects empty member id before any HTTP call', async () => {
const client = fakeClient()
await expect(
runDeleteMember(
{ memberId: '' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
),
).rejects.toThrow(/member id is required/)
expect(client.remove).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,90 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import * as readline from 'node:readline'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { DeleteMemberOutput } from './handlers.js'
export type DeleteMemberOptions = {
readonly memberId: string
readonly workspace?: string
readonly format?: string
readonly yes?: boolean
}
export type DeleteMemberDeps = {
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly membersFactory?: (http: KyInstance) => MembersClient
}
export type DeleteMemberResult = {
readonly data: DeleteMemberOutput
readonly workspaceId: string
}
export async function runDeleteMember(
opts: DeleteMemberOptions,
deps: DeleteMemberDeps,
): Promise<DeleteMemberResult> {
if (opts.memberId === undefined || opts.memberId === '') {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'member id is required',
hint: 'pass it positionally: difyctl delete member <member-id>',
})
}
const env = deps.envLookup ?? ((k: string) => process.env[k])
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
const io = deps.io ?? nullStreams()
const cs = colorScheme(colorEnabled(io.isErrTTY))
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
bundle: deps.bundle,
})
if (!opts.yes && io.isErrTTY) {
const confirmed = await promptConfirm(io, `Remove member ${opts.memberId}? [y/N] `)
if (!confirmed) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'aborted by user',
hint: 'pass --yes to skip confirmation',
})
}
}
await runWithSpinner(
{ io, label: `Removing ${opts.memberId}` },
() => factory(deps.http).remove(wsId, opts.memberId),
)
const textLine = `${cs.successIcon()} Removed ${opts.memberId}\n`
return {
data: new DeleteMemberOutput(opts.memberId, textLine),
workspaceId: wsId,
}
}
async function promptConfirm(io: IOStreams, message: string): Promise<boolean> {
io.err.write(message)
const rl = readline.createInterface({ input: io.in, output: io.err, terminal: false })
try {
const line: string = await new Promise(resolve => rl.once('line', resolve))
return line.trim().toLowerCase() === 'y'
}
finally {
rl.close()
}
}

View File

@ -0,0 +1,89 @@
import type { MemberListResponse, MemberResponse } from '@dify/contracts/api/openapi/types.gen'
import type { TableCell } from '../../../framework/output.js'
import type { TableColumn } from '../../../printers/format-table.js'
export const MEMBER_MODE_KEY = 'member'
const CURRENT_MARKER = '*'
export const MEMBER_COLUMNS: readonly TableColumn[] = [
{ name: 'ID', priority: 0 },
{ name: 'NAME', priority: 0 },
{ name: 'EMAIL', priority: 0 },
{ name: 'ROLE', priority: 0 },
{ name: 'STATUS', priority: 0 },
{ name: 'CURRENT', priority: 0 },
]
export class MemberRow {
readonly id: string
readonly displayName: string
readonly email: string
readonly role: string
readonly status: string
readonly current: boolean
constructor(member: MemberResponse, current: boolean) {
this.id = member.id
this.displayName = member.name
this.email = member.email
this.role = member.role
this.status = member.status
this.current = current
}
tableRow(): readonly TableCell[] {
return [
this.id,
this.displayName,
this.email,
this.role,
this.status,
this.current ? CURRENT_MARKER : '',
]
}
name(): string {
return this.id
}
json() {
return {
id: this.id,
name: this.displayName,
email: this.email,
role: this.role,
status: this.status,
current: this.current,
}
}
}
export class MemberListOutput {
readonly rows: readonly MemberRow[]
readonly envelope: MemberListResponse
constructor(rows: readonly MemberRow[], envelope: MemberListResponse) {
this.rows = rows
this.envelope = envelope
}
static tableColumns(): readonly TableColumn[] {
return MEMBER_COLUMNS
}
tableColumns(): readonly TableColumn[] {
return MemberListOutput.tableColumns()
}
tableRows(): readonly (readonly TableCell[])[] {
return this.rows.map(row => row.tableRow())
}
name(): string {
return this.rows.map(row => row.name()).join('\n')
}
json(): MemberListResponse {
return this.envelope
}
}

View File

@ -0,0 +1,44 @@
import { Flags } from '../../../framework/flags.js'
import { table } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runGetMember } from './run.js'
export default class GetMember extends DifyCommand {
static override description = 'List members of the active (or specified) workspace'
static override examples = [
'<%= config.bin %> get member',
'<%= config.bin %> get member -w ws-1',
'<%= config.bin %> get member --page 2 --limit 50',
'<%= config.bin %> get member -o json',
'<%= config.bin %> get member -o name',
]
static override flags = {
'workspace': Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|wide)', default: '' }),
}
async run(argv: string[]) {
const { flags } = this.parse(GetMember, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runGetMember(
{
workspace: flags.workspace,
page: flags.page,
limitRaw: flags.limit,
format,
},
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return table({ format, data: result.data })
}
}

View File

@ -0,0 +1,153 @@
import type { MemberListResponse } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { runGetMember } from './run.js'
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
function fakeClient(envelope: MemberListResponse) {
return { list: vi.fn(() => Promise.resolve(envelope)) }
}
describe('runGetMember', () => {
const env: MemberListResponse = {
page: 1,
limit: 20,
total: 2,
has_more: false,
data: [
{ id: 'acct-1', name: 'Me', email: 'me@example.com', role: 'owner', status: 'active' },
{ id: 'acct-2', name: 'Mate', email: 'mate@example.com', role: 'admin', status: 'active' },
],
}
it('lists members and marks the calling account with current=true', async () => {
const client = fakeClient(env)
const r = await runGetMember(
{},
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.list).toHaveBeenCalledExactlyOnceWith('ws-1', { page: 1, limit: 20 })
expect(r.workspaceId).toBe('ws-1')
expect(r.data.rows.map(row => row.current)).toEqual([true, false])
expect(r.data.rows.map(row => row.id)).toEqual(['acct-1', 'acct-2'])
})
it('-w flag overrides resolved workspace', async () => {
const client = fakeClient(env)
const r = await runGetMember(
{ workspace: 'ws-9' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.list).toHaveBeenCalledWith('ws-9', { page: 1, limit: 20 })
expect(r.workspaceId).toBe('ws-9')
})
it('--page/--limit are forwarded to the client', async () => {
const client = fakeClient(env)
await runGetMember(
{ page: 3, limitRaw: '50' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.list).toHaveBeenCalledWith('ws-1', { page: 3, limit: 50 })
})
it('marks no row when bundle has no account id', async () => {
const client = fakeClient(env)
const b = bundle()
b.account = { id: '', email: '', name: '' }
const r = await runGetMember(
{},
{
bundle: b,
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(r.data.rows.every(row => !row.current)).toBe(true)
})
it('throws when no workspace can be resolved', async () => {
const client = fakeClient(env)
await expect(
runGetMember(
{},
{
bundle: {
current_host: '',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: '', name: '' },
},
http: {} as KyInstance,
io: bufferStreams(),
envLookup: () => undefined,
membersFactory: () => client as never,
},
),
).rejects.toThrow(/no workspace selected/)
expect(client.list).not.toHaveBeenCalled()
})
})
describe('MemberListOutput shape', () => {
it('builds table with CURRENT marker column', async () => {
const env: MemberListResponse = {
page: 1,
limit: 20,
total: 1,
has_more: false,
data: [
{ id: 'acct-1', name: 'Me', email: 'me@example.com', role: 'owner', status: 'active' },
],
}
const client = fakeClient(env)
const r = await runGetMember(
{},
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(r.data.tableColumns().map(c => c.name)).toEqual([
'ID',
'NAME',
'EMAIL',
'ROLE',
'STATUS',
'CURRENT',
])
expect(r.data.tableRows()[0]?.[5]).toBe('*')
expect(r.data.name()).toBe('acct-1')
expect(r.data.json().data[0]?.email).toBe('me@example.com')
})
})

View File

@ -0,0 +1,65 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { LIMIT_DEFAULT, parseLimit } from '../../../limit/limit.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { MemberListOutput, MemberRow } from './handlers.js'
export type GetMemberOptions = {
readonly workspace?: string
readonly page?: number
readonly limitRaw?: string
readonly format?: string
}
export type GetMemberDeps = {
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly membersFactory?: (http: KyInstance) => MembersClient
}
export type GetMemberResult = {
readonly data: MemberListOutput
readonly workspaceId: string
}
export async function runGetMember(
opts: GetMemberOptions,
deps: GetMemberDeps,
): Promise<GetMemberResult> {
const env = deps.envLookup ?? ((k: string) => process.env[k])
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
const io = deps.io ?? nullStreams()
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
bundle: deps.bundle,
})
const limit = resolveLimit(opts.limitRaw, env)
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
const envelope = await runWithSpinner(
{ io, label: 'Fetching members' },
() => factory(deps.http).list(wsId, { page, limit }),
)
const callerId = deps.bundle.account?.id ?? ''
const rows = envelope.data.map(m => new MemberRow(m, callerId !== '' && m.id === callerId))
return { data: new MemberListOutput(rows, envelope), workspaceId: wsId }
}
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
if (raw !== undefined && raw !== '')
return parseLimit(raw, '--limit')
const envValue = env('DIFY_LIMIT')
if (envValue !== undefined && envValue !== '')
return parseLimit(envValue, 'DIFY_LIMIT')
return LIMIT_DEFAULT
}

View File

@ -0,0 +1,26 @@
export type SetMemberPayload = {
readonly id: string
readonly role: 'normal' | 'admin'
}
export class SetMemberOutput {
readonly payload: SetMemberPayload
readonly textLine: string
constructor(payload: SetMemberPayload, textLine: string) {
this.payload = payload
this.textLine = textLine
}
text(): string {
return this.textLine
}
json(): SetMemberPayload {
return this.payload
}
name(): string {
return this.payload.id
}
}

View File

@ -0,0 +1,43 @@
import { Args, Flags } from '../../../framework/flags.js'
import { formatted } from '../../../framework/output.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runSetMember } from './run.js'
export default class SetMember extends DifyCommand {
static override description = 'Change a member\'s role in the active (or specified) workspace'
static override examples = [
'<%= config.bin %> set member acct-1 --role admin',
'<%= config.bin %> set member acct-1 --role normal -w ws-1',
'<%= config.bin %> set member acct-1 --role admin -o json',
]
static override args = {
memberId: Args.string({ description: 'account id of the member to update', required: true }),
}
static override flags = {
'role': Flags.string({
description: 'new role (normal|admin); owner is not assignable here',
required: true,
}),
'workspace': Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.string({ char: 'o', description: 'output format (json|yaml|name|text)', default: '' }),
}
async run(argv: string[]) {
const { args, flags } = this.parse(SetMember, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runSetMember(
{ memberId: args.memberId, role: flags.role, workspace: flags.workspace, format },
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
)
return formatted({ format, data: result.data })
}
}

View File

@ -0,0 +1,87 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { describe, expect, it, vi } from 'vitest'
import { bufferStreams } from '../../../io/streams.js'
import { runSetMember } from './run.js'
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'me@example.com', name: 'Me' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
}
}
function fakeClient() {
return {
updateRole: vi.fn(() => Promise.resolve({ result: 'success' as const })),
}
}
describe('runSetMember', () => {
it('happy path: PUT new role, returns SetMemberOutput with text/json/name', async () => {
const client = fakeClient()
const result = await runSetMember(
{ memberId: 'acct-2', role: 'admin' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.updateRole).toHaveBeenCalledExactlyOnceWith('ws-1', 'acct-2', { role: 'admin' })
expect(result.data.text()).toMatch(/Set acct-2 role to admin/)
expect(result.data.name()).toBe('acct-2')
expect(result.data.json()).toEqual({ id: 'acct-2', role: 'admin' })
expect(result.workspaceId).toBe('ws-1')
})
it('rejects unknown role before any HTTP call', async () => {
const client = fakeClient()
await expect(
runSetMember(
{ memberId: 'acct-2', role: 'owner' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
),
).rejects.toThrow(/invalid --role/)
expect(client.updateRole).not.toHaveBeenCalled()
})
it('rejects empty member id', async () => {
const client = fakeClient()
await expect(
runSetMember(
{ memberId: '', role: 'admin' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
),
).rejects.toThrow(/member id is required/)
})
it('-w flag overrides resolved workspace', async () => {
const client = fakeClient()
await runSetMember(
{ memberId: 'acct-2', role: 'normal', workspace: 'ws-9' },
{
bundle: bundle(),
http: {} as KyInstance,
io: bufferStreams(),
membersFactory: () => client as never,
},
)
expect(client.updateRole).toHaveBeenCalledWith('ws-9', 'acct-2', { role: 'normal' })
})
})

View File

@ -0,0 +1,78 @@
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import { MembersClient } from '../../../api/members.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
import { nullStreams } from '../../../io/streams.js'
import { resolveWorkspaceId } from '../../../workspace/resolver.js'
import { SetMemberOutput } from './handlers.js'
export type SetMemberOptions = {
readonly memberId: string
readonly role: string
readonly workspace?: string
readonly format?: string
}
export type SetMemberDeps = {
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io?: IOStreams
readonly envLookup?: (k: string) => string | undefined
readonly membersFactory?: (http: KyInstance) => MembersClient
}
export type SetMemberResult = {
readonly data: SetMemberOutput
readonly workspaceId: string
}
const ASSIGNABLE_ROLES = new Set(['normal', 'admin'])
export async function runSetMember(
opts: SetMemberOptions,
deps: SetMemberDeps,
): Promise<SetMemberResult> {
if (opts.memberId === undefined || opts.memberId === '') {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'member id is required',
hint: 'pass it positionally: difyctl set member <member-id> --role <role>',
})
}
if (!ASSIGNABLE_ROLES.has(opts.role)) {
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `invalid --role "${opts.role}"`,
hint: 'expected: normal | admin (ownership transfer is console-only)',
})
}
const env = deps.envLookup ?? ((k: string) => process.env[k])
const factory = deps.membersFactory ?? ((h: KyInstance) => new MembersClient(h))
const io = deps.io ?? nullStreams()
const cs = colorScheme(colorEnabled(io.isErrTTY))
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
bundle: deps.bundle,
})
await runWithSpinner(
{ io, label: `Updating role for ${opts.memberId}` },
() => factory(deps.http).updateRole(wsId, opts.memberId, {
role: opts.role as 'normal' | 'admin',
}),
)
const role = opts.role as 'normal' | 'admin'
const textLine = `${cs.successIcon()} Set ${opts.memberId} role to ${role}\n`
return {
data: new SetMemberOutput({ id: opts.memberId, role }, textLine),
workspaceId: wsId,
}
}

View File

@ -7,22 +7,26 @@ import AuthDevicesRevoke from './auth/devices/revoke/index.js'
import AuthLogin from './auth/login/index.js'
import AuthLogout from './auth/logout/index.js'
import AuthStatus from './auth/status/index.js'
import AuthUse from './auth/use/index.js'
import AuthWhoami from './auth/whoami/index.js'
import ConfigGet from './config/get/index.js'
import ConfigPath from './config/path/index.js'
import ConfigSet from './config/set/index.js'
import ConfigUnset from './config/unset/index.js'
import ConfigView from './config/view/index.js'
import CreateMember from './create/member/index.js'
import DeleteMember from './delete/member/index.js'
import DescribeApp from './describe/app/index.js'
import EnvList from './env/list/index.js'
import GetApp from './get/app/index.js'
import GetMember from './get/member/index.js'
import GetWorkspace from './get/workspace/index.js'
import HelpAccount from './help/account/index.js'
import HelpEnvironment from './help/environment/index.js'
import HelpExternal from './help/external/index.js'
import ResumeApp from './resume/app/index.js'
import RunApp from './run/app/index.js'
import SetMember from './set/member/index.js'
import UseWorkspace from './use/workspace/index.js'
import Version from './version/index.js'
export const commandTree: CommandTree = {
@ -37,7 +41,6 @@ export const commandTree: CommandTree = {
login: { command: AuthLogin, subcommands: {} },
logout: { command: AuthLogout, subcommands: {} },
status: { command: AuthStatus, subcommands: {} },
use: { command: AuthUse, subcommands: {} },
whoami: { command: AuthWhoami, subcommands: {} },
},
},
@ -50,6 +53,16 @@ export const commandTree: CommandTree = {
view: { command: ConfigView, subcommands: {} },
},
},
create: {
subcommands: {
member: { command: CreateMember, subcommands: {} },
},
},
delete: {
subcommands: {
member: { command: DeleteMember, subcommands: {} },
},
},
describe: {
subcommands: {
app: { command: DescribeApp, subcommands: {} },
@ -63,6 +76,7 @@ export const commandTree: CommandTree = {
get: {
subcommands: {
app: { command: GetApp, subcommands: {} },
member: { command: GetMember, subcommands: {} },
workspace: { command: GetWorkspace, subcommands: {} },
},
},
@ -83,5 +97,15 @@ export const commandTree: CommandTree = {
app: { command: RunApp, subcommands: {} },
},
},
set: {
subcommands: {
member: { command: SetMember, subcommands: {} },
},
},
use: {
subcommands: {
workspace: { command: UseWorkspace, subcommands: {} },
},
},
version: { command: Version, subcommands: {} },
}

View File

@ -0,0 +1,31 @@
import { Args } from '../../../framework/flags.js'
import { DifyCommand } from '../../_shared/dify-command.js'
import { httpRetryFlag } from '../../_shared/global-flags.js'
import { runUseWorkspace } from './use.js'
export default class UseWorkspace extends DifyCommand {
static override description = 'Switch the active workspace on the server and refresh hosts.yml'
static override examples = [
'<%= config.bin %> use workspace ws-abc123',
]
static override args = {
workspaceId: Args.string({ description: 'workspace id to switch to', required: true }),
}
static override flags = {
'http-retry': httpRetryFlag,
}
async run(argv: string[]): Promise<void> {
const { args, flags } = this.parse(UseWorkspace, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
await runUseWorkspace({ workspaceId: args.workspaceId }, {
configDir: ctx.configDir,
bundle: ctx.bundle,
http: ctx.http,
io: ctx.io,
})
}
}

View File

@ -0,0 +1,199 @@
import type {
WorkspaceDetailResponse,
WorkspaceListResponse,
} from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'
import type { HostsBundle } from '../../../auth/hosts.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { loadHosts, saveHosts } from '../../../auth/hosts.js'
import { bufferStreams } from '../../../io/streams.js'
import { runUseWorkspace } from './use.js'
function bundle(): HostsBundle {
return {
current_host: 'cloud.dify.ai',
token_storage: 'file',
tokens: { bearer: 'dfoa_test' },
account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Tester' },
workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
available_workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Stale Name', role: 'normal' },
],
}
}
function fakeClient(opts: {
switch?: () => Promise<WorkspaceDetailResponse>
list?: () => Promise<WorkspaceListResponse>
}) {
return {
switch: vi.fn(opts.switch ?? (() => Promise.resolve({
id: 'ws-2',
name: 'Switched',
role: 'normal',
status: 'normal',
current: true,
created_at: '2026-05-18T00:00:00Z',
}))),
list: vi.fn(opts.list ?? (() => Promise.resolve({
workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false },
{ id: 'ws-2', name: 'Switched', role: 'normal', status: 'normal', current: true },
],
}))),
}
}
describe('runUseWorkspace', () => {
let configDir: string
beforeEach(async () => {
configDir = await mkdtemp(join(tmpdir(), 'difyctl-use-workspace-'))
})
afterEach(async () => {
await rm(configDir, { recursive: true, force: true })
})
it('happy path: POST /switch → GET /workspaces → write hosts.yml', async () => {
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const client = fakeClient({})
const next = await runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
},
)
expect(client.switch).toHaveBeenCalledExactlyOnceWith('ws-2')
expect(client.list).toHaveBeenCalledOnce()
expect(next.workspace).toEqual({ id: 'ws-2', name: 'Switched', role: 'normal' })
expect(next.available_workspaces).toEqual([
{ id: 'ws-1', name: 'Default', role: 'owner' },
{ id: 'ws-2', name: 'Switched', role: 'normal' },
])
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.id).toBe('ws-2')
expect(reloaded?.workspace?.name).toBe('Switched')
expect(io.outBuf()).toMatch(/Switched to Switched \(ws-2\)/)
})
it('refreshes stale workspace name from server', async () => {
// bundle has ws-2 named "Stale Name"; server returns "Switched".
// We expect saveHosts to record the fresh name from the server.
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const client = fakeClient({})
await runUseWorkspace(
{ workspaceId: 'ws-2' },
{ configDir, bundle: b, http: {} as KyInstance, io, workspacesFactory: () => client as never },
)
const reloaded = await loadHosts(configDir)
expect(reloaded?.workspace?.name).toBe('Switched')
expect(reloaded?.available_workspaces?.find(w => w.id === 'ws-2')?.name).toBe('Switched')
})
it('does NOT mutate hosts.yml when POST /switch fails', async () => {
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
switch: () => Promise.reject(new Error('forbidden')),
})
await expect(
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
},
),
).rejects.toThrow(/forbidden/)
expect(client.list).not.toHaveBeenCalled()
const after = await loadHosts(configDir)
expect(after).toEqual(before)
expect(after?.workspace?.id).toBe('ws-1')
})
it('does NOT mutate hosts.yml when GET /workspaces fails after switch', async () => {
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const before = await loadHosts(configDir)
const client = fakeClient({
list: () => Promise.reject(new Error('transient list failure')),
})
await expect(
runUseWorkspace(
{ workspaceId: 'ws-2' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
},
),
).rejects.toThrow(/transient list failure/)
const after = await loadHosts(configDir)
expect(after).toEqual(before)
})
it('throws when server returns switch=<id> but id is missing from /workspaces list', async () => {
const io = bufferStreams()
const b = bundle()
await saveHosts(configDir, b)
const client = fakeClient({
switch: () => Promise.resolve({
id: 'ws-7',
name: 'Ghost',
role: 'normal',
status: 'normal',
current: true,
created_at: null as unknown as string,
}),
list: () => Promise.resolve({
workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: false },
],
}),
})
await expect(
runUseWorkspace(
{ workspaceId: 'ws-7' },
{
configDir,
bundle: b,
http: {} as KyInstance,
io,
workspacesFactory: () => client as never,
},
),
).rejects.toThrow(/not visible in \/workspaces/)
})
})

View File

@ -0,0 +1,76 @@
import type { KyInstance } from 'ky'
import type { HostsBundle, Workspace } from '../../../auth/hosts.js'
import type { IOStreams } from '../../../io/streams.js'
import { WorkspacesClient } from '../../../api/workspaces.js'
import { saveHosts } from '../../../auth/hosts.js'
import { BaseError } from '../../../errors/base.js'
import { ErrorCode } from '../../../errors/codes.js'
import { colorEnabled, colorScheme } from '../../../io/color.js'
import { runWithSpinner } from '../../../io/spinner.js'
export type UseWorkspaceOptions = {
readonly workspaceId: string
}
export type UseWorkspaceDeps = {
readonly configDir: string
readonly bundle: HostsBundle
readonly http: KyInstance
readonly io: IOStreams
readonly workspacesFactory?: (http: KyInstance) => WorkspacesClient
}
/**
* Switch the caller's active workspace.
*
* Strict ordering:
* 1. POST /workspaces/<id>/switch if this fails (403/404/etc.) we abort
* with no `hosts.yml` mutation, so local state never diverges from the
* server. Any fallback to a pure-local update is explicitly disallowed
* (see workspace-plan.md decision D4).
* 2. GET /workspaces refresh the membership list so `available_workspaces`
* stays in sync. Failure here also aborts; the server-side current has
* already moved, but the local file is left untouched. A follow-up
* `difyctl get workspace` will reconcile.
* 3. Persist `workspace` + `available_workspaces` atomically via `saveHosts`.
*/
export async function runUseWorkspace(
opts: UseWorkspaceOptions,
deps: UseWorkspaceDeps,
): Promise<HostsBundle> {
const cs = colorScheme(colorEnabled(deps.io.isErrTTY))
const factory = deps.workspacesFactory ?? ((h: KyInstance) => new WorkspacesClient(h))
const client = factory(deps.http)
const detail = await runWithSpinner(
{ io: deps.io, label: `Switching to ${opts.workspaceId}` },
() => client.switch(opts.workspaceId),
)
const list = await runWithSpinner(
{ io: deps.io, label: 'Refreshing workspaces' },
() => client.list(),
)
const matched = list.workspaces.find(w => w.id === detail.id)
if (matched === undefined) {
throw new BaseError({
code: ErrorCode.Unknown,
message: `server returned switch=${detail.id} but it is not visible in /workspaces`,
hint: 'try again or contact your workspace admin',
})
}
const next: HostsBundle = {
...deps.bundle,
workspace: { id: matched.id, name: matched.name, role: matched.role },
available_workspaces: list.workspaces.map<Workspace>(w => ({
id: w.id,
name: w.name,
role: w.role,
})),
}
await saveHosts(deps.configDir, next)
deps.io.out.write(`${cs.successIcon()} Switched to ${matched.name} (${matched.id})\n`)
return next
}

View File

@ -25,7 +25,7 @@ export function resolveWorkspaceId(inputs: WorkspaceResolveInputs): string {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'no workspace selected',
hint: 'pass --workspace, set DIFY_WORKSPACE_ID, or run \'difyctl auth use\'',
hint: 'pass --workspace, set DIFY_WORKSPACE_ID, or run \'difyctl use workspace <id>\'',
})
}

View File

@ -7,6 +7,8 @@ import {
zDeleteAccountSessionsBySessionIdPath,
zDeleteAccountSessionsBySessionIdResponse,
zDeleteAccountSessionsSelfResponse,
zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath,
zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse,
zGetAccountResponse,
zGetAccountSessionsResponse,
zGetAppsByAppIdDescribePath,
@ -23,6 +25,9 @@ import {
zGetOauthDeviceLookupResponse,
zGetPermittedExternalAppsResponse,
zGetVersionResponse,
zGetWorkspacesByWorkspaceIdMembersPath,
zGetWorkspacesByWorkspaceIdMembersQuery,
zGetWorkspacesByWorkspaceIdMembersResponse,
zGetWorkspacesByWorkspaceIdPath,
zGetWorkspacesByWorkspaceIdResponse,
zGetWorkspacesResponse,
@ -44,6 +49,14 @@ import {
zPostOauthDeviceDenyResponse,
zPostOauthDeviceTokenBody,
zPostOauthDeviceTokenResponse,
zPostWorkspacesByWorkspaceIdMembersBody,
zPostWorkspacesByWorkspaceIdMembersPath,
zPostWorkspacesByWorkspaceIdMembersResponse,
zPostWorkspacesByWorkspaceIdSwitchPath,
zPostWorkspacesByWorkspaceIdSwitchResponse,
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse,
} from './zod.gen'
/**
@ -461,7 +474,97 @@ export const permittedExternalApps = {
get: get10,
}
export const put = oc
.route({
inputStructure: 'detailed',
method: 'PUT',
operationId: 'putWorkspacesByWorkspaceIdMembersByMemberIdRole',
path: '/workspaces/{workspace_id}/members/{member_id}/role',
tags: ['openapi'],
})
.input(
z.object({
body: zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody,
params: zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath,
}),
)
.output(zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse)
export const role = {
put,
}
export const delete3 = oc
.route({
inputStructure: 'detailed',
method: 'DELETE',
operationId: 'deleteWorkspacesByWorkspaceIdMembersByMemberId',
path: '/workspaces/{workspace_id}/members/{member_id}',
tags: ['openapi'],
})
.input(z.object({ params: zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath }))
.output(zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse)
export const byMemberId = {
delete: delete3,
role,
}
export const get11 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesByWorkspaceIdMembers',
path: '/workspaces/{workspace_id}/members',
tags: ['openapi'],
})
.input(
z.object({
params: zGetWorkspacesByWorkspaceIdMembersPath,
query: zGetWorkspacesByWorkspaceIdMembersQuery.optional(),
}),
)
.output(zGetWorkspacesByWorkspaceIdMembersResponse)
export const post9 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdMembers',
path: '/workspaces/{workspace_id}/members',
successStatus: 201,
tags: ['openapi'],
})
.input(
z.object({
body: zPostWorkspacesByWorkspaceIdMembersBody,
params: zPostWorkspacesByWorkspaceIdMembersPath,
}),
)
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
export const members = {
get: get11,
post: post9,
byMemberId,
}
export const post10 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
operationId: 'postWorkspacesByWorkspaceIdSwitch',
path: '/workspaces/{workspace_id}/switch',
tags: ['openapi'],
})
.input(z.object({ params: zPostWorkspacesByWorkspaceIdSwitchPath }))
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
export const switch_ = {
post: post10,
}
export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -473,10 +576,12 @@ export const get11 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
get: get11,
get: get12,
members,
switch: switch_,
}
export const get12 = oc
export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@ -487,7 +592,7 @@ export const get12 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
get: get12,
get: get13,
byWorkspaceId,
}

View File

@ -169,6 +169,50 @@ export type HumanInputFormSubmitPayload = {
export type JsonValue = unknown
export type MemberActionResponse = {
result?: string
}
export type MemberInvitePayload = {
email: string
role: 'admin' | 'normal'
}
export type MemberInviteResponse = {
email: string
invite_url: string
member_id: string
result?: string
role: string
tenant_id: string
}
export type MemberListQuery = {
limit?: number
page?: number
}
export type MemberListResponse = {
data: Array<MemberResponse>
has_more: boolean
limit: number
page: number
total: number
}
export type MemberResponse = {
avatar?: string | null
email: string
id: string
name: string
role: string
status: string
}
export type MemberRoleUpdatePayload = {
role: 'admin' | 'normal'
}
export type MessageMetadata = {
retriever_resources?: Array<{
[key: string]: unknown
@ -638,3 +682,88 @@ export type GetWorkspacesByWorkspaceIdResponses = {
export type GetWorkspacesByWorkspaceIdResponse
= GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses]
export type GetWorkspacesByWorkspaceIdMembersData = {
body?: never
path: {
workspace_id: string
}
query?: {
limit?: number
page?: number
}
url: '/workspaces/{workspace_id}/members'
}
export type GetWorkspacesByWorkspaceIdMembersResponses = {
200: MemberListResponse
}
export type GetWorkspacesByWorkspaceIdMembersResponse
= GetWorkspacesByWorkspaceIdMembersResponses[keyof GetWorkspacesByWorkspaceIdMembersResponses]
export type PostWorkspacesByWorkspaceIdMembersData = {
body: MemberInvitePayload
path: {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/members'
}
export type PostWorkspacesByWorkspaceIdMembersResponses = {
201: MemberInviteResponse
}
export type PostWorkspacesByWorkspaceIdMembersResponse
= PostWorkspacesByWorkspaceIdMembersResponses[keyof PostWorkspacesByWorkspaceIdMembersResponses]
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = {
body?: never
path: {
member_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/members/{member_id}'
}
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
200: MemberActionResponse
}
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses]
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
body: MemberRoleUpdatePayload
path: {
member_id: string
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/members/{member_id}/role'
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
200: MemberActionResponse
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses]
export type PostWorkspacesByWorkspaceIdSwitchData = {
body?: never
path: {
workspace_id: string
}
query?: never
url: '/workspaces/{workspace_id}/switch'
}
export type PostWorkspacesByWorkspaceIdSwitchResponses = {
200: WorkspaceDetailResponse
}
export type PostWorkspacesByWorkspaceIdSwitchResponse
= PostWorkspacesByWorkspaceIdSwitchResponses[keyof PostWorkspacesByWorkspaceIdSwitchResponses]

View File

@ -150,6 +150,73 @@ export const zHumanInputFormSubmitPayload = z.object({
inputs: z.record(z.string(), zJsonValue),
})
/**
* MemberActionResponse
*/
export const zMemberActionResponse = z.object({
result: z.string().optional().default('success'),
})
/**
* MemberInvitePayload
*/
export const zMemberInvitePayload = z.object({
email: z.string(),
role: z.enum(['admin', 'normal']),
})
/**
* MemberInviteResponse
*/
export const zMemberInviteResponse = z.object({
email: z.string(),
invite_url: z.string(),
member_id: z.string(),
result: z.string().optional().default('success'),
role: z.string(),
tenant_id: z.string(),
})
/**
* MemberListQuery
*
* Strict (extra='forbid').
*/
export const zMemberListQuery = z.object({
limit: z.int().gte(1).lte(200).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* MemberResponse
*/
export const zMemberResponse = z.object({
avatar: z.string().nullish(),
email: z.string(),
id: z.string(),
name: z.string(),
role: z.string(),
status: z.string(),
})
/**
* MemberListResponse
*/
export const zMemberListResponse = z.object({
data: z.array(zMemberResponse),
has_more: z.boolean(),
limit: z.int(),
page: z.int(),
total: z.int(),
})
/**
* MemberRoleUpdatePayload
*/
export const zMemberRoleUpdatePayload = z.object({
role: z.enum(['admin', 'normal']),
})
/**
* PermittedExternalAppsListQuery
*
@ -546,3 +613,59 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({
* Workspace detail
*/
export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse
export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({
workspace_id: z.string(),
})
export const zGetWorkspacesByWorkspaceIdMembersQuery = z.object({
limit: z.int().gte(1).lte(200).optional().default(20),
page: z.int().gte(1).optional().default(1),
})
/**
* Member list
*/
export const zGetWorkspacesByWorkspaceIdMembersResponse = zMemberListResponse
export const zPostWorkspacesByWorkspaceIdMembersBody = zMemberInvitePayload
export const zPostWorkspacesByWorkspaceIdMembersPath = z.object({
workspace_id: z.string(),
})
/**
* Member invited
*/
export const zPostWorkspacesByWorkspaceIdMembersResponse = zMemberInviteResponse
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath = z.object({
member_id: z.string(),
workspace_id: z.string(),
})
/**
* Member removed
*/
export const zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse = zMemberActionResponse
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleBody = zMemberRoleUpdatePayload
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRolePath = z.object({
member_id: z.string(),
workspace_id: z.string(),
})
/**
* Role updated
*/
export const zPutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponse = zMemberActionResponse
export const zPostWorkspacesByWorkspaceIdSwitchPath = z.object({
workspace_id: z.string(),
})
/**
* Workspace detail
*/
export const zPostWorkspacesByWorkspaceIdSwitchResponse = zWorkspaceDetailResponse