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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@

ContentCloud 的重要变更记录在此文件中。

## [0.3.0] - 2026-07-26

### Added

- 增加独立平台管理员后台,提供全平台租户、用户、项目、在线设备和活跃任务概览。
- 增加租户停用与恢复能力;停用时原子撤销该租户的活动会话,并阻止成员继续登录。
- 增加 `/workspace`、`/admin`、认证和公开审批页面的独立 React Router 路由树及按需加载。
- 增加公开 `/api/bootstrap` Agent 初始化协议和 Web Prompt 引导;只有项目级配置、doctor 与 `workspace.register` 全部完成后才确认连接成功。

### Changed

- 将 npm 包作用域统一迁移到 `@limecloud/contentcloud`。
- 统一 Server、Worker、CLI、Web 和 npm 安装器版本为 `0.3.0`,GitHub 发布标签为 `v0.3.0`。
- 平台管理员权限改为通过 `CONTENTCLOUD_PLATFORM_ADMIN_EMAILS` 显式配置,不复用租户角色。

### Fixed

- 在宝塔 Nginx HTTPS 反向代理及原生 TLS 场景下为会话 Cookie 启用 `Secure` 标记,同时保留本地 HTTP 开发能力。
- 隐藏停用租户的登录入口,并在恢复后重新允许其成员建立会话。

## [0.2.0] - 2026-07-26

### Added
Expand Down
34 changes: 23 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ V2 交付 AI 视频就绪剧本,不生成图片、视频或成片。Hosted Pre

- 云端 zero-exec:不调用、代理、编排 LLM,不保存模型凭据,不执行客户上传代码。
- Agent、Skill、Renderer、脚本和 CI 的所有程序化服务通讯只经过 `contentcloud` CLI。
- Web 只访问同源 `/api/bff`;内部 HTTP、token 和对象存储协议不是公共 SDK。
- 客户先在 Web 创建项目,再用一次性连接码初始化本地工作区、项目级 Skills 和 MCP。
- 使用者工作台只访问同源 `/api/bff`,独立系统后台只访问 `/api/v1/admin`;这些内部 HTTP、token 和对象存储协议都不是公共 SDK。
- 客户先在 Web 创建项目,再把一次性 Agent Prompt 粘贴到 Codex 或 Claude,由 Agent 初始化本地工作区、项目级 Skills 和 MCP。
- 普通本地操作不创建 TaskRun;只有显式启用的远程、事件或定时 Automation 使用 Daemon。
- 客户审批绑定不可变 SubmissionRevision 内容哈希,不跟随“最新版本”。

Expand All @@ -36,7 +36,7 @@ make build
CONTENTCLOUD_DEV_MODE=1 ./bin/contentcloud-server
```

打开 `http://localhost:8080`。开发模式使用 Memory Store、本地 Blob,并自动创建金陵古法线香演示项目;来源由内置确定性 Worker 处理。
打开 `http://localhost:8080` 使用租户工作台,或打开 `http://localhost:8080/admin/dashboard` 使用独立系统后台。开发模式使用 Memory Store、本地 Blob,演示账号默认具备平台管理员权限,并自动创建金陵古法线香演示项目;来源由内置确定性 Worker 处理。

CLI 示例:

Expand Down Expand Up @@ -72,6 +72,7 @@ CONTENTCLOUD_BINARY_PATH=./bin/contentcloud node packages/contentcloud/bin/conte
```bash
export CONTENTCLOUD_DATABASE_URL='postgres://...'
export CONTENTCLOUD_AUTO_MIGRATE=1
export CONTENTCLOUD_PLATFORM_ADMIN_EMAILS='admin@example.com' # 多个邮箱用逗号分隔
export CONTENTCLOUD_S3_BUCKET='contentcloud'
export CONTENTCLOUD_S3_REGION='us-east-1'
export CONTENTCLOUD_S3_ENDPOINT='https://s3.example.com' # AWS S3 可省略
Expand All @@ -93,22 +94,33 @@ CONTENTCLOUD_REQUIRE_MALWARE_SCAN=1 ./bin/contentcloud-worker
## 首次项目连接

1. 用户在 Web 创建项目。
2. 项目总览生成 10 分钟有效、单次使用的 `cck_`。
3. 用户在自己的 Mac 运行页面给出的命令:
2. 项目总览生成 10 分钟有效、单次使用的 `cck_`,并拼成不含登录态的 Agent Prompt:

```text
Fetch https://content.example.com/api/bootstrap and initialize this ContentCloud project.

server-url: https://content.example.com
connect-key: cck_xxx
project: "品牌 / 单品"
```

3. 用户把 Prompt 粘贴到目标项目的 Codex 或 Claude 会话。Agent 获取公开的 `/api/bootstrap` Markdown 协议,检查目录并执行初始化。
4. CLI 先消费连接码并把会话推进到 `verifying`,再初始化项目级 Skills/MCP、执行 `workspace doctor` 并调用 `workspace.register`;只有全部成功后 Web 才显示 `connected`。
5. npm 安装器校验 GitHub Release 的 `checksums.txt`,原子安装 Go binary。
6. CLI 把 `wt_` Workspace Credential 和兼容用 `dt_` Device Credential 写入 macOS Keychain。
7. 初始化默认不注册 LaunchAgent、不启动 Daemon、不上传文件,也不修改全局 Agent 配置。

无法使用 Coding Agent 时,可以在一个空目录中手动运行:

```bash
npx --yes @goodvision/contentcloud@latest init \
npx --yes @limecloud/contentcloud@latest init \
--server-url https://content.example.com \
--connect cck_xxx \
--target all \
--accept-project-config \
./contentcloud-project
.
```

4. npm 安装器校验 GitHub Release 的 `checksums.txt`,原子安装 Go binary。
5. CLI 把 `wt_` Workspace Credential 和兼容用 `dt_` Device Credential 写入 macOS Keychain。
6. CLI 初始化本地模板、Skills/MCP,并通过 `workspace.register` 确认绑定;默认不注册 LaunchAgent、不启动 Daemon、不上传文件。

用户 CLI 登录与设备连接凭据分离:`contentcloud auth login --no-wait --json` 发起浏览器确认,之后用 `--device-code` 完成并把 `ct_` 写入 Keychain。

## 验收
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.2.0
0.3.0
19 changes: 18 additions & 1 deletion cmd/contentcloud-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"

Expand Down Expand Up @@ -48,10 +49,14 @@ func main() {
logger.Error("initialize object storage", "error", err)
os.Exit(1)
}
service := app.NewWithBlob(st, logger, blobStore)
addr := env("CONTENTCLOUD_ADDR", ":8080")
webDist := env("CONTENTCLOUD_WEB_DIST", "web/dist")
devMode := os.Getenv("CONTENTCLOUD_DEV_MODE") == "1" || os.Getenv("CONTENTCLOUD_DEV_MODE") == "true"
adminEmails := splitValues(os.Getenv("CONTENTCLOUD_PLATFORM_ADMIN_EMAILS"))
if devMode {
adminEmails = append(adminEmails, "demo@contentcloud.local")
}
service := app.NewWithBlob(st, logger, blobStore, app.WithPlatformAdminEmails(adminEmails...))
workerCtx, cancelWorker := context.WithCancel(context.Background())
defer cancelWorker()
if devMode && databaseURL == "" {
Expand Down Expand Up @@ -86,6 +91,18 @@ func main() {
defer cancel()
_ = server.Shutdown(ctx)
}

func splitValues(value string) []string {
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if normalized := strings.TrimSpace(part); normalized != "" {
out = append(out, normalized)
}
}
return out
}

func env(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
Expand Down
42 changes: 42 additions & 0 deletions contracts/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ info:
servers:
- url: /api
tags:
- {name: Bootstrap, description: Public Agent-readable project initialization protocol}
- {name: CLI, description: Private transport used only by the Go CLI}
- {name: Web BFF, description: Same-origin human Web application}
- {name: Admin, description: Same-origin platform administrator control plane}
- {name: Review BFF, description: Token-bound customer approval projection}
x-json-read: &jsonRead
tags: [Web BFF]
Expand Down Expand Up @@ -44,7 +46,42 @@ x-public-json-write: &publicJSONWrite
'400': {$ref: '#/components/responses/Error'}
'401': {$ref: '#/components/responses/Error'}
'409': {$ref: '#/components/responses/Error'}
x-admin-json-read: &adminJSONRead
tags: [Admin]
security: [{cookieAuth: []}]
responses:
'200': {$ref: '#/components/responses/Success'}
'401': {$ref: '#/components/responses/Error'}
'403': {$ref: '#/components/responses/Error'}
'404': {$ref: '#/components/responses/Error'}
x-admin-json-write: &adminJSONWrite
tags: [Admin]
security: [{cookieAuth: []}]
requestBody:
required: true
content:
application/json: {schema: {type: object}}
responses:
'200': {$ref: '#/components/responses/Success'}
'400': {$ref: '#/components/responses/Error'}
'401': {$ref: '#/components/responses/Error'}
'403': {$ref: '#/components/responses/Error'}
'404': {$ref: '#/components/responses/Error'}
paths:
/bootstrap:
get:
tags: [Bootstrap]
operationId: getAgentBootstrap
summary: Read the ContentCloud Agent initialization protocol
security: []
responses:
'200':
description: Agent-readable Markdown instructions
headers:
Cache-Control: {schema: {type: string}, description: Always `no-cache`}
content:
text/markdown:
schema: {type: string}
/v1/auth/register:
post: *publicJSONWrite
/v1/auth/login:
Expand All @@ -66,6 +103,11 @@ paths:
'401': {$ref: '#/components/responses/Error'}
'403': {$ref: '#/components/responses/Error'}
'409': {$ref: '#/components/responses/Error'}
/v1/admin/dashboard:
get: *adminJSONRead
/v1/admin/tenants/{tenant_id}:
parameters: [{name: tenant_id, in: path, required: true, schema: {type: string, format: uuid}}]
patch: *adminJSONWrite
/bff/session:
get: *jsonRead
/bff/session/switch:
Expand Down
1 change: 1 addition & 0 deletions deploy/systemd/contentcloud.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ CONTENTCLOUD_ADDR=127.0.0.1:18082
CONTENTCLOUD_WEB_DIST=/opt/contentcloud/current/web
CONTENTCLOUD_DEV_MODE=0
CONTENTCLOUD_AUTO_MIGRATE=1
CONTENTCLOUD_PLATFORM_ADMIN_EMAILS=admin@example.com
CONTENTCLOUD_REQUIRE_MALWARE_SCAN=0
4 changes: 2 additions & 2 deletions docs/roadmap/v1/05-agent-protocol-and-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ CLI 成功 envelope 固定为 `{ok, command, request_id, data, meta}`;错误 e

## 4. CLI 命令面与安装

npm 安装器和 Go 可执行文件分别为 `@goodvision/contentcloud` 与 `contentcloud`。参考飞书官方 CLI,业务逻辑位于跨平台 Go 单二进制;npm 只负责选择 OS/arch、下载、校验并调用它,Daemon 常驻不依赖 Node.js。
npm 安装器和 Go 可执行文件分别为 `@limecloud/contentcloud` 与 `contentcloud`。参考飞书官方 CLI,业务逻辑位于跨平台 Go 单二进制;npm 只负责选择 OS/arch、下载、校验并调用它,Daemon 常驻不依赖 Node.js。

```text
contentcloud auth login|status|logout
Expand Down Expand Up @@ -174,7 +174,7 @@ contentcloud --json result ratings --project "$PROJECT_ID"
## 5. 项目优先的 Connect Session

1. 登录用户先在 Web 创建 BrandProject,再从项目页创建 `connect-key`;key 绑定 tenant、project 和邀请人,有效期 10 分钟、仅可消费一次。
2. Web 展示 `npx --yes @goodvision/contentcloud@latest up --server-url <url> --connect-key <cck_...>`,以及可复制给 Codex/Claude Code 的同义提示。
2. Web 展示 `npx --yes @limecloud/contentcloud@latest up --server-url <url> --connect-key <cck_...>`,以及可复制给 Codex/Claude Code 的同义提示。
3. npm 安装器在用户电脑下载并校验 Go 单二进制;CLI 执行 capability probe,提交 key、设备元数据和设备公钥摘要。
4. 服务端原子消费 key,生成 32 字节随机 device token,只通过 TLS 返回一次,并创建当前项目的 ProjectDeviceGrant。
5. CLI 将 server URL/device ID 写配置,将 token 写平台安全凭据存储,并注册用户级后台服务。
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v1/09-hosted-preview-and-cli-gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ sequenceDiagram
Web 展示的主命令为:

```bash
npx --yes @goodvision/contentcloud@latest up \
npx --yes @limecloud/contentcloud@latest up \
--server-url https://app.contentcloud.cn \
--connect-key cck_xxx
```
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v1/10-technology-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ Go 统一控制面与客户端的收益:
Web 在项目创建成功后展示:

```bash
npx --yes @goodvision/contentcloud@latest up \
npx --yes @limecloud/contentcloud@latest up \
--server-url https://app.contentcloud.cn \
--connect-key cck_xxx
```
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v1/11-feishu-cli-benchmark.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ContentCloud 的首次接入仍保持“服务端先创建项目,客户端后
| 观察项 | 飞书 CLI 实现 | 对 ContentCloud 的含义 |
| --- | --- | --- |
| 运行时 | Go 1.23+、Cobra、跨平台单二进制 | 控制面与 CLI/Daemon 继续采用 Go 1.24 |
| 安装 | npm `bin` 指向 Node runner;postinstall 按 OS/arch 下载 release 归档 | `@goodvision/contentcloud` 只做平台选择、下载、校验和执行 |
| 安装 | npm `bin` 指向 Node runner;postinstall 按 OS/arch 下载 release 归档 | `@limecloud/contentcloud` 只做平台选择、下载、校验和执行 |
| 供应链 | GoReleaser 生成 `checksums.txt`;安装器校验 SHA-256、限制初始下载 host 和重定向次数 | V1 必须校验 checksum;正式发布增加签名和最终下载来源校验 |
| 命令设计 | 快捷命令、类型化 API 命令、通用 API 三层;根 help 内置 Agent quickstart | 只采用产品级 noun/verb 命令和 `schema`,拒绝任意 raw write |
| 输出 | JSON 成功写 stdout,结构化错误写 stderr,退出码与错误类别绑定 | 固定 success/error envelope,禁止日志污染 stdout |
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v1/prototype.html
Original file line number Diff line number Diff line change
Expand Up @@ -1699,7 +1699,7 @@ <h3>项目已在服务端创建</h3>
</div>
<div class="connect-pane is-active" data-connect-pane="new">
<div class="command-label"><span>在你自己的电脑运行</span><span class="state-pill">10 分钟 · 单次连接</span></div>
<div class="command-box"><code id="installCommand">npx --yes @goodvision/contentcloud@latest up --server-url https://app.contentcloud.cn --connect-key cck_7c3a91f80b42</code><button class="icon-button" type="button" id="copyInstallCommandButton" data-tooltip="复制安装命令" aria-label="复制安装命令"><i data-lucide="copy"></i></button></div>
<div class="command-box"><code id="installCommand">npx --yes @limecloud/contentcloud@latest up --server-url https://app.contentcloud.cn --connect-key cck_7c3a91f80b42</code><button class="icon-button" type="button" id="copyInstallCommandButton" data-tooltip="复制安装命令" aria-label="复制安装命令"><i data-lucide="copy"></i></button></div>
<div class="agent-prompt"><span>也可以把安装提示粘贴给当前 Codex 或 Claude Code 会话,由 Agent 检查环境并运行同一条 `contentcloud` 命令。</span><button class="button" type="button" id="copyAgentPromptButton"><i data-lucide="bot"></i>复制给 Agent</button></div>
<div class="connect-status" id="connectStatus"><i data-lucide="radio"></i><div><strong id="connectStatusTitle">等待电脑连接...</strong><small id="connectStatusDetail">服务端不会远程运行任何命令;它只等待本机 CLI 消费连接码并上报首个心跳。</small></div></div>
<div class="onboarding-actions"><button class="button" type="button" id="backToProjectButton"><i data-lucide="arrow-left"></i>返回项目信息</button><button class="button button-primary" type="button" id="simulateConnectButton"><i data-lucide="plug-zap"></i>模拟本机已运行</button></div>
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v2/06-local-workspace-and-publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
Web 显示一条可复制命令:

```bash
npx --yes @goodvision/contentcloud@latest init \
npx --yes @limecloud/contentcloud@latest init \
--server-url https://content.example.com \
--connect <one-time-code> \
--target all \
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v2/09-cli-mcp-and-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
## 3. 安装与首次初始化

```bash
npx --yes @goodvision/contentcloud@latest init --connect <one-time-code> ./project
npx --yes @limecloud/contentcloud@latest init --connect <one-time-code> ./project
cd ./project
contentcloud workspace doctor
```
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap/v2/prototype.html
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@
</div>
<div class="grid cols-2">
<section class="panel"><div class="panel-head"><h2>本机安装命令</h2>${status("代码 23:41 后过期","warn")}</div><div class="panel-body">
${cmd("npx --yes @goodvision/contentcloud@latest init --connect cc_init_JLGD_8K4M ./jinling-gudu")}
${cmd("npx --yes @limecloud/contentcloud@latest init --connect cc_init_JLGD_8K4M ./jinling-gudu")}
<div class="callout" style="margin-top:12px">初始化只创建本地项目文件并绑定云端项目。不会上传 raw 资料,也不会启动后台 Automation。</div>
<div class="actions" style="margin-top:14px"><button class="btn primary" data-action="mark-init">我已完成初始化</button><button class="btn" data-command="contentcloud workspace doctor">查看诊断命令</button></div>
</div></section>
Expand Down
39 changes: 39 additions & 0 deletions internal/app/connect_session_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package app_test

import (
"log/slog"
"testing"

"github.com/limecloud/contentcloud/internal/app"
"github.com/limecloud/contentcloud/internal/store/memory"
)

func TestConnectSessionCompletesOnlyAfterWorkspaceRegistration(t *testing.T) {
service := app.New(memory.New(), slog.Default())
session, err := service.Register(t.Context(), "connect@example.com", "long-enough-password", "Connect User", "Connect Tenant")
must(t, err)
actor, _, err := service.SessionActor(t.Context(), session.ID)
must(t, err)
project, err := service.CreateProject(t.Context(), actor, app.CreateProjectInput{BrandName: "Brand", ProductName: "Product", Channel: "douyin"}, "connect-project")
must(t, err)
connect, err := service.CreateConnectSession(t.Context(), actor, project.ID, "connect-session")
must(t, err)

device, err := service.ConnectDevice(t.Context(), app.ConnectDeviceInput{ConnectKey: connect.PlaintextConnectKey, Hostname: "connect-mac", Platform: "darwin", Arch: "arm64", Version: "test"})
must(t, err)
status, err := service.ConnectSession(t.Context(), actor, connect.ID)
must(t, err)
if status.State != "verifying" {
t.Fatalf("state after device connection = %q, want verifying", status.State)
}

workspaceActor, binding, err := service.WorkspaceActor(t.Context(), device.WorkspaceToken)
must(t, err)
_, err = service.RegisterWorkspace(t.Context(), workspaceActor, binding, "workspace_marketing_video", "2.0.0", []string{"codex"}, "workspace-register")
must(t, err)
status, err = service.ConnectSession(t.Context(), actor, connect.ID)
must(t, err)
if status.State != "connected" {
t.Fatalf("state after workspace registration = %q, want connected", status.State)
}
}
57 changes: 57 additions & 0 deletions internal/app/platform.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package app

import (
"context"
"strings"

"github.com/limecloud/contentcloud/internal/domain"
)

func (s *Service) PlatformOverview(ctx context.Context, actor Actor) (domain.PlatformOverview, error) {
if !actor.PlatformAdmin {
return domain.PlatformOverview{}, domain.Policy("PLATFORM_ADMIN_REQUIRED", "只有平台管理员可以访问系统后台", "联系系统管理员配置平台权限")
}
tenants, err := s.store.PlatformTenants(ctx)
if err != nil {
return domain.PlatformOverview{}, err
}
users, err := s.store.PlatformUsers(ctx)
if err != nil {
return domain.PlatformOverview{}, err
}
counts := domain.PlatformCounts{Tenants: len(tenants), Users: len(users)}
for i := range tenants {
if tenants[i].Status == "active" {
counts.ActiveTenants++
}
counts.Projects += tenants[i].ProjectCount
counts.OnlineDevices += tenants[i].DeviceCount
counts.ActiveRuns += tenants[i].ActiveRunCount
}
for i := range users {
_, users[i].IsPlatformAdmin = s.platformAdminEmails[strings.ToLower(users[i].Email)]
if users[i].Memberships == nil {
users[i].Memberships = []domain.PlatformUserMembership{}
}
}
return domain.PlatformOverview{Counts: counts, Tenants: tenants, Users: users, GeneratedAt: s.now().UTC()}, nil
}

func (s *Service) UpdatePlatformTenantStatus(ctx context.Context, actor Actor, tenantID, status, requestID string) (domain.Tenant, error) {
if !actor.PlatformAdmin {
return domain.Tenant{}, domain.Policy("PLATFORM_ADMIN_REQUIRED", "只有平台管理员可以修改租户状态", "联系系统管理员配置平台权限")
}
status = strings.ToLower(strings.TrimSpace(status))
if status != "active" && status != "suspended" {
return domain.Tenant{}, domain.Invalid("TENANT_STATUS_INVALID", "租户状态只能是 active 或 suspended")
}
if tenantID == actor.TenantID && status != "active" {
return domain.Tenant{}, domain.Policy("CURRENT_TENANT_REQUIRED", "不能停用当前管理会话所在租户", "先切换到其他有效租户")
}
tenant, err := s.store.SetTenantStatus(ctx, tenantID, status, s.now().UTC())
if err != nil {
return domain.Tenant{}, err
}
s.audit(ctx, actor, "", "platform.tenant_status_changed", "tenant", tenant.ID, requestID, map[string]any{"status": status, "tenant_name": tenant.Name})
return tenant, nil
}
Loading
Loading