diff --git a/docs/27-authoring-drafts.md b/docs/27-authoring-drafts.md index b2219a997..840a13147 100644 --- a/docs/27-authoring-drafts.md +++ b/docs/27-authoring-drafts.md @@ -1,6 +1,12 @@ # 草稿与结构校验 API -这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。尚不包含网页工作台、模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。 +这是创作平台的第一层:创建私有草稿、维护文件和运行结构校验。网页工作台位于 `/dashboard/authoring`:创建、删除草稿,编辑文本或上传二进制资源,发起结构校验并查看运行状态与问题。尚不包含模型执行、MCP 探测、自动修复确认或发布集成。草稿校验通过不等于技能已发布,也不代表运行效果或安全扫描通过。 + +## 网页工作台 + +登录后从个人中心的“技能创作”进入。文件保存携带读取时的草稿 revision,遇到并发冲突保留本地编辑;重新加载或切换文件前确认放弃未保存修改。创建同名文件不会用空内容覆盖已有资源。文本是否可编辑以读取接口的 `encoding` 为准,二进制资源只显示元数据并通过上传替换。 + +未保存修改时不能发起校验。运行状态和 findings 通过轮询更新;活动运行支持取消,终态后停止轮询并刷新草稿校验状态。问题面板显示规则、消息及文件位置,作者可手动编辑后重新校验。结构检查通过不等于脚本或模型行为已验证。 ## 权限与文件 diff --git a/docs/frontend-analytics-manifest.json b/docs/frontend-analytics-manifest.json index 6e280c546..2aa992ce7 100644 --- a/docs/frontend-analytics-manifest.json +++ b/docs/frontend-analytics-manifest.json @@ -1,62 +1,64 @@ { "schema": 1, "description": "Source declarations, not runtime DOM counts, pageviews, successful outcomes or a closed runtime enum. Forwarding sites require explicit callers. See frontend-analytics.md for gaps and event policy.", - "counts": {"routes":54,"uniqueIds":804,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":29,"forwardingSites":36}, + "counts": {"routes":56,"uniqueIds":805,"literalJsxSites":821,"literalJsxValues":975,"navigationDefinitions":30,"forwardingSites":36}, "routes": [ - {"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":258},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false}, - {"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false}, - {"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false}, - {"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":281},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false}, - {"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":290},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false}, - {"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false}, - {"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false}, - {"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":318},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false}, - {"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":329},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false}, - {"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false}, - {"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":344},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false}, - {"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false}, - {"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":357},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false}, - {"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":364},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false}, - {"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":374},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false}, - {"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":384},"implementation":{"file":"web/src/pages/dashboard.tsx","line":98,"export":"DashboardPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":391},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":404},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":430},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":437},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":447},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":463},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":481},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":491},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":503},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":510},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":517},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":524},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":531},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":541},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":548},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":555},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true}, - {"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":588},"implementation":null,"guarded":true,"roleProtected":false}, - {"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":597},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":607},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":629},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true}, - {"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":636},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":643},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":650},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true}, - {"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":657},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":667},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":677},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false}, - {"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":684},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false}, - {"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":691},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false}, - {"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":706},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false}, - {"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":712},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false}, - {"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":719},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false}, - {"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false}, - {"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":733},"implementation":null,"guarded":true,"roleProtected":false}, - {"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":742},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true}, - {"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":749},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true}, - {"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":756},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true}, - {"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":763},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true}, - {"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":770},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true} + {"name":"landingRoute","path":"/","declaration":{"file":"web/src/app/router.tsx","line":264},"implementation":{"file":"web/src/pages/landing.tsx","line":257,"export":"LandingPage"},"guarded":false,"roleProtected":false}, + {"name":"skillsRoute","path":"skills","declaration":{"file":"web/src/app/router.tsx","line":270},"implementation":{"file":"web/src/pages/home.tsx","line":12,"export":"HomePage"},"guarded":false,"roleProtected":false}, + {"name":"loginRoute","path":"login","declaration":{"file":"web/src/app/router.tsx","line":276},"implementation":{"file":"web/src/pages/login.tsx","line":22,"export":"LoginPage"},"guarded":true,"roleProtected":false}, + {"name":"registerRoute","path":"register","declaration":{"file":"web/src/app/router.tsx","line":287},"implementation":{"file":"web/src/pages/register.tsx","line":55,"export":"RegisterPage"},"guarded":false,"roleProtected":false}, + {"name":"resetPasswordRoute","path":"reset-password","declaration":{"file":"web/src/app/router.tsx","line":296},"implementation":{"file":"web/src/pages/reset-password.tsx","line":13,"export":"ResetPasswordPage"},"guarded":false,"roleProtected":false}, + {"name":"privacyRoute","path":"privacy","declaration":{"file":"web/src/app/router.tsx","line":302},"implementation":{"file":"web/src/pages/privacy.tsx","line":155,"export":"PrivacyPolicyPage"},"guarded":false,"roleProtected":false}, + {"name":"searchRoute","path":"search","declaration":{"file":"web/src/app/router.tsx","line":308},"implementation":{"file":"web/src/pages/search.tsx","line":88,"export":"SearchPage"},"guarded":false,"roleProtected":false}, + {"name":"githubSkillsRoute","path":"github","declaration":{"file":"web/src/app/router.tsx","line":324},"implementation":{"file":"web/src/pages/github-skills.tsx","line":39,"export":"GithubSkillsPage"},"guarded":false,"roleProtected":false}, + {"name":"suitesRoute","path":"suites","declaration":{"file":"web/src/app/router.tsx","line":335},"implementation":{"file":"web/src/pages/suites.tsx","line":17,"export":"SuitesPage"},"guarded":false,"roleProtected":false}, + {"name":"suiteDetailRoute","path":"/suite/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":341},"implementation":{"file":"web/src/pages/suite-detail.tsx","line":22,"export":"SuiteDetailPage"},"guarded":false,"roleProtected":false}, + {"name":"termsRoute","path":"terms","declaration":{"file":"web/src/app/router.tsx","line":350},"implementation":{"file":"web/src/pages/terms.tsx","line":171,"export":"TermsOfServicePage"},"guarded":false,"roleProtected":false}, + {"name":"namespaceRoute","path":"/space/$namespace","declaration":{"file":"web/src/app/router.tsx","line":356},"implementation":{"file":"web/src/pages/namespace.tsx","line":5,"export":"NamespacePage"},"guarded":true,"roleProtected":false}, + {"name":"joinRoute","path":"join","declaration":{"file":"web/src/app/router.tsx","line":363},"implementation":{"file":"web/src/pages/join.tsx","line":12,"export":"JoinPage"},"guarded":true,"roleProtected":false}, + {"name":"skillDetailRoute","path":"/space/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":370},"implementation":{"file":"web/src/pages/skill-detail.tsx","line":133,"export":"SkillDetailPage"},"guarded":false,"roleProtected":false}, + {"name":"skillVersionCompareRoute","path":"/space/$namespace/$slug/compare","declaration":{"file":"web/src/app/router.tsx","line":380},"implementation":{"file":"web/src/pages/skill-version-compare.tsx","line":22,"export":"SkillVersionComparePage"},"guarded":false,"roleProtected":false}, + {"name":"dashboardRoute","path":"dashboard","declaration":{"file":"web/src/app/router.tsx","line":390},"implementation":{"file":"web/src/pages/dashboard.tsx","line":99,"export":"DashboardPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSkillsRoute","path":"dashboard/skills","declaration":{"file":"web/src/app/router.tsx","line":397},"implementation":{"file":"web/src/pages/dashboard/my-skills.tsx","line":46,"export":"MySkillsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardPublishRoute","path":"dashboard/publish","declaration":{"file":"web/src/app/router.tsx","line":410},"implementation":{"file":"web/src/pages/dashboard/publish.tsx","line":41,"export":"PublishPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteCreateRoute","path":"dashboard/suites/new","declaration":{"file":"web/src/app/router.tsx","line":436},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":716,"export":"SuiteCreatePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuitesRoute","path":"dashboard/suites","declaration":{"file":"web/src/app/router.tsx","line":443},"implementation":{"file":"web/src/pages/dashboard/my-suites.tsx","line":21,"export":"MySuitesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteManagementRoute","path":"dashboard/suites/$namespace/$slug","declaration":{"file":"web/src/app/router.tsx","line":453},"implementation":{"file":"web/src/pages/dashboard/suite-management.tsx","line":31,"export":"SuiteManagementPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuitePublishingTaskRoute","path":"dashboard/suites/publishing/$operationId","declaration":{"file":"web/src/app/router.tsx","line":469},"implementation":{"file":"web/src/pages/dashboard/suite-publishing-task.tsx","line":6,"export":"SuitePublishingTaskPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteEditRoute","path":"dashboard/suites/$namespace/$slug/edit","declaration":{"file":"web/src/app/router.tsx","line":487},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":720,"export":"SuiteEditPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSuiteVersionCreateRoute","path":"dashboard/suites/$namespace/$slug/new-version","declaration":{"file":"web/src/app/router.tsx","line":497},"implementation":{"file":"web/src/pages/dashboard/suite-editor.tsx","line":726,"export":"SuiteVersionCreatePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespacesRoute","path":"dashboard/namespaces","declaration":{"file":"web/src/app/router.tsx","line":509},"implementation":{"file":"web/src/pages/dashboard/my-namespaces.tsx","line":18,"export":"MyNamespacesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceManagementRoute","path":"dashboard/namespaces/$slug","declaration":{"file":"web/src/app/router.tsx","line":516},"implementation":{"file":"web/src/pages/dashboard/namespace-management.tsx","line":7,"export":"NamespaceManagementPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceSettingsRoute","path":"dashboard/namespaces/$slug/settings","declaration":{"file":"web/src/app/router.tsx","line":523},"implementation":{"file":"web/src/pages/dashboard/namespace-settings.tsx","line":14,"export":"NamespaceSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceMembersRoute","path":"dashboard/namespaces/$slug/members","declaration":{"file":"web/src/app/router.tsx","line":530},"implementation":{"file":"web/src/pages/dashboard/namespace-members.tsx","line":45,"export":"NamespaceMembersPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceReviewsRoute","path":"dashboard/namespaces/$slug/reviews","declaration":{"file":"web/src/app/router.tsx","line":537},"implementation":{"file":"web/src/pages/dashboard/namespace-reviews.tsx","line":145,"export":"NamespaceReviewsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceGithubSourcesRoute","path":"dashboard/namespaces/$slug/github-sources","declaration":{"file":"web/src/app/router.tsx","line":547},"implementation":{"file":"web/src/pages/dashboard/namespace-github-sources.tsx","line":26,"export":"NamespaceGithubSourcesPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardGovernanceRoute","path":"dashboard/governance","declaration":{"file":"web/src/app/router.tsx","line":554},"implementation":{"file":"web/src/pages/dashboard/governance.tsx","line":40,"export":"GovernancePage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardGithubSyncRoute","path":"dashboard/github-sync","declaration":{"file":"web/src/app/router.tsx","line":561},"implementation":{"file":"web/src/pages/dashboard/github-sync.tsx","line":20,"export":"GithubSyncPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardGithubDeepDiscoveryRoute","path":"dashboard/github-deep-discovery","declaration":{"file":"web/src/app/router.tsx","line":594},"implementation":null,"guarded":true,"roleProtected":false}, + {"name":"dashboardReviewsRoute","path":"dashboard/reviews","declaration":{"file":"web/src/app/router.tsx","line":603},"implementation":{"file":"web/src/pages/dashboard/reviews.tsx","line":39,"export":"ReviewsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardReviewProgressRoute","path":"dashboard/review-progress","declaration":{"file":"web/src/app/router.tsx","line":613},"implementation":{"file":"web/src/pages/dashboard/review-progress.tsx","line":27,"export":"ReviewProgressPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardReportsRoute","path":"dashboard/reports","declaration":{"file":"web/src/app/router.tsx","line":635},"implementation":{"file":"web/src/pages/dashboard/reports.tsx","line":31,"export":"ReportsPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardReviewDetailRoute","path":"dashboard/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":642},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":530,"export":"ReviewDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNamespaceReviewDetailRoute","path":"dashboard/namespaces/$slug/reviews/$id","declaration":{"file":"web/src/app/router.tsx","line":649},"implementation":{"file":"web/src/pages/dashboard/review-detail.tsx","line":542,"export":"NamespaceReviewDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardPromotionsRoute","path":"dashboard/promotions","declaration":{"file":"web/src/app/router.tsx","line":656},"implementation":{"file":"web/src/pages/dashboard/promotions.tsx","line":408,"export":"PromotionsPage"},"guarded":true,"roleProtected":true}, + {"name":"dashboardStarsRoute","path":"dashboard/stars","declaration":{"file":"web/src/app/router.tsx","line":663},"implementation":{"file":"web/src/pages/dashboard/stars.tsx","line":12,"export":"MyStarsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardSubscriptionsRoute","path":"dashboard/subscriptions","declaration":{"file":"web/src/app/router.tsx","line":673},"implementation":{"file":"web/src/pages/dashboard/subscriptions.tsx","line":12,"export":"MySubscriptionsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardNotificationsRoute","path":"dashboard/notifications","declaration":{"file":"web/src/app/router.tsx","line":683},"implementation":{"file":"web/src/pages/notifications.tsx","line":48,"export":"NotificationsPage"},"guarded":true,"roleProtected":false}, + {"name":"dashboardTokensRoute","path":"dashboard/tokens","declaration":{"file":"web/src/app/router.tsx","line":690},"implementation":{"file":"web/src/pages/dashboard/tokens.tsx","line":8,"export":"TokensPage"},"guarded":true,"roleProtected":false}, + {"name":"authoringDraftsRoute","path":"dashboard/authoring","declaration":{"file":"web/src/app/router.tsx","line":697},"implementation":{"file":"web/src/pages/authoring/drafts.tsx","line":46,"export":"DraftsPage"},"guarded":true,"roleProtected":false}, + {"name":"authoringDraftDetailRoute","path":"dashboard/authoring/$draftId","declaration":{"file":"web/src/app/router.tsx","line":704},"implementation":{"file":"web/src/pages/authoring/draft-detail.tsx","line":15,"export":"DraftDetailPage"},"guarded":true,"roleProtected":false}, + {"name":"cliAuthRoute","path":"cli/auth","declaration":{"file":"web/src/app/router.tsx","line":711},"implementation":{"file":"web/src/pages/cli-auth.tsx","line":48,"export":"CliAuthPage"},"guarded":false,"roleProtected":false}, + {"name":"deviceAuthRoute","path":"device","declaration":{"file":"web/src/app/router.tsx","line":726},"implementation":{"file":"web/src/pages/device.tsx","line":20,"export":"DeviceAuthPage"},"guarded":false,"roleProtected":false}, + {"name":"settingsSecurityRoute","path":"settings/security","declaration":{"file":"web/src/app/router.tsx","line":732},"implementation":{"file":"web/src/pages/settings/security.tsx","line":28,"export":"SecuritySettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsProfileRoute","path":"settings/profile","declaration":{"file":"web/src/app/router.tsx","line":739},"implementation":{"file":"web/src/pages/settings/profile.tsx","line":40,"export":"ProfileSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsNotificationsRoute","path":"settings/notifications","declaration":{"file":"web/src/app/router.tsx","line":746},"implementation":{"file":"web/src/pages/settings/notification-settings.tsx","line":8,"export":"NotificationSettingsPage"},"guarded":true,"roleProtected":false}, + {"name":"settingsAccountsRoute","path":"settings/accounts","declaration":{"file":"web/src/app/router.tsx","line":753},"implementation":null,"guarded":true,"roleProtected":false}, + {"name":"adminUsersRoute","path":"admin/users","declaration":{"file":"web/src/app/router.tsx","line":762},"implementation":{"file":"web/src/pages/admin/users.tsx","line":47,"export":"AdminUsersPage"},"guarded":true,"roleProtected":true}, + {"name":"adminAuditLogRoute","path":"admin/audit-log","declaration":{"file":"web/src/app/router.tsx","line":769},"implementation":{"file":"web/src/pages/admin/audit-log.tsx","line":50,"export":"AuditLogPage"},"guarded":true,"roleProtected":true}, + {"name":"adminLabelsRoute","path":"admin/labels","declaration":{"file":"web/src/app/router.tsx","line":776},"implementation":{"file":"web/src/pages/admin/labels.tsx","line":118,"export":"AdminLabelsPage"},"guarded":true,"roleProtected":true}, + {"name":"systemConfigRoute","path":"admin/system-config","declaration":{"file":"web/src/app/router.tsx","line":783},"implementation":{"file":"web/src/pages/admin/system-config.tsx","line":13,"export":"SystemConfigPage"},"guarded":true,"roleProtected":true}, + {"name":"adminNamespacesRoute","path":"admin/namespaces","declaration":{"file":"web/src/app/router.tsx","line":790},"implementation":{"file":"web/src/pages/admin/namespaces.tsx","line":104,"export":"AdminNamespacesPage"},"guarded":true,"roleProtected":true} ], "definitions": [ {"file":"web/src/app/layout.tsx","line":89,"ids":["navigation.home.open"]}, @@ -78,16 +80,17 @@ {"file":"web/src/pages/dashboard.tsx","line":52,"ids":["navigation.starred.open"]}, {"file":"web/src/pages/dashboard.tsx","line":53,"ids":["navigation.subscriptions.open"]}, {"file":"web/src/pages/dashboard.tsx","line":54,"ids":["account.tokens.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":55,"ids":["review.progress.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":61,"ids":["governance.center.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":62,"ids":["report.queue.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":63,"ids":["github.sync.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":87,"ids":["navigation.owned.skills"]}, - {"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.suites"]}, - {"file":"web/src/pages/dashboard.tsx","line":89,"ids":["account.tokens.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":90,"ids":["navigation.starred.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":91,"ids":["account.profile.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.security.open"]} + {"file":"web/src/pages/dashboard.tsx","line":55,"ids":["navigation.authoring.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":56,"ids":["review.progress.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":62,"ids":["governance.center.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":63,"ids":["report.queue.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":64,"ids":["github.sync.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":88,"ids":["navigation.owned.skills"]}, + {"file":"web/src/pages/dashboard.tsx","line":89,"ids":["navigation.owned.suites"]}, + {"file":"web/src/pages/dashboard.tsx","line":90,"ids":["account.tokens.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":91,"ids":["navigation.starred.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":92,"ids":["account.profile.open"]}, + {"file":"web/src/pages/dashboard.tsx","line":93,"ids":["account.security.open"]} ], "sites": [ {"file":"web/src/app/layout.tsx","line":126,"host":"Link","prop":"data-analytics-id","event":"click","ids":["navigation.home.open"]}, @@ -758,10 +761,10 @@ {"file":"web/src/pages/dashboard/suite-management.tsx","line":210,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.content.edit"]}, {"file":"web/src/pages/dashboard/suite-management.tsx","line":236,"host":"Button","prop":"data-analytics-id","event":"click","ids":["suite.members.edit"]}, {"file":"web/src/pages/dashboard/suite-management.tsx","line":333,"host":"Link","prop":"data-analytics-id","event":"click","ids":["suite.operation.open"]}, - {"file":"web/src/pages/dashboard.tsx","line":128,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"}, - {"file":"web/src/pages/dashboard.tsx","line":176,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]}, - {"file":"web/src/pages/dashboard.tsx","line":194,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, - {"file":"web/src/pages/dashboard.tsx","line":245,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":129,"host":"Link","prop":"data-analytics-id","event":"click","forward":"card.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":177,"host":"button","prop":"data-analytics-id","event":"click","ids":["navigation.dashboard.toggle"]}, + {"file":"web/src/pages/dashboard.tsx","line":195,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, + {"file":"web/src/pages/dashboard.tsx","line":246,"host":"Link","prop":"data-analytics-id","event":"click","forward":"item.analyticsId"}, {"file":"web/src/pages/device.tsx","line":106,"host":"form","prop":"data-analytics-id","event":"submit","ids":["auth.device.authorize"]}, {"file":"web/src/pages/github-skills.tsx","line":33,"host":"Button","prop":"data-analytics-id","event":"click","ids":["skill.detail.open"]}, {"file":"web/src/pages/github-skills.tsx","line":62,"host":"Button","prop":"data-analytics-id","event":"click","ids":["github.catalog.explore"]}, diff --git a/web/e2e/authoring-structure.spec.ts b/web/e2e/authoring-structure.spec.ts new file mode 100644 index 000000000..1a910ffb5 --- /dev/null +++ b/web/e2e/authoring-structure.spec.ts @@ -0,0 +1,93 @@ +import { expect, test } from '@playwright/test' +import { setEnglishLocale, setUniqueClientIp } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { csrfHeaders } from './helpers/csrf' + +test('authoring routes require a session', async ({ page }) => { + await setEnglishLocale(page) + await page.goto('/dashboard/authoring') + await expect(page).toHaveURL(/\/login\?returnTo=/) +}) + +test('creates a draft, edits files and rechecks structure using merged APIs', async ({ page }, testInfo) => { + await setEnglishLocale(page) + await setUniqueClientIp(page, testInfo.title) + await registerSession(page, testInfo, { allowMockSession: false }) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + const namespace = await builder.ensureWritableNamespace() + const name = `structure-${Date.now().toString(36)}` + let draftId: number | undefined + const authoringRequests: string[] = [] + page.on('request', request => { if (request.url().includes('/authoring/')) authoringRequests.push(request.url()) }) + try { + await page.goto('/dashboard/authoring') + await page.getByTestId('create-draft').click() + await page.getByTestId('draft-namespace').click() + await page.getByRole('option', { name: namespace.displayName || namespace.slug, exact: true }).click() + await page.getByLabel('Skill name', { exact: true }).fill(name) + const createdResponse = page.waitForResponse(response => response.url().endsWith('/api/web/authoring/drafts') && response.request().method() === 'POST') + await page.getByTestId('submit-create-draft').click() + const created = await (await createdResponse).json() + draftId = created.data.id + expect(draftId).toBeDefined() + await page.getByRole('link', { name, exact: true }).click() + await expect(page.getByRole('heading', { name, exact: true })).toBeVisible() + const editor = page.getByLabel('File content', { exact: true }) + await expect(editor).toBeVisible() + await editor.fill(`---\nname: ${name}\n---\n\n# Structure test\n`) + await expect(page.getByTestId('start-run')).toBeDisabled() + await page.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByTestId('start-run')).toBeEnabled() + await page.getByTestId('start-run').click() + await expect(page.getByTestId('validation-results').getByText(/Failed · revision/)).toBeVisible({ timeout: 30_000 }) + await expect(page.getByText(/FRONTMATTER_FIELD_MISSING/)).toBeVisible() + await editor.fill(`---\nname: ${name}\ndescription: A structure validation test skill\n---\n\n# Structure test\n\nUse this skill to test package validation.\n`) + await page.getByRole('button', { name: 'Save', exact: true }).click() + await expect(page.getByTestId('start-run')).toBeEnabled() + await page.getByTestId('start-run').click() + await expect(page.getByTestId('validation-results').getByText(/Succeeded · revision/)).toBeVisible({ timeout: 30_000 }) + await expect(page.getByText(/Structure checked/)).toBeVisible() + // A successful structural check must not introduce runtime, fix or publish actions. + expect(authoringRequests.some(url => /\/(runtime|submit|apply|dismiss)(?:\?|$)/.test(url))).toBe(false) + const persisted = await page.request.get(`/api/web/authoring/drafts/${draftId}`) + expect((await persisted.json()).data.validated).toBe(true) + + // Transport encoding, rather than filename guessing, keeps binary bytes out of the editor. + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg==', 'base64') + await page.getByLabel('Path', { exact: true }).fill('assets/pixel.png') + await page.getByLabel('Upload file', { exact: true }).setInputFiles({ name: 'pixel.png', mimeType: 'image/png', buffer: png }) + await page.getByRole('button', { name: 'Upload', exact: true }).click() + await expect(page.getByTestId('binary-file-panel')).toBeVisible() + const binary = await page.request.get(`/api/web/authoring/drafts/${draftId}/files/content?path=assets%2Fpixel.png`) + const binaryFile = (await binary.json()).data + expect(binaryFile.encoding).toBe('base64') + expect(Buffer.from(binaryFile.content, 'base64').equals(png)).toBe(true) + const invalidated = await page.request.get(`/api/web/authoring/drafts/${draftId}`) + expect((await invalidated.json()).data.validated).toBe(false) + + // Private draft and content are also refused by the real API without this session. + const stranger = await page.context().browser()!.newContext({ baseURL: testInfo.project.use.baseURL }) + try { + expect((await stranger.request.get(`/api/web/authoring/drafts/${draftId}`)).status()).toBe(401) + expect((await stranger.request.get(`/api/web/authoring/drafts/${draftId}/files/content?path=SKILL.md`)).status()).toBe(401) + } finally { await stranger.close() } + + for (const [theme, width, density] of [['light', 1440, 'regular'], ['dark', 390, 'compact']] as const) { + await page.setViewportSize({ width, height: 900 }) + await page.evaluate(({ theme, density }) => { + document.documentElement.classList.toggle('dark', theme === 'dark') + document.documentElement.dataset.density = density + }, { theme, density }) + await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + await page.screenshot({ path: testInfo.outputPath(`structure-${theme}-${width}.png`), fullPage: true }) + } + } finally { + if (draftId != null) { + const removed = await page.request.delete(`/api/web/authoring/drafts/${draftId}`, { headers: await csrfHeaders(page) }) + expect(removed.ok()).toBeTruthy() + } + await builder.cleanup() + } +}) diff --git a/web/src/api/authoring.test.ts b/web/src/api/authoring.test.ts new file mode 100644 index 000000000..07221e040 --- /dev/null +++ b/web/src/api/authoring.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { authoringApi } from './client' +import { ApiError } from '@/shared/lib/api-error' +const fetchMock = vi.fn() +beforeEach(() => { vi.stubGlobal('fetch', fetchMock); fetchMock.mockReset(); document.cookie = 'XSRF-TOKEN=fixture-csrf; path=/' }) +afterEach(() => { document.cookie = 'XSRF-TOKEN=; Max-Age=0; path=/'; vi.unstubAllGlobals() }) +describe('authoring request boundary', () => { + it('keeps binary encoding, revision and CSRF on file writes', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { created: true } }))) + await authoringApi.saveFile(3, { path: 'assets/raw.bin', content: '/wAB', base64: true, expectedRevision: 7 }) + const [url, request] = fetchMock.mock.calls[0] + expect(url).toBe('/api/web/authoring/drafts/3/files') + expect(request.method).toBe('PUT') + expect(new Headers(request.headers).get('X-XSRF-TOKEN')).toBe('fixture-csrf') + expect(JSON.parse(request.body)).toEqual({ path: 'assets/raw.bin', content: '/wAB', encoding: 'base64', expectedRevision: 7 }) + }) + it('encodes file paths without losing separators, unicode or query punctuation', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 0, msg: 'ok', data: { content: 'hello', encoding: 'text' } }))) + await authoringApi.readFile(3, 'references/示例 & notes.md') + const url = new URL(fetchMock.mock.calls[0][0], 'http://localhost') + expect(url.searchParams.get('path')).toBe('references/示例 & notes.md') + expect([...url.searchParams.keys()]).toEqual(['path']) + }) + it('preserves server conflict errors instead of treating saves as successful', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ code: 409, msg: 'Revision conflict', data: null }), { status: 409 })) + await expect(authoringApi.saveFile(3, { path: 'SKILL.md', content: 'edit', expectedRevision: 1 })).rejects.toMatchObject({ status: 409, message: 'Revision conflict' } satisfies Partial) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f28f0bc41..b4573852e 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -62,6 +62,12 @@ import type { BatchMemberResponse, SkillSuite, SkillSuiteDraftInput, + AuthoringDraft, + DraftFileSummary, + DraftFileContent, + SaveDraftFileOutcome, + ValidationRunInfo, + ValidationFindingInfo, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -2282,3 +2288,101 @@ export const notificationApi = { }) }, } + +export const authoringApi = { + async listDrafts(): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`) + }, + + async createDraft(request: components['schemas']['CreateDraftRequest']): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async getDraft(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`) + }, + + async deleteDraft(draftId: number): Promise { + await fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async listFiles(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`) + }, + + async readFile(draftId: number, path: string): Promise { + const query = new URLSearchParams({ path }) + return fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`, + ) + }, + + async saveFile( + draftId: number, + request: Omit & { base64?: boolean }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + path: request.path, + content: request.content, + encoding: request.base64 ? 'base64' : undefined, + contentType: request.contentType, + expectedRevision: request.expectedRevision, + }), + }) + }, + + async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise { + const query = new URLSearchParams({ path }) + if (expectedRevision !== undefined) { + query.set('expectedRevision', String(expectedRevision)) + } + await fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`, + { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async startRun(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listRuns(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`) + }, + + async getRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}`) + }, + + async cancelRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listFindings(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`) + }, + +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index bbf46a062..5fc927c5b 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -885,3 +885,11 @@ export type NamespaceInvitationHistory = Required, 'items'> & { items: NamespaceInvitationHistory[] } + +// Existing authoring API contracts; no backend/schema changes are needed for the workbench. +export type AuthoringDraft = components['schemas']['DraftResponse'] +export type DraftFileSummary = components['schemas']['DraftFileResponse'] +export type DraftFileContent = components['schemas']['DraftFileContentResponse'] +export type SaveDraftFileOutcome = components['schemas']['SaveDraftFileResponse'] +export type ValidationRunInfo = components['schemas']['ValidationRunResponse'] +export type ValidationFindingInfo = components['schemas']['ValidationFindingResponse'] diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index 9219ef9d0..8c182211b 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -195,6 +195,12 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions) const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions) +const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions) +const AuthoringDraftDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/draft-detail'), + 'DraftDetailPage', + dashboardRouteOptions, +) const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), @@ -688,6 +694,20 @@ const dashboardTokensRoute = createRoute({ component: TokensPage, }) +const authoringDraftsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring', + beforeLoad: requireAuth, + component: AuthoringDraftsPage, +}) + +const authoringDraftDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId', + beforeLoad: requireAuth, + component: AuthoringDraftDetailPage, +}) + const cliAuthRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cli/auth', @@ -818,6 +838,8 @@ const routeTree = rootRoute.addChildren([ dashboardSubscriptionsRoute, dashboardNotificationsRoute, dashboardTokensRoute, + authoringDraftsRoute, + authoringDraftDetailRoute, cliAuthRoute, deviceAuthRoute, settingsSecurityRoute, diff --git a/web/src/features/authoring/binary-file.test.ts b/web/src/features/authoring/binary-file.test.ts new file mode 100644 index 000000000..283f00893 --- /dev/null +++ b/web/src/features/authoring/binary-file.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes } from './binary-file' + +describe('formatBytes', () => { + it('formats byte sizes for humans', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB') + }) +}) diff --git a/web/src/features/authoring/binary-file.ts b/web/src/features/authoring/binary-file.ts new file mode 100644 index 000000000..bc9766af3 --- /dev/null +++ b/web/src/features/authoring/binary-file.ts @@ -0,0 +1,35 @@ +export function formatBytes(size: number): string { + if (size < 1024) { + return `${size} B` + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) + reader.onload = () => { + const buffer = reader.result + if (!(buffer instanceof ArrayBuffer)) { + reject(new Error('failed to read file')) + return + } + resolve(bytesToBase64(new Uint8Array(buffer))) + } + reader.readAsArrayBuffer(file) + }) +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} diff --git a/web/src/features/authoring/draft-file-editor.test.tsx b/web/src/features/authoring/draft-file-editor.test.tsx new file mode 100644 index 000000000..2ae114336 --- /dev/null +++ b/web/src/features/authoring/draft-file-editor.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { authoringApi } from '@/api/client' +import { DraftFileEditor } from './draft-file-editor' +import i18n from '@/i18n/config' + +vi.mock('@tanstack/react-router', () => ({ useBlocker: () => ({ status: 'idle' }) })) +vi.mock('@/api/client', () => ({ authoringApi: { getDraft: vi.fn(), listFiles: vi.fn(), readFile: vi.fn(), saveFile: vi.fn(), deleteFile: vi.fn() } })) +vi.mock('@/shared/lib/toast', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) +const clients: QueryClient[] = [] +function mount() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + clients.push(client) + const props = { draftId: 42, revision: 3, files: [{ path: 'SKILL.md', sha256: 'old', size: 4 }, { path: 'scripts/test.sh', sha256: 'script', size: 2 }], onSaved: vi.fn(), onDirtyChange: vi.fn() } + const view = render() + return { ...view, props, client } +} +beforeEach(async () => { + vi.clearAllMocks() + await i18n.changeLanguage('en') + vi.mocked(authoringApi.getDraft).mockResolvedValue({ revision: 3 }) + vi.mocked(authoringApi.readFile).mockImplementation(async (_id, path) => ({ path, content: path === 'SKILL.md' ? 'original' : 'echo ok', encoding: 'text', sha256: path })) + vi.mocked(authoringApi.saveFile).mockResolvedValue({ created: true, draft: { revision: 4 } }) +}) +afterEach(() => { cleanup(); clients.forEach(client => client.clear()); clients.length = 0 }) + +describe('draft files', () => { + it('keeps edits after a conflict and uses the revision captured with the read', async () => { + const { props, rerender, client } = mount() + const editor = await screen.findByLabelText('File content') + fireEvent.change(editor, { target: { value: 'my edit' } }) + rerender() + vi.mocked(authoringApi.saveFile).mockRejectedValueOnce(new Error('Revision conflict')) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(authoringApi.saveFile).toHaveBeenCalledWith(42, { path: 'SKILL.md', content: 'my edit', expectedRevision: 3 })) + await waitFor(() => expect((editor as HTMLTextAreaElement).disabled).toBe(false)) + expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('my edit') + expect(screen.getByText('Unsaved changes')).toBeTruthy() + }) + + it('requires confirmation before switching away from a dirty file', async () => { + mount() + fireEvent.change(await screen.findByLabelText('File content'), { target: { value: 'my edit' } }) + fireEvent.click(screen.getByRole('button', { name: 'scripts/test.sh' })) + expect(screen.getByRole('dialog')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('my edit') + fireEvent.click(screen.getByRole('button', { name: 'scripts/test.sh' })) + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) + await waitFor(() => expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('echo ok')) + }) + + it('does not create an empty file over an existing path', async () => { + mount() + await screen.findByLabelText('File content') + fireEvent.change(screen.getByLabelText('Path'), { target: { value: 'skill.md' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create file' })) + expect(authoringApi.saveFile).not.toHaveBeenCalled() + }) + + it('refuses to overwrite a file absent from the cached list', async () => { + vi.mocked(authoringApi.listFiles).mockResolvedValue([{ path: 'new.md' }]) + mount() + await screen.findByLabelText('File content') + fireEvent.change(screen.getByLabelText('Path'), { target: { value: 'new.md' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create file' })) + await waitFor(() => expect(authoringApi.listFiles).toHaveBeenCalledWith(42)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Create file' }).disabled).toBe(false)) + expect(authoringApi.saveFile).not.toHaveBeenCalled() + }) + + it('uses server encoding rather than an extension to protect binary bytes', async () => { + vi.mocked(authoringApi.readFile).mockResolvedValue({ content: '/wAB', encoding: 'base64', sha256: 'binary' }) + mount() + await screen.findByTestId('binary-file-panel') + expect(screen.queryByLabelText('File content')).toBeNull() + expect(screen.queryByText('/wAB')).toBeNull() + }) + + it('shows a retry on read failure instead of an editable empty file', async () => { + vi.mocked(authoringApi.readFile).mockRejectedValueOnce(new Error('unavailable')) + mount() + await screen.findByRole('alert') + expect(screen.queryByLabelText('File content')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await screen.findByLabelText('File content') + }) + + it('can discard edits and reload even when the server bytes did not change', async () => { + mount() + fireEvent.change(await screen.findByLabelText('File content'), { target: { value: 'my edit' } }) + fireEvent.click(screen.getByRole('button', { name: 'Reload file' })) + fireEvent.click(screen.getByRole('button', { name: 'Confirm' })) + await waitFor(() => expect((screen.getByLabelText('File content') as HTMLTextAreaElement).value).toBe('original')) + expect(screen.queryByText('Unsaved changes')).toBeNull() + }) +}) diff --git a/web/src/features/authoring/draft-file-editor.tsx b/web/src/features/authoring/draft-file-editor.tsx new file mode 100644 index 000000000..2043b8f93 --- /dev/null +++ b/web/src/features/authoring/draft-file-editor.tsx @@ -0,0 +1,166 @@ +import { useEffect, useState } from 'react' +import { useBlocker } from '@tanstack/react-router' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' +import { authoringApi } from '@/api/client' +import type { DraftFileSummary } from '@/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' +import { Textarea } from '@/shared/ui/textarea' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { toast } from '@/shared/lib/toast' +import { fileToBase64, formatBytes } from './binary-file' +import { QueryError } from './query-error' + +export function DraftFileEditor({ draftId, files, revision, onSaved, onDirtyChange }: { + draftId: number, files: DraftFileSummary[], revision: number, + onSaved: () => void, onDirtyChange: (dirty: boolean) => void, +}) { + const { t } = useTranslation() + const client = useQueryClient() + const [selectedPath, setSelectedPath] = useState(null) + const [newPath, setNewPath] = useState('') + const [uploadFile, setUploadFile] = useState(null) + const [busy, setBusy] = useState(false) + const [dirty, setDirty] = useState(false) + const [discardPath, setDiscardPath] = useState(null) + const [deleteOpen, setDeleteOpen] = useState(false) + const [reloadOpen, setReloadOpen] = useState(false) + const [editorGeneration, setEditorGeneration] = useState(0) + const paths = files.flatMap(file => file.path ? [file.path] : []).sort() + const path = selectedPath && paths.includes(selectedPath) ? selectedPath : paths[0] ?? null + const selected = files.find(file => file.path === path) + const readKey = ['authoring', 'draft', draftId, 'content', path] + const read = useQuery({ + queryKey: readKey, + queryFn: async () => { + if (!path) throw new Error(t('authoring.files.empty')) + // Capture the revision at the start of this read; later refreshes must not + // silently authorize an edit based on stale bytes against a newer revision. + const before = await authoringApi.getDraft(draftId) + if (before.revision == null) throw new Error(t('authoring.ui.loadFailed')) + const file = await authoringApi.readFile(draftId, path) + return { ...file, revision: before.revision } + }, + enabled: path != null, meta: { skipGlobalErrorHandler: true }, retry: false, + staleTime: Infinity, + }) + const loadedFile = read.data + const blocker = useBlocker({ shouldBlockFn: () => dirty || busy, enableBeforeUnload: dirty || busy, withResolver: true }) + useEffect(() => { onDirtyChange(dirty || busy) }, [dirty, busy, onDirtyChange]) + + const choosePath = (next: string) => { + if (next === path) return + if (dirty) setDiscardPath(next) + else setSelectedPath(next) + } + const mutateFile = async (operation: () => Promise, successKey: string, nextPath?: string) => { + if (busy) return + setBusy(true) + try { + await operation() + setDirty(false) + if (nextPath) setSelectedPath(nextPath) + await client.invalidateQueries({ queryKey: ['authoring', 'draft', draftId, 'content'] }) + onSaved() + toast.success(t(successKey)) + return true + } catch (error) { + toast.error(error instanceof Error ? error.message : t('apiError.unknown')) + return false + } finally { setBusy(false) } + } + const create = async () => { + const next = newPath.trim().replace(/\\/g, '/').replace(/(^|\/)skill\.md$/i, '$1SKILL.md') + if (!next || busy || dirty) return + // Creating a file must never overwrite an existing resource with empty bytes. + if (paths.includes(next)) { + toast.error(t('authoring.files.exists')) + return + } + const created = await mutateFile(async () => { + // Read the revision before listing files. Any concurrent creation after + // that revision either appears in the list or makes the write conflict. + const snapshot = await authoringApi.getDraft(draftId) + if (snapshot.revision == null) throw new Error(t('authoring.ui.loadFailed')) + const latestFiles = await authoringApi.listFiles(draftId) + if (latestFiles.some(file => file.path === next)) { + throw new Error(t('authoring.files.exists')) + } + return authoringApi.saveFile(draftId, { + path: next, content: '', expectedRevision: snapshot.revision, + }) + }, 'authoring.files.created', next) + if (created) setNewPath('') + } + const upload = async () => { + if (!uploadFile || !newPath.trim() || busy || dirty) return + const file = uploadFile + const next = newPath.trim() + const ok = await mutateFile(async () => authoringApi.saveFile(draftId, { + path: next, content: await fileToBase64(file), base64: true, + contentType: file.type || undefined, expectedRevision: revision, + }), 'authoring.files.uploaded', next) + if (ok) { setUploadFile(null); setNewPath('') } + } + return ( +
+
+
    + {paths.map(name =>
  • )} +
+ + setNewPath(event.target.value)} placeholder="scripts/run.py" /> + + + { + const file = event.target.files?.[0] ?? null + setUploadFile(file) + if (file && !newPath.trim()) setNewPath(`assets/${file.name}`) + }} /> +

{t('authoring.files.replaceHint')}

+ +
+
+ {!path &&

{t('authoring.files.empty')}

} + {path && read.isPending &&

{t('authoring.ui.loading')}

} + {path && read.isError && void read.refetch()} />} + {path && loadedFile && (loadedFile.encoding === 'text' ? mutateFile(() => authoringApi.saveFile(draftId, { path, content, expectedRevision: loadedFile.revision }), 'authoring.files.saved')} /> :
+

{path}

+

{t('authoring.files.size')}: {formatBytes(selected?.size ?? 0)}

+

sha256: {selected?.sha256}

+

{t('authoring.files.binaryHint')}

+
)} + {path && } + {path && } +
+ { if (!open) setDiscardPath(null) }} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={() => { setDirty(false); setSelectedPath(discardPath); setDiscardPath(null) }} /> + { if (!open) blocker.reset?.() }} title={t('authoring.files.discardTitle')} description={t('authoring.files.discardDescription')} onConfirm={() => { if (!busy) blocker.proceed?.() }} closeOnConfirm={false} /> + { const result = await read.refetch(); if (result.isSuccess) { setEditorGeneration(value => value + 1); setDirty(false); setReloadOpen(false) } }} /> + { + if (!path) return + if (await mutateFile(() => authoringApi.deleteFile(draftId, path, read.data?.revision ?? revision), 'authoring.files.deleted')) { setDeleteOpen(false); setSelectedPath(null) } + }} /> +
+ ) +} + +function TextFileEditor({ content: initial, disabled, onDirtyChange, onSave }: { + content: string, disabled: boolean, onDirtyChange: (dirty: boolean) => void, + onSave: (content: string) => Promise, +}) { + const { t } = useTranslation() + const [content, setContent] = useState(initial) + const [saved, setSaved] = useState(initial) + const dirty = content !== saved + useEffect(() => { onDirtyChange(dirty); return () => onDirtyChange(false) }, [dirty, onDirtyChange]) + return
+ +