From 8dd99c1855b4e29b9af8088011785d6524404fb2 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 10 Aug 2026 18:27:53 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=BC=BA=E9=9D=99=E6=80=81=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260810-static-release-only-a91c.md | 12 +- electron/api/routes/device-preview.ts | 52 +----- electron/api/routes/works.ts | 86 ++++++++- electron/api/works-play-url.ts | 62 +++++++ electron/services/project-progress-sync.ts | 2 +- electron/services/works-submission-binding.ts | 86 +++++++-- src/components/works/ProjectPublishAction.tsx | 13 ++ src/lib/works-square.ts | 20 +- tests/e2e/project-superpowers-toggle.spec.ts | 6 +- tests/unit/device-preview-routes.test.ts | 4 +- tests/unit/project-publish-action.test.tsx | 27 +++ tests/unit/works-play-url.test.ts | 46 +++++ tests/unit/works-routes.test.ts | 171 +++++++++++++++++- tests/unit/works-square.test.ts | 10 +- tests/unit/works-submission-binding.test.ts | 48 +++++ 15 files changed, 560 insertions(+), 85 deletions(-) create mode 100644 electron/api/works-play-url.ts create mode 100644 tests/unit/works-play-url.test.ts diff --git a/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md b/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md index e5b950a..24fb71f 100644 --- a/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md +++ b/.project-docs/30-worklog/tasks/20260810-static-release-only-a91c.md @@ -46,6 +46,11 @@ - 内置 skill 清单把 `deploy-publish-check` 标记为 retired,客户端启动时会清理已安装副本;课程、开发和营销提示统一改为“用户一键提交、平台安全打包与云端验收”,不再要求 Dockerfile、Compose、ZIP 或服务器信息。 - 项目进度同步改用已提交版本绑定识别服务端项目,不再依赖 AI 生成的 `works-publish.json`。README 已同步静态 Release、`play_url` 和兼容边界。 - 保留一般 OpenCode/Monaco 的 Dockerfile 语法支持、AI 设计能力、会话运行时清理和打包器对历史控制文件的安全排除;这些能力不属于旧 Compose 发布协调链。 +- 补强 Main 边界:`/api/works/projects/publish-source` 必须携带 Renderer capability,缺失或错误时在读取发布凭据、查询项目和打包前直接返回 403,OpenCode 子进程不能只凭 Host token/base 绕过 UI 发起发布。 +- 抽取共享播放 URL 校验器供 ProjectPublic 投影和设备预览复用:只接受与 Works Square base 同源的 HTTPS、禁止 userinfo/loopback、限制长度,公共作品必须精确落在 `/apps/{encodeURIComponent(app_id)}/` 且无 query/fragment;只有 `playable === true`、版本名非空且首选 `play_url`(字段缺失时才兼容 `runtime_url`)可信时才对 Renderer 标记可播放。 +- 上传云端成功但本机版本绑定落盘失败时,Main 返回固定、无磁盘路径的结构化 `binding_warning`;Renderer 显示“已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交”,同时继续轮询云构建,避免把本地辅助状态失败误报为上传失败。 +- schema v2 读取改为逐字段验证,约束 submitted/legacy_retired 的状态专属字段,并在按项目读取时校验绑定内 `project_id` 与当前项目一致,拒绝复制或串项目的绑定文件。 +- Electron E2E 增加用户可见入口断言:新建小游戏后只展示一个“一键提交审核”按钮,旧“自动部署/部署发布检查”入口不可见。 ## Verification @@ -58,6 +63,11 @@ - `pnpm run build:vite`:Renderer、Electron Main、Preload 均构建成功;仅有仓库既有 dynamic-import/chunk-size 提示。 - `pnpm exec playwright test --list --reporter=line`:成功收集 13 个 Electron E2E 文件、21 条用例,当前仓库不存在“缺少 Electron E2E fixture”问题。 - `git diff --check`:通过;仅有 Git 的 LF/CRLF 工作区提示。 +- 安全补强聚焦回归:`pnpm exec vitest run tests/unit/works-play-url.test.ts tests/unit/works-submission-binding.test.ts tests/unit/device-preview-routes.test.ts tests/unit/project-progress-sync.test.ts tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/project-publish-action.test.tsx tests/unit/works-project-publish.test.ts tests/unit/project-packager.test.ts tests/unit/opencode-routes.test.ts`:10 files / 227 tests passed;覆盖 capability 先行拒绝、公共 URL fail-closed 投影、绑定告警继续轮询、schema v2 状态字段与 project_id 一致性。 +- 对本次补强涉及的全部 TS/TSX 文件执行 scoped ESLint:0 errors / 0 warnings。 +- 安全补强后再次执行 `pnpm run typecheck` 与 `pnpm run build:vite`:均通过;构建仅有既有 dynamic-import/chunk-size 提示。 +- `pnpm exec playwright test tests/e2e/project-superpowers-toggle.spec.ts --reporter=line`:1 passed;实际 Electron 启动、创建小游戏、一键提交入口和旧自动部署入口消失均通过。 +- 安全补强后再次执行 `pnpm exec playwright test --list --reporter=line`:成功收集 13 个 Electron E2E 文件、21 条用例。 ## Follow-ups @@ -69,7 +79,7 @@ - Target: `.project-docs/20-architecture/system-overview.md`、`.project-docs/40-domain/business-rules.md` 与 README 当前产品状态。 - Proposal: 客户端发布唯一链路是 Main 安全生成静态包并提交服务端 Builder;客户端只持久化成功提交的精确版本绑定,不再拥有 Compose runner、自动 watcher/arm/upload 或手工 ZIP 入口。 - - Evidence: 本任务删除 3,000 余行旧协调代码与测试,新增 schema v2 迁移及 216 项聚焦回归,typecheck、Vite 构建和 Electron E2E 收集通过。 + - Evidence: 本任务删除 3,000 余行旧协调代码与测试,新增 schema v2 迁移、安全播放 URL 投影和 Renderer capability 门禁;补强后的 227 项聚焦回归、typecheck、Vite 构建、实际 Electron E2E 与 E2E 收集均通过。 - Future impact: 后续发布功能必须建立在 immutable static Release、`play_url` 和服务端审核发布状态之上;不得重新要求非专业用户准备 Docker/Compose/ZIP/服务器凭证。 - Semantic conflicts: 落盘文件名 `works-cloud-deploy.json` 仅为已安装数据兼容,不代表仍存在 cloud deployment coordinator;`runtime_url` 仅为一个客户端版本的临时兼容字段。 - Human confirmation required: 是;用户已明确要求一步到位移除旧 runtime-cleaner/runner 方向并统一到 Builder -> OSS -> CDN -> App,canonical promotion 仍由 Integration Gate 执行。 diff --git a/electron/api/routes/device-preview.ts b/electron/api/routes/device-preview.ts index 561fc10..e14b86f 100644 --- a/electron/api/routes/device-preview.ts +++ b/electron/api/routes/device-preview.ts @@ -3,6 +3,10 @@ import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { hasRendererCapability } from '../renderer-capability'; import { WORKS_SQUARE_CONFIG } from '../works-config'; +import { + trustedWorksProjectPlayUrl, + trustedWorksReleasePreviewUrl, +} from '../works-play-url'; import { getValidWorksSquareAccessToken } from '../../services/works-square-session'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import type { WorksSubmissionBindingRecord } from '../../../shared/works-submission-binding'; @@ -80,46 +84,6 @@ function normalizedWorksBaseUrl(): URL { return new URL(`${base}/`); } -function isLoopbackHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); - return normalized === 'localhost' - || normalized === '::1' - || normalized === '0.0.0.0' - || normalized.startsWith('127.'); -} - -function trustedLaunchUrl(value: string, worksBase: URL): string | null { - try { - const target = new URL(value, worksBase); - const absoluteUrl = target.toString(); - if ( - absoluteUrl.length > 1_024 - || worksBase.protocol !== 'https:' - || target.protocol !== 'https:' - || target.origin !== worksBase.origin - || Boolean(target.username || target.password) - || isLoopbackHostname(target.hostname) - ) { - return null; - } - return absoluteUrl; - } catch { - return null; - } -} - -function trustedReleasePreviewUrl( - value: string, - worksBase: URL, - releaseId: string, -): string | null { - const trusted = trustedLaunchUrl(value, worksBase); - if (!trusted) return null; - const target = new URL(trusted); - const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`; - return target.pathname.startsWith(expectedPrefix) ? trusted : null; -} - async function requireManagedAccessToken(): Promise { const accessToken = await getValidWorksSquareAccessToken(); if (!accessToken) { @@ -294,7 +258,11 @@ async function resolveDevicePreview( const targetVersionMatches = remote.latestVersionId === deployment.version_id && remote.latestVersionName === deployment.version_name; const launchUrl = remote.runtimeUrl - ? trustedLaunchUrl(remote.runtimeUrl, normalizedWorksBaseUrl()) + ? trustedWorksProjectPlayUrl( + remote.runtimeUrl, + normalizedWorksBaseUrl(), + deployment.app_id, + ) : null; if (!targetVersionMatches) { @@ -334,7 +302,7 @@ async function resolveDevicePreview( remote.latestReleaseId, accessToken, ); - const previewUrl = trustedReleasePreviewUrl( + const previewUrl = trustedWorksReleasePreviewUrl( rawPreviewUrl, normalizedWorksBaseUrl(), remote.latestReleaseId, diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index 9f1d4d5..93bdd4f 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -5,6 +5,8 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; +import { hasRendererCapability } from '../renderer-capability'; +import { trustedWorksProjectPlayUrl } from '../works-play-url'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import { WORKS_SQUARE_CONFIG } from '../works-config'; import { @@ -201,7 +203,6 @@ function projectSafeProject(value: unknown): Record | null { 'buddy_name', 'buddy_sprite_url', 'buddy_pose_url', - 'version_name', 'testing_ask', 'update_note', 'remix_note', @@ -212,10 +213,45 @@ function projectSafeProject(value: unknown): Record | null { const fieldValue = value[field]; if (fieldValue === null || typeof fieldValue === 'string') projected[field] = fieldValue; } - if (typeof value.playable === 'boolean') projected.playable = value.playable; + + const versionName = readOptionalString(value.version_name); + if (value.version_name !== undefined) projected.version_name = versionName ?? null; + projected.playable = false; + projected.play_url = null; + projected.runtime_url = null; + + if (value.playable === true && versionName) { + const playUrlPresent = value.play_url !== undefined && value.play_url !== null; + const primaryPlayUrl = readOptionalString(value.play_url); + const fallbackRuntimeUrl = readOptionalString(value.runtime_url); + const candidate = playUrlPresent ? primaryPlayUrl : fallbackRuntimeUrl; + const trusted = candidate + ? trustedWorksProjectPlayUrl( + candidate, + new URL(`${normalizeWorksBase()}/`), + appId, + ) + : null; + if (trusted) { + projected.playable = true; + if (playUrlPresent) projected.play_url = trusted; + else projected.runtime_url = trusted; + } + } return projected; } +function projectSafeProjectPage(value: unknown): Record | null { + if (!isRecord(value) || !Array.isArray(value.items)) return null; + const items = value.items.map(projectSafeProject); + if (items.some((item) => item === null)) return null; + const nextCursor = readNullableStringField(value, 'next_cursor'); + if (nextCursor === undefined || typeof value.limit !== 'number' || !Number.isFinite(value.limit)) { + return null; + } + return { items, next_cursor: nextCursor, limit: value.limit }; +} + function projectSafeVersion(value: unknown): Record | null { if (!isRecord(value)) return null; const id = readOptionalString(value.id); @@ -296,7 +332,12 @@ async function handleListProjects(res: ServerResponse, url: URL): Promise return; } - sendJson(res, 200, { success: true, page: await readResponsePayload(response) }); + const page = projectSafeProjectPage(await readResponsePayload(response)); + if (!page) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' }); + return; + } + sendJson(res, 200, { success: true, page }); } async function handleGetProject(res: ServerResponse, appId: string): Promise { @@ -307,7 +348,12 @@ async function handleGetProject(res: ServerResponse, appId: string): Promise { @@ -433,7 +479,12 @@ async function handleCreateProject(req: IncomingMessage, res: ServerResponse): P return; } - sendJson(res, response.status, { success: true, project: await readResponsePayload(response) }); + const project = projectSafeProject(await readResponsePayload(response)); + if (!project) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project' }); + return; + } + sendJson(res, response.status, { success: true, project }); } async function handleListMyProjects( @@ -457,7 +508,12 @@ async function handleListMyProjects( return; } - sendJson(res, response.status, { success: true, page: await readResponsePayload(response) }); + const page = projectSafeProjectPage(await readResponsePayload(response)); + if (!page) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid project list' }); + return; + } + sendJson(res, response.status, { success: true, page }); } async function handleGetBillingTokenUsage( @@ -619,6 +675,10 @@ async function handleGetMyProjectStatus( const SOURCE_PUBLISH_CHANGE_LOG = '通过 Makelore 一键提交'; const RETRYABLE_SOURCE_UPLOAD_STATUSES = new Set([408, 502, 503, 504]); const VERSION_FILE_MAX_BYTES = 64 * 1024; +const LOCAL_PREVIEW_BINDING_WARNING = { + code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED', + message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', +} as const; function createFallbackVersionName(now = new Date()): string { return `v${now.toISOString().replace(/\D/g, '').slice(0, 14)}`; @@ -841,6 +901,7 @@ async function handlePublishProjectSource( ); return; } + let bindingWarning: typeof LOCAL_PREVIEW_BINDING_WARNING | undefined; if (ctx.worksSubmissionBinding) { try { await ctx.worksSubmissionBinding.recordSubmitted(projectId, { @@ -852,13 +913,17 @@ async function handlePublishProjectSource( }); } catch { logger.warn('[works] One-click submission succeeded, but local preview mapping could not be saved'); + bindingWarning = LOCAL_PREVIEW_BINDING_WARNING; } + } else { + bindingWarning = LOCAL_PREVIEW_BINDING_WARNING; } const { archivePath: _archivePath, ...rendererPackageSummary } = packageSummary; sendJson(res, uploadResponse.status, { success: true, package: rendererPackageSummary, upload: uploadPayload, + ...(bindingWarning ? { binding_warning: bindingWarning } : {}), }); } finally { await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined); @@ -1005,6 +1070,15 @@ export async function handleWorksRoutes( } if (url.pathname === '/api/works/projects/publish-source' && req.method === 'POST') { + if (!hasRendererCapability(req)) { + sendJson(res, 403, { + success: false, + status: 403, + code: 'RENDERER_CAPABILITY_REQUIRED', + error: 'Renderer capability required', + }); + return true; + } await handlePublishProjectSource(req, res, ctx); return true; } diff --git a/electron/api/works-play-url.ts b/electron/api/works-play-url.ts new file mode 100644 index 0000000..50bc1c3 --- /dev/null +++ b/electron/api/works-play-url.ts @@ -0,0 +1,62 @@ +const MAX_WORKS_PLAY_URL_LENGTH = 1_024; + +function isLoopbackHostname(hostname: string): boolean { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); + return normalized === 'localhost' + || normalized.endsWith('.localhost') + || normalized === '::' + || normalized === '::1' + || normalized.startsWith('::ffff:127.') + || normalized.startsWith('::ffff:7f00:') + || normalized === '0.0.0.0' + || normalized.startsWith('127.'); +} + +function trustedWorksHttpsUrl(value: string, worksBase: URL): URL | null { + try { + const target = new URL(value, worksBase); + if ( + target.toString().length > MAX_WORKS_PLAY_URL_LENGTH + || worksBase.protocol !== 'https:' + || target.protocol !== 'https:' + || target.origin !== worksBase.origin + || Boolean(target.username || target.password) + || isLoopbackHostname(target.hostname) + ) { + return null; + } + return target; + } catch { + return null; + } +} + +export function trustedWorksProjectPlayUrl( + value: string, + worksBase: URL, + appId: string, +): string | null { + const normalizedAppId = appId.trim(); + if (!normalizedAppId) return null; + const target = trustedWorksHttpsUrl(value, worksBase); + if ( + !target + || target.pathname !== `/apps/${encodeURIComponent(normalizedAppId)}/` + || target.search + || target.hash + ) { + return null; + } + return target.toString(); +} + +export function trustedWorksReleasePreviewUrl( + value: string, + worksBase: URL, + releaseId: string, +): string | null { + const target = trustedWorksHttpsUrl(value, worksBase); + if (!target) return null; + const expectedPrefix = `/previews/${encodeURIComponent(releaseId)}/`; + return target.pathname.startsWith(expectedPrefix) ? target.toString() : null; +} diff --git a/electron/services/project-progress-sync.ts b/electron/services/project-progress-sync.ts index 44706a9..6df885e 100644 --- a/electron/services/project-progress-sync.ts +++ b/electron/services/project-progress-sync.ts @@ -139,7 +139,7 @@ async function readProductOverview(projectPath: string): Promise { async function resolveProjectProgressIdentity( project: OpencodeProject, ): Promise { - const binding = await readWorksSubmissionBinding(project.path); + const binding = await readWorksSubmissionBinding(project.path, project.id); if (binding?.status === 'submitted' && binding.app_id) { return { projectType: 'server', projectKey: binding.app_id }; } diff --git a/electron/services/works-submission-binding.ts b/electron/services/works-submission-binding.ts index 8738601..28fdb72 100644 --- a/electron/services/works-submission-binding.ts +++ b/electron/services/works-submission-binding.ts @@ -47,15 +47,58 @@ function readString(value: unknown): string | null { function parseCurrentRecord(value: unknown): WorksSubmissionBindingRecord | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const record = value as Record; - if ( - record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION - || (record.status !== 'submitted' && record.status !== 'legacy_retired') - ) return null; + if (record.schema_version !== WORKS_SUBMISSION_BINDING_SCHEMA_VERSION) return null; const projectId = readString(record.project_id); const requestedAt = readString(record.requested_at); const updatedAt = readString(record.updated_at); if (!projectId || !requestedAt || !updatedAt) return null; - return record as WorksSubmissionBindingRecord; + + if (record.status === 'submitted') { + const appId = readString(record.app_id); + const versionId = readString(record.version_id); + const versionName = readString(record.version_name); + const reviewStatus = record.review_status === undefined + ? null + : readString(record.review_status); + const zipSha256 = record.zip_sha256 === undefined + ? null + : readString(record.zip_sha256); + if ( + !appId + || !versionId + || !versionName + || (record.review_status !== undefined && !reviewStatus) + || (record.zip_sha256 !== undefined && (!zipSha256 || !/^[a-f0-9]{64}$/i.test(zipSha256))) + ) return null; + return { + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: projectId, + status: 'submitted', + requested_at: requestedAt, + updated_at: updatedAt, + app_id: appId, + version_id: versionId, + version_name: versionName, + ...(reviewStatus ? { review_status: reviewStatus } : {}), + ...(zipSha256 ? { zip_sha256: zipSha256.toLowerCase() } : {}), + }; + } + + const message = readString(record.message); + if ( + record.status !== 'legacy_retired' + || record.error_code !== 'LEGACY_AUTO_DEPLOY_RETIRED' + || !message + ) return null; + return { + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: projectId, + status: 'legacy_retired', + requested_at: requestedAt, + updated_at: updatedAt, + error_code: 'LEGACY_AUTO_DEPLOY_RETIRED', + message, + }; } function parseLegacyRecord(value: unknown): LegacyBindingRecord | null { @@ -73,23 +116,25 @@ function parseLegacyRecord(value: unknown): LegacyBindingRecord | null { function migrateLegacyRecord(record: LegacyBindingRecord): WorksSubmissionBindingRecord { const now = new Date().toISOString(); - if ( - record.status === 'submitted' - && readString(record.app_id) - && readString(record.version_id) - && readString(record.version_name) - ) { + const appId = readString(record.app_id); + const versionId = readString(record.version_id); + const versionName = readString(record.version_name); + if (record.status === 'submitted' && appId && versionId && versionName) { + const reviewStatus = readString(record.review_status); + const zipSha256 = readString(record.zip_sha256); return { schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, project_id: record.project_id, status: 'submitted', requested_at: record.requested_at, updated_at: now, - app_id: record.app_id, - version_id: record.version_id, - version_name: record.version_name, - ...(readString(record.review_status) ? { review_status: record.review_status } : {}), - ...(readString(record.zip_sha256) ? { zip_sha256: record.zip_sha256 } : {}), + app_id: appId, + version_id: versionId, + version_name: versionName, + ...(reviewStatus ? { review_status: reviewStatus } : {}), + ...(zipSha256 && /^[a-f0-9]{64}$/i.test(zipSha256) + ? { zip_sha256: zipSha256.toLowerCase() } + : {}), }; } @@ -120,6 +165,7 @@ async function persistRecord( export async function readWorksSubmissionBinding( projectPath: string, + expectedProjectId?: string, ): Promise { let parsed: unknown; try { @@ -132,9 +178,9 @@ export async function readWorksSubmissionBinding( } const current = parseCurrentRecord(parsed); - if (current) return current; + if (current) return !expectedProjectId || current.project_id === expectedProjectId ? current : null; const legacy = parseLegacyRecord(parsed); - if (!legacy) return null; + if (!legacy || (expectedProjectId && legacy.project_id !== expectedProjectId)) return null; return persistRecord(projectPath, migrateLegacyRecord(legacy)); } @@ -162,13 +208,13 @@ export function createWorksSubmissionBindingStore(projectStore: ProjectStore) { async function get(projectId: string): Promise { const project = (await projectStore.listProjects()).find((item) => item.id === projectId); - return project ? readWorksSubmissionBinding(project.path) : null; + return project ? readWorksSubmissionBinding(project.path, project.id) : null; } async function start(): Promise { for (const project of await projectStore.listProjects()) { try { - await readWorksSubmissionBinding(project.path); + await readWorksSubmissionBinding(project.path, project.id); } catch (error) { logger.warn(`[works-submission-binding] Failed to migrate ${project.id}`, error); } diff --git a/src/components/works/ProjectPublishAction.tsx b/src/components/works/ProjectPublishAction.tsx index 4b19d9c..0ed955d 100644 --- a/src/components/works/ProjectPublishAction.tsx +++ b/src/components/works/ProjectPublishAction.tsx @@ -68,6 +68,7 @@ function buttonLabel(phase: PublishPhase): string { export function ProjectPublishAction({ project, projectType }: ProjectPublishActionProps) { const [phase, setPhase] = useState('idle'); const [failure, setFailure] = useState(null); + const [bindingWarning, setBindingWarning] = useState(null); const pollGeneration = useRef(0); useEffect(() => () => { @@ -130,6 +131,7 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct pollGeneration.current += 1; const generation = pollGeneration.current; setFailure(null); + setBindingWarning(null); setPhase('submitting'); try { @@ -138,6 +140,7 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct project: metadata, }); if (pollGeneration.current !== generation) return; + setBindingWarning(result.bindingWarning?.message ?? null); setPhase('polling'); void pollBuildStatus(result.upload.version_id, generation); } catch (error) { @@ -185,6 +188,16 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct

) : null} + {bindingWarning ? ( +

+ {bindingWarning} +

+ ) : null} + {failure ? (
('/api/works/projects/publish-source', { method: 'POST', body: JSON.stringify({ @@ -430,7 +437,18 @@ export async function publishWorksProjectSource( response.code, ); } - return { package: response.package, upload: response.upload }; + const bindingWarning = isRecord(response.binding_warning) + && response.binding_warning.code === 'LOCAL_PREVIEW_BINDING_SAVE_FAILED' + ? { + code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED' as const, + message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', + } + : undefined; + return { + package: response.package, + upload: response.upload, + ...(bindingWarning ? { bindingWarning } : {}), + }; } export async function fetchWorksProjectVersions( diff --git a/tests/e2e/project-superpowers-toggle.spec.ts b/tests/e2e/project-superpowers-toggle.spec.ts index 8d337bd..096b983 100644 --- a/tests/e2e/project-superpowers-toggle.spec.ts +++ b/tests/e2e/project-superpowers-toggle.spec.ts @@ -4,7 +4,7 @@ import path from 'node:path'; import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron'; test.describe('Project-level Superpowers setting', () => { - test('starts new projects with Superpowers disabled', async ({ launchElectronApp }) => { + test('starts publishable projects with Superpowers disabled and only the one-click release entry', async ({ launchElectronApp }) => { const parentPath = await mkdtemp(path.join(tmpdir(), 'niancode-superpowers-e2e-')); const app = await launchElectronApp({ skipSetup: true }); @@ -36,7 +36,9 @@ test.describe('Project-level Superpowers setting', () => { await expect(page.getByText('已关闭')).toBeVisible(); await page.getByRole('button', { name: '关闭' }).click(); await expect(page.getByTestId('resource-card-publish')).toHaveCount(0); - await expect(page.getByRole('button', { name: '一键提交审核' })).toBeVisible(); + await expect(page.getByRole('button', { name: '一键提交审核' })).toHaveCount(1); + await expect(page.getByText('自动部署', { exact: false })).toHaveCount(0); + await expect(page.getByText('部署发布检查', { exact: false })).toHaveCount(0); } finally { await closeElectronApp(app); await rm(parentPath, { recursive: true, force: true }); diff --git a/tests/unit/device-preview-routes.test.ts b/tests/unit/device-preview-routes.test.ts index 00a7783..46604ff 100644 --- a/tests/unit/device-preview-routes.test.ts +++ b/tests/unit/device-preview-routes.test.ts @@ -221,7 +221,7 @@ describe('device preview Host API route', () => { it('keeps the legacy runtime alias as a one-release fallback', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response( JSON.stringify(matchingRemotePayload({ - project: { play_url: null, runtime_url: '/apps/runtime-fallback/' }, + project: { play_url: null, runtime_url: '/apps/planet-game/' }, })), { status: 200 }, ))); @@ -237,7 +237,7 @@ describe('device preview Host API route', () => { expect(response.json()).toMatchObject({ preview: { state: 'ready', - launchUrl: 'https://square.nianxx.cn/apps/runtime-fallback/', + launchUrl: 'https://square.nianxx.cn/apps/planet-game/', }, }); }); diff --git a/tests/unit/project-publish-action.test.tsx b/tests/unit/project-publish-action.test.tsx index bafda84..0e86133 100644 --- a/tests/unit/project-publish-action.test.tsx +++ b/tests/unit/project-publish-action.test.tsx @@ -141,6 +141,33 @@ describe('ProjectPublishAction', () => { expect(screen.getByRole('button', { name: '已提交,请稍后查看' })).toBeDisabled(); }); + it('shows a safe local binding warning while continuing cloud build polling', async () => { + publishWorksProjectSourceMock.mockResolvedValueOnce({ + package: {}, + upload, + bindingWarning: { + code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED', + message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', + }, + }); + fetchCurrentWorksProjectStatusMock.mockResolvedValue(projectStatus('succeeded')); + render(); + + fireEvent.click(screen.getByRole('button', { name: '一键提交审核' })); + await flushSubmission(); + + expect(screen.getByTestId('project-publish-binding-warning')).toHaveTextContent( + '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', + ); + expect(screen.getByRole('button', { name: '正在等待云端检查…' })).toBeDisabled(); + + await advancePoll(); + + expect(fetchCurrentWorksProjectStatusMock).toHaveBeenCalledOnce(); + expect(screen.getByRole('button', { name: '已提交,等待运营审核' })).toBeDisabled(); + expect(screen.getByTestId('project-publish-binding-warning')).toBeVisible(); + }); + it('shows a safe timeout instead of inviting a duplicate submission', async () => { const status = projectStatus('queued'); status.latest_version.id = 'another-version'; diff --git a/tests/unit/works-play-url.test.ts b/tests/unit/works-play-url.test.ts new file mode 100644 index 0000000..424e097 --- /dev/null +++ b/tests/unit/works-play-url.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { + trustedWorksProjectPlayUrl, + trustedWorksReleasePreviewUrl, +} from '@electron/api/works-play-url'; + +const worksBase = new URL('https://square.nianxx.cn/'); + +describe('Works Square playable URL security', () => { + it('normalizes only the exact same-origin HTTPS app path', () => { + expect(trustedWorksProjectPlayUrl('/apps/space-cleaner/', worksBase, 'space-cleaner')) + .toBe('https://square.nianxx.cn/apps/space-cleaner/'); + expect(trustedWorksProjectPlayUrl( + '/apps/space%20cleaner/', + worksBase, + 'space cleaner', + )).toBe('https://square.nianxx.cn/apps/space%20cleaner/'); + }); + + it.each([ + ['cross origin', 'https://evil.example/apps/space-cleaner/', worksBase], + ['HTTP', 'http://square.nianxx.cn/apps/space-cleaner/', worksBase], + ['userinfo', 'https://user:secret@square.nianxx.cn/apps/space-cleaner/', worksBase], + ['loopback base', '/apps/space-cleaner/', new URL('https://127.0.0.1:8443/')], + ['wrong app path', '/apps/another-app/', worksBase], + ['nested path', '/apps/space-cleaner/index.html', worksBase], + ['query string', '/apps/space-cleaner/?token=secret', worksBase], + ['fragment', '/apps/space-cleaner/#start', worksBase], + ['overlong URL', `/apps/space-cleaner/${'x'.repeat(1_100)}`, worksBase], + ])('rejects %s', (_case, value, base) => { + expect(trustedWorksProjectPlayUrl(value, base, 'space-cleaner')).toBeNull(); + }); + + it('keeps signed release previews on their exact release prefix', () => { + expect(trustedWorksReleasePreviewUrl( + '/previews/release-7/signed-ticket/?signature=ok', + worksBase, + 'release-7', + )).toBe('https://square.nianxx.cn/previews/release-7/signed-ticket/?signature=ok'); + expect(trustedWorksReleasePreviewUrl( + '/previews/release-8/signed-ticket/', + worksBase, + 'release-7', + )).toBeNull(); + }); +}); diff --git a/tests/unit/works-routes.test.ts b/tests/unit/works-routes.test.ts index de5232a..7c27ace 100644 --- a/tests/unit/works-routes.test.ts +++ b/tests/unit/works-routes.test.ts @@ -5,6 +5,11 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { handleWorksRoutes } from '@electron/api/routes/works'; +import { + getRendererCapability, + RENDERER_CAPABILITY_HEADER, + rotateRendererCapability, +} from '@electron/api/renderer-capability'; import { createProjectConfig } from '../../shared/project-config'; const getValidWorksSquareAccessTokenMock = vi.hoisted(() => vi.fn()); @@ -46,6 +51,12 @@ function createRequest(method: string, body?: unknown, headers: Record { await mkdir(join(projectPath, 'src'), { recursive: true }); await mkdir(join(projectPath, '.niancode'), { recursive: true }); @@ -77,6 +88,7 @@ describe('works square host api routes', () => { beforeEach(() => { vi.restoreAllMocks(); + rotateRendererCapability(); getValidWorksSquareAccessTokenMock.mockReset(); getValidWorksSquareAccessTokenMock.mockResolvedValue('main-owned-access-token'); }); @@ -101,6 +113,11 @@ describe('works square host api routes', () => { age_band: '8-12', difficulty: 'beginner', updated_at: '2026-06-20T22:55:37.790408+08:00', + playable: true, + version_name: 'v1.0.0', + play_url: '/apps/space-cleaner/', + runtime_url: '/apps/legacy-space-cleaner/', + owner_email: 'private@example.com', }, ], next_cursor: null, @@ -132,18 +149,103 @@ describe('works square host api routes', () => { age_band: '8-12', difficulty: 'beginner', updated_at: '2026-06-20T22:55:37.790408+08:00', + playable: true, + version_name: 'v1.0.0', + play_url: 'https://square.nianxx.cn/apps/space-cleaner/', + runtime_url: null, }, ], next_cursor: null, limit: 24, }, }); + expect(JSON.stringify(response.json())).not.toContain('private@example.com'); expect(fetchMock).toHaveBeenCalledWith( 'https://square.nianxx.cn/api/projects?q=space&category=game&limit=24', { method: 'GET' }, ); }); + it('uses runtime_url only as a trusted fallback and fails closed on unsafe primary data', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ + items: [ + { + app_id: 'legacy-game', + title: 'Legacy Game', + summary: 'Compatibility release', + playable: true, + version_name: 'v1.0.0', + play_url: null, + runtime_url: '/apps/legacy-game/', + }, + { + app_id: 'unsafe-game', + title: 'Unsafe Game', + summary: 'Must not launch', + playable: true, + version_name: 'v1.0.0', + play_url: 'https://evil.example/apps/unsafe-game/', + runtime_url: '/apps/unsafe-game/', + }, + { + app_id: 'unversioned-game', + title: 'Unversioned Game', + summary: 'Missing release version', + playable: true, + play_url: '/apps/unversioned-game/', + }, + ], + next_cursor: null, + limit: 24, + }), { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + await handleWorksRoutes( + createRequest('GET'), + response.res, + new URL('http://127.0.0.1/api/works/projects'), + {} as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + success: true, + page: { + items: [ + { + app_id: 'legacy-game', + title: 'Legacy Game', + summary: 'Compatibility release', + playable: true, + version_name: 'v1.0.0', + play_url: null, + runtime_url: 'https://square.nianxx.cn/apps/legacy-game/', + }, + { + app_id: 'unsafe-game', + title: 'Unsafe Game', + summary: 'Must not launch', + playable: false, + version_name: 'v1.0.0', + play_url: null, + runtime_url: null, + }, + { + app_id: 'unversioned-game', + title: 'Unversioned Game', + summary: 'Missing release version', + playable: false, + play_url: null, + runtime_url: null, + }, + ], + next_cursor: null, + limit: 24, + }, + }); + }); + it('lists public assets through the Works Square API', async () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response(JSON.stringify({ @@ -363,6 +465,9 @@ describe('works square host api routes', () => { age_band: '8-12', difficulty: 'beginner', updated_at: '2026-06-20T22:55:37.790408+08:00', + playable: false, + play_url: null, + runtime_url: null, }, }); expect(fetchMock).toHaveBeenCalledWith( @@ -401,6 +506,7 @@ describe('works square host api routes', () => { status: 'draft', updated_at: '2026-06-20T22:55:37.790408+08:00', playable: false, + play_url: null, runtime_url: null, }, ], @@ -435,6 +541,7 @@ describe('works square host api routes', () => { status: 'draft', updated_at: '2026-06-20T22:55:37.790408+08:00', playable: false, + play_url: null, runtime_url: null, }, ], @@ -743,7 +850,7 @@ describe('works square host api routes', () => { status: 'draft', updated_at: '2026-06-20T22:55:37.790408+08:00', playable: false, - play_url: '/apps/space-cleaner/', + play_url: null, runtime_url: null, }, latest_version: { @@ -832,7 +939,12 @@ describe('works square host api routes', () => { expect(response.json()).toEqual({ success: true, status: { - project: status.project, + project: { + ...status.project, + playable: false, + play_url: null, + runtime_url: null, + }, latest_version: { id: 'version-new', version_name: 'v1.1.0', @@ -911,6 +1023,42 @@ describe('works square host api routes', () => { expect(JSON.stringify(response.json())).not.toContain('token=secret'); }); + it.each([ + ['missing', {}], + ['incorrect', { [RENDERER_CAPABILITY_HEADER]: 'incorrect-capability' }], + ])('rejects a %s Renderer capability before credentials or project files are read', async (_case, headers) => { + const fetchMock = vi.fn(); + const listProjects = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + + const handled = await handleWorksRoutes( + createRequest('POST', { + projectId: 'project-1', + project: { + app_id: 'space-cleaner', + title: 'Space Cleaner', + summary: 'Catch space trash', + }, + }, headers), + response.res, + new URL('http://127.0.0.1/api/works/projects/publish-source'), + { opencodeProjectStore: { listProjects } } as never, + ); + + expect(handled).toBe(true); + expect(response.statusCode).toBe(403); + expect(response.json()).toEqual({ + success: false, + status: 403, + code: 'RENDERER_CAPABILITY_REQUIRED', + error: 'Renderer capability required', + }); + expect(getValidWorksSquareAccessTokenMock).not.toHaveBeenCalled(); + expect(listProjects).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('packages and submits source with Main-owned credentials and automatic release metadata', async () => { tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-publish-')); await writePublishableProject(tempDir); @@ -936,7 +1084,7 @@ describe('works square host api routes', () => { const recordSubmitted = vi.fn(async () => undefined); const handled = await handleWorksRoutes( - createRequest('POST', { projectId: project.id, project: projectMetadata }), + createRendererRequest('POST', { projectId: project.id, project: projectMetadata }), response.res, new URL('http://127.0.0.1/api/works/projects/publish-source'), { @@ -1021,7 +1169,7 @@ describe('works square host api routes', () => { const response = createResponse(); await handleWorksRoutes( - createRequest('POST', { + createRendererRequest('POST', { projectId: project.id, project: { app_id: 'space-cleaner', @@ -1043,7 +1191,12 @@ describe('works square host api routes', () => { expect(response.json()).toMatchObject({ success: true, upload: { version_id: 'version-1', review_status: 'building' }, + binding_warning: { + code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED', + message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', + }, }); + expect(JSON.stringify(response.json())).not.toContain('disk unavailable'); expect(fetchMock).toHaveBeenCalledTimes(2); }); @@ -1062,7 +1215,7 @@ describe('works square host api routes', () => { const response = createResponse(); await handleWorksRoutes( - createRequest('POST', { + createRendererRequest('POST', { projectId: project.id, project: { app_id: 'space-cleaner', @@ -1105,7 +1258,7 @@ describe('works square host api routes', () => { for (let index = 0; index < 2; index += 1) { const response = createResponse(); await handleWorksRoutes( - createRequest('POST', { projectId: project.id, project: projectMetadata }), + createRendererRequest('POST', { projectId: project.id, project: projectMetadata }), response.res, new URL('http://127.0.0.1/api/works/projects/publish-source'), ctx, @@ -1144,7 +1297,7 @@ describe('works square host api routes', () => { const response = createResponse(); await handleWorksRoutes( - createRequest('POST', { + createRendererRequest('POST', { projectId: project.id, project: { app_id: 'space-cleaner', @@ -1180,7 +1333,7 @@ describe('works square host api routes', () => { const response = createResponse(); await handleWorksRoutes( - createRequest('POST', { + createRendererRequest('POST', { projectId: project.id, project: { app_id: 'space-cleaner', @@ -1212,7 +1365,7 @@ describe('works square host api routes', () => { const response = createResponse(); await handleWorksRoutes( - createRequest('POST', { + createRendererRequest('POST', { projectId: 'project-1', project: { app_id: 'space-cleaner', title: 'Space Cleaner', summary: 'Catch trash' }, }), diff --git a/tests/unit/works-square.test.ts b/tests/unit/works-square.test.ts index b709776..f9a6346 100644 --- a/tests/unit/works-square.test.ts +++ b/tests/unit/works-square.test.ts @@ -339,6 +339,10 @@ describe('works square client', () => { build_job_id: 'job-1', build_status: 'queued', }, + binding_warning: { + code: 'LOCAL_PREVIEW_BINDING_SAVE_FAILED', + message: '已提交云端,但本机预览绑定保存失败;可重新打开项目/重新提交。', + }, }; hostApiFetchMock.mockResolvedValueOnce({ success: true, ...publishResult }); @@ -352,7 +356,11 @@ describe('works square client', () => { }, }); - expect(result).toEqual(publishResult); + expect(result).toEqual({ + package: publishResult.package, + upload: publishResult.upload, + bindingWarning: publishResult.binding_warning, + }); expect(hostApiFetchMock).toHaveBeenCalledWith('/api/works/projects/publish-source', { method: 'POST', body: JSON.stringify({ diff --git a/tests/unit/works-submission-binding.test.ts b/tests/unit/works-submission-binding.test.ts index 1952af6..29ca012 100644 --- a/tests/unit/works-submission-binding.test.ts +++ b/tests/unit/works-submission-binding.test.ts @@ -127,4 +127,52 @@ describe('Works Square submission binding store', () => { review_status: 'approved', }); }); + + it.each([ + ['submitted without a version id', { + status: 'submitted', + app_id: 'planet-game', + version_name: 'v0.7.0', + }], + ['retired without its fixed error code', { + status: 'legacy_retired', + message: '请重新提交', + }], + ])('rejects a malformed schema 2 record: %s', async (_case, fields) => { + const project = await createProject(); + await writeFile( + join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME), + `${JSON.stringify({ + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: project.id, + requested_at: '2026-08-10T00:00:00.000Z', + updated_at: '2026-08-10T00:05:00.000Z', + ...fields, + })}\n`, + 'utf8', + ); + + await expect(readWorksSubmissionBinding(project.path, project.id)).resolves.toBeNull(); + }); + + it('rejects a copied binding whose project id does not match the current project', async () => { + const project = await createProject(); + await writeFile( + join(project.path, WORKS_SUBMISSION_BINDING_FILE_NAME), + `${JSON.stringify({ + schema_version: WORKS_SUBMISSION_BINDING_SCHEMA_VERSION, + project_id: 'another-project', + status: 'submitted', + requested_at: '2026-08-10T00:00:00.000Z', + updated_at: '2026-08-10T00:05:00.000Z', + app_id: 'planet-game', + version_id: 'version-7', + version_name: 'v0.7.0', + })}\n`, + 'utf8', + ); + const store = createWorksSubmissionBindingStore(createStore(project)); + + await expect(store.get(project.id)).resolves.toBeNull(); + }); });