Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/app/api/endpoints/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ApplicationTypeListResponse,
SiteAppType,
SiteListResponse,
SiteMetadataUpdateRequest,
SiteNetworkUpdateRequest,
SiteResponse,
SiteUpdateRequest,
Expand Down Expand Up @@ -160,6 +161,52 @@ async def update_site(
_raise_sites_error(error)


@router.patch("/{siteid}", response_model=SiteResponse)
async def update_site_metadata(
siteid: str,
request: SiteMetadataUpdateRequest,
current_user: User = Depends(security.get_current_user),
) -> SiteResponse:
"""Update editable metadata for an owned site project."""
request_fields = request.model_fields_set
title = request.title if "title" in request_fields else request.name
title = title.strip() if title is not None else None
if ("title" in request_fields or "name" in request_fields) and not title:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "validation_error",
"message": "Site title is required",
},
)
custom_domain_prefix_set = "custom_domain_prefix" in request_fields
custom_domain_prefix = request.custom_domain_prefix
if isinstance(custom_domain_prefix, str):
custom_domain_prefix = custom_domain_prefix.strip() or None
if title is None and not custom_domain_prefix_set:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "validation_error",
"message": "No editable site fields were provided",
},
)
try:
return await sites_service.update_site_metadata(
siteid,
username=current_user.user_name,
title=title,
custom_domain_prefix=custom_domain_prefix,
custom_domain_prefix_set=custom_domain_prefix_set,
)
except (
SitesNotAvailableError,
SitesUpstreamUnavailableError,
SitesUpstreamResponseError,
) as error:
_raise_sites_error(error)


@router.delete("/{siteid}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_site(
siteid: str,
Expand Down
19 changes: 18 additions & 1 deletion backend/app/schemas/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,26 @@
]
SiteNetwork = Literal["inner", "outer"]
SiteAppType = Literal["web", "miniapp", "site", "mini_program"]
ApplicationCapability = Literal["create", "publish", "delete", "open_experience"]
ApplicationCapability = Literal[
"create",
"publish",
"edit",
"delete",
"open_experience",
]


class SiteResponse(BaseModel):
"""A generated site registered with the Sites service."""

app_type: Literal["web"] = "web"
siteid: str
project_id: str
taskid: str
username: str
name: str
slug: str
custom_domain_prefix: str | None = None
network: SiteNetwork
internal_url: AnyHttpUrl
external_url: AnyHttpUrl | None = None
Expand All @@ -46,6 +54,7 @@ class MiniProgramResponse(BaseModel):

app_type: Literal["miniapp"] = "miniapp"
siteid: str
project_id: str
taskid: str
username: str
name: str
Expand Down Expand Up @@ -109,3 +118,11 @@ class SiteUpdateRequest(BaseModel):

sitename: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)


class SiteMetadataUpdateRequest(BaseModel):
"""Request to update editable site project metadata."""

title: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
custom_domain_prefix: str | None = Field(default=None, min_length=4, max_length=63)
6 changes: 5 additions & 1 deletion backend/app/services/site_application_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ class SiteApplicationHandler(ApplicationTypeHandler):
capabilities: tuple[ApplicationCapability, ...] = (
"create",
"publish",
"edit",
"delete",
)
create_plugin_name = BUILTIN_SITES_PLUGIN_NAME
Expand All @@ -128,10 +129,12 @@ def parse(self, payload: Any, *, username: str) -> SiteResponse:
site = {
"app_type": self.app_type,
"siteid": project_id,
"project_id": project_id,
"taskid": project_id,
"username": username,
"name": payload.get("title"),
"slug": project_id,
"slug": payload.get("slug") or project_id,
"custom_domain_prefix": payload.get("custom_domain_prefix"),
"network": network,
"internal_url": url,
"external_url": url if network == "outer" else None,
Expand Down Expand Up @@ -173,6 +176,7 @@ def parse(self, payload: Any, *, username: str) -> MiniProgramResponse:
mini_program = {
"app_type": self.app_type,
"siteid": project_id,
"project_id": project_id,
"taskid": project_id,
"username": username,
"name": payload.get("title"),
Expand Down
39 changes: 36 additions & 3 deletions backend/app/services/sites.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""Gateway for the external Sites project API."""

from typing import Any
from uuid import uuid4

import httpx

Expand Down Expand Up @@ -61,18 +62,23 @@ async def _request(
*,
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
) -> Any:
headers = {}
request_headers = {}
if settings.SITES_API_TOKEN.strip():
headers["Authorization"] = f"Bearer {settings.SITES_API_TOKEN.strip()}"
request_headers["Authorization"] = (
f"Bearer {settings.SITES_API_TOKEN.strip()}"
)
if headers:
request_headers.update(headers)
try:
async with httpx.AsyncClient(timeout=self._timeout_seconds) as client:
response = await client.request(
method,
f"{self._base_url()}{path}",
params=params,
json=json,
headers=headers or None,
headers=request_headers or None,
)
except SitesNotAvailableError:
raise
Expand Down Expand Up @@ -103,6 +109,8 @@ def _response_detail(response: httpx.Response) -> Any:

if isinstance(payload, dict) and "detail" in payload:
return payload["detail"]
if isinstance(payload, dict) and isinstance(payload.get("error"), dict):
return payload["error"]
return payload

async def list_sites(
Expand Down Expand Up @@ -168,6 +176,31 @@ async def update_site_name(
)
return self._parse_site_project(payload, username=username)

async def update_site_metadata(
self,
siteid: str,
*,
username: str,
title: str | None = None,
custom_domain_prefix: str | None = None,
custom_domain_prefix_set: bool = False,
) -> SiteResponse:
json_payload: dict[str, Any] = {}
if title is not None:
json_payload["title"] = title
if custom_domain_prefix_set:
json_payload["custom_domain_prefix"] = custom_domain_prefix
payload = await self._request(
"PATCH",
f"/api/v1/projects/{siteid}",
json=json_payload,
headers={
"Idempotency-Key": f"project-update-{uuid4().hex}",
"X-Wegent-Username": username,
},
)
return self._parse_site_project(payload, username=username)

async def _list_platform_sites(
self,
*,
Expand Down
39 changes: 38 additions & 1 deletion backend/tests/api/test_sites_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def test_list_site_app_types_returns_ordered_capabilities(
"app_type": "web",
"enabled": True,
"order": 10,
"capabilities": ["create", "publish", "delete"],
"capabilities": ["create", "publish", "edit", "delete"],
"create": {
"plugin_name": "wegent-sites",
"marketplace_name": "wegent",
Expand Down Expand Up @@ -142,10 +142,12 @@ def test_list_sites_searches_platform_projects_with_authenticated_username(
assert body["items"][0] == {
"app_type": "web",
"siteid": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY",
"project_id": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY",
"taskid": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY",
"username": "testuser",
"name": "Product site",
"slug": "prj_01K0A0BCDEFGHJKMNPQRSTVWXY",
"custom_domain_prefix": None,
"network": "inner",
"internal_url": "https://product.inner.test/",
"external_url": None,
Expand Down Expand Up @@ -219,6 +221,7 @@ def test_list_sites_returns_mini_programs_from_the_shared_endpoint(
{
"app_type": "miniapp",
"siteid": "mini_01K0A0BCDEFGHJKMNPQRSTVWXY",
"project_id": "mini_01K0A0BCDEFGHJKMNPQRSTVWXY",
"taskid": "mini_01K0A0BCDEFGHJKMNPQRSTVWXY",
"username": "testuser",
"name": "Campaign assistant",
Expand Down Expand Up @@ -479,6 +482,40 @@ def test_update_site_name_proxies_platform_name_update(
}


def test_update_site_metadata_proxies_platform_patch(
test_client: TestClient,
test_token: str,
monkeypatch: pytest.MonkeyPatch,
httpx_mock: HTTPXMock,
) -> None:
monkeypatch.setattr(settings, "SITES_API_BASE_URL", SITES_API_BASE_URL)
monkeypatch.setattr(settings, "SITES_API_TOKEN", "platform-token")
httpx_mock.add_response(
method="PATCH",
url=f"{SITES_API_BASE_URL}/api/v1/projects/prj_01K0A0BCDEFGHJKMNPQRSTVWXY",
json=_project(title="Docs Site", custom_domain_prefix="docs"),
)

response = test_client.patch(
"/api/sites/prj_01K0A0BCDEFGHJKMNPQRSTVWXY",
json={"title": " Docs Site ", "custom_domain_prefix": "docs"},
headers=_authorization(test_token),
)

assert response.status_code == 200
body = response.json()
assert body["name"] == "Docs Site"
assert body["custom_domain_prefix"] == "docs"
request = httpx_mock.get_requests()[0]
assert request.headers["authorization"] == "Bearer platform-token"
assert request.headers["x-wegent-username"] == "testuser"
assert request.headers["idempotency-key"].startswith("project-update-")
assert _json_body(request) == {
"title": "Docs Site",
"custom_domain_prefix": "docs",
}


def test_delete_site_removes_owned_platform_project(
test_client: TestClient,
test_token: str,
Expand Down
2 changes: 1 addition & 1 deletion docs/en/wework/developer-guide/wework-codex-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Plugin installation is user-scoped, while CLI credentials are device-scoped. Ins

Built-in application plugin identity is defined by the Backend built-in plugin registry. The current registry contains only `wegent-sites` and `weibo-miniapp-h5-develop-agent`; both use `visibility=workspace`, so their canonical marketplace name is `wegent`. `public` remains a valid visibility for ordinary plugins. Only when the built-in installation path finds one of these two system-owned `user_id=0` marketplace rows still stored as `visibility=public` does Backend treat it as a legacy row and normalize it to `workspace` before installing. This prevents the same built-in plugin from appearing as `plugin://...@wework` in old data and `plugin://...@wegent` in the current application create flow.

The Applications page reads its lists through `GET /api/sites`. Sites and Mini Programs share this endpoint and pass `app_type=web` and `app_type=miniapp`, respectively. Omitting the parameter defaults to Sites for backward compatibility. The response `app_type` discriminates the fields for each application type. The page also calls `GET /api/sites/app-types` to discover the types enabled by the current Backend, their display order, and capabilities such as `create`, `publish`, `delete`, and `open_experience`. Wework only shows types that are both enabled by the server and represented by a local Definition, and hides operations that are not supported by the advertised capabilities.
The Applications page reads its lists through `GET /api/sites`. Sites and Mini Programs share this endpoint and pass `app_type=web` and `app_type=miniapp`, respectively. Omitting the parameter defaults to Sites for backward compatibility. The response `app_type` discriminates the fields for each application type. The page also calls `GET /api/sites/app-types` to discover the types enabled by the current Backend, their display order, and capabilities such as `create`, `publish`, `edit`, `delete`, and `open_experience`. Wework only shows types that are both enabled by the server and represented by a local Definition, and hides operations that are not supported by the advertised capabilities.

When Wework is connected to Wegent cloud, it calls `POST /api/users/me/wegent-runtime-token` to issue the token that local application Skills use for Backend runtime APIs, then writes it to the local Codex shell environment as `WEGENT_RUNTIME_AUTH_TOKEN`. Wework refreshes this token before the returned `expires_in`. `AUTH_TOKEN` remains the existing per-task bearer token, and `WEGENT_AUTH_TOKEN` remains reserved for executor device connections; these credentials must not be used interchangeably.

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/wework/developer-guide/wework-codex-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Codex 插件运行配置位于“设置 → 集成 → 插件”,当前提供

内置应用插件的身份以 Backend 内置插件注册表为准。当前注册表只包含 `wegent-sites` 和 `weibo-miniapp-h5-develop-agent`,二者都使用 `visibility=workspace`,因此规范市场名是 `wegent`。`public` 仍是普通插件的合法可见性;只有在内置插件安装路径中,系统所有者 `user_id=0` 下的这两个内置插件市场行仍保存为 `visibility=public` 时,才会被视为历史遗留行并在安装前规范化为 `workspace`。这样可以避免同一个内置插件在旧数据中以 `plugin://...@wework`、在当前应用创建流程中以 `plugin://...@wegent` 出现两套身份。

应用页通过 `GET /api/sites` 读取列表。站点和小程序共用该接口,并分别传入 `app_type=web` 和 `app_type=miniapp`;省略参数时默认返回站点,兼容已有调用。响应中的 `app_type` 是区分两类应用字段的判别值。页面还会调用 `GET /api/sites/app-types` 获取当前 Backend 启用的类型、展示顺序和 `create`、`publish`、`delete`、`open_experience` 等能力;Wework 只显示本地已有 Definition 且服务端已启用的类型,并按能力隐藏不支持的操作。
应用页通过 `GET /api/sites` 读取列表。站点和小程序共用该接口,并分别传入 `app_type=web` 和 `app_type=miniapp`;省略参数时默认返回站点,兼容已有调用。响应中的 `app_type` 是区分两类应用字段的判别值。页面还会调用 `GET /api/sites/app-types` 获取当前 Backend 启用的类型、展示顺序和 `create`、`publish`、`edit`、`delete`、`open_experience` 等能力;Wework 只显示本地已有 Definition 且服务端已启用的类型,并按能力隐藏不支持的操作。

连接 Wegent 云端时,Wework 会调用 `POST /api/users/me/wegent-runtime-token` 获取本地应用 Skill 访问 Backend runtime API 的 token,并把它作为 `WEGENT_RUNTIME_AUTH_TOKEN` 写入本机 Codex shell 环境配置;该 token 会按响应中的 `expires_in` 提前刷新。`AUTH_TOKEN` 仍表示单次任务的原有 bearer token,`WEGENT_AUTH_TOKEN` 仍保留给 executor 设备连接使用,三者不能混用。

Expand Down
2 changes: 1 addition & 1 deletion wework/e2e/desktop/modules/desktop-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,7 @@ class DesktopE2EServer {
app_type: 'web',
enabled: true,
order: 10,
capabilities: ['create', 'publish', 'delete'],
capabilities: ['create', 'publish', 'edit', 'delete'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Keep the desktop Sites fixture aligned with the API response contract.

backend/app/schemas/site.py now requires project_id for web and mini-program responses. The /api/sites fixture omits this field from both payloads. The web payload also omits custom_domain_prefix. Add these fields so desktop tests use the same contract as the Backend.

Proposed fixture update
 {
   app_type: 'miniapp',
   siteid: 'prj_e2e_mini',
+  project_id: 'prj_e2e_mini',
   taskid: 'prj_e2e_mini',
 }
 
 {
   app_type: 'web',
   siteid: 'prj_e2e_product',
+  project_id: 'prj_e2e_product',
   taskid: 'prj_e2e_product',
+  custom_domain_prefix: null,
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
capabilities: ['create', 'publish', 'edit', 'delete'],
{
app_type: 'miniapp',
siteid: 'prj_e2e_mini',
project_id: 'prj_e2e_mini',
taskid: 'prj_e2e_mini',
}
{
app_type: 'web',
siteid: 'prj_e2e_product',
project_id: 'prj_e2e_product',
taskid: 'prj_e2e_product',
custom_domain_prefix: null,
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@wework/e2e/desktop/modules/desktop-server.mjs` at line 901, Update the
desktop Sites fixture payloads used by the /api/sites mock to include the
required project_id field in both web and mini-program responses, and add
custom_domain_prefix to the web payload. Keep the existing capabilities and
other fixture values unchanged while matching the backend site response
contract.

create: {
plugin_name: 'wegent-sites',
marketplace_name: 'wegent',
Expand Down
46 changes: 44 additions & 2 deletions wework/e2e/utils/mock-sites-upstream-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ seedProjects()
function corsHeaders(extra = {}) {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Authorization',
'Access-Control-Allow-Methods': 'GET,POST,PATCH,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type,Authorization,Idempotency-Key,X-Wegent-Username',
...extra,
}
}
Expand Down Expand Up @@ -168,6 +168,43 @@ function updateProjectName(req, res, body) {
writeJson(res, 200, project)
}

function updateProjectMetadata(req, res, body) {
if (!requireAuth(req, res)) {
return
}

const projectId = new URL(req.url || '/', `http://127.0.0.1:${PORT}`).pathname.split('/').pop()
if (!req.headers['idempotency-key'] || !req.headers['x-wegent-username']) {
writeJson(res, 400, {
error: {
code: 'VALIDATION_ERROR',
message: 'Missing project metadata headers',
},
})
return
}
if (!projectId || !projects.has(projectId)) {
writeJson(res, 404, {
error: {
code: 'NOT_FOUND',
message: 'Project not found',
},
})
return
}

const payload = parseJsonBody(body)
const project = projects.get(projectId)
if (typeof payload?.title === 'string' && payload.title.trim()) {
project.title = payload.title.trim()
}
if (Object.hasOwn(payload || {}, 'custom_domain_prefix')) {
project.custom_domain_prefix = payload.custom_domain_prefix || null
}

writeJson(res, 200, project)
}

function deleteProject(req, res, body) {
if (!requireAuth(req, res)) {
return
Expand Down Expand Up @@ -238,6 +275,11 @@ const server = http.createServer((req, res) => {
return
}

if (req.url?.startsWith('/api/v1/projects/') && req.method === 'PATCH') {
updateProjectMetadata(req, res, body)
return
}

if (req.url === '/api/v1/projects/del' && req.method === 'POST') {
deleteProject(req, res, body)
return
Expand Down
Loading
Loading