# 内部小工具自助部署平台 - 产品需求文档

> **module**: internal-app-platform
> **doc_type**: PRD
> **status**: Draft
> **owner**: lijian.dai
> **approver**: chentao.jia
> **issue**: [#332](http://43.130.59.228/FFAIWorkspace/workspace/issues/332)
> **upstream_docs**: 无（PRD 是依赖链起点）
> **last_verified**: 2026-05-13
>
> **事实源**: 本文档定义 MVP 阶段业务范围与验收边界
>
> **环境与域名拓扑**（事实源；本文示例统一使用生产域名）：
>
> | 环境 | 通配域名 | IP | 角色 |
> |---|---|---|---|
> | 测试 | `*.apps.ffworkspace.test.faradayfuturecn.com` | `170.106.161.71` | 平台开发者自用，不暴露给员工 |
> | UAT  | `*.apps.ffworkspace.test.faradayfuture.com`    | `43.153.69.73`（与 FFOA UAT 共机）| 平台 staging 验证，不暴露给员工 |
> | 生产 | `*.apps.ffworkspace.faradayfuture.com`         | `43.130.6.44`（与 FFOA 生产共机）  | **员工唯一感知环境**——onboarding token / MCP / app 部署全部走这套 |
>
> 设计原则：员工只感知生产；测试/UAT 仅平台开发者 + IT 自用验证。员工 onboarding URL 和 MCP 端点写死生产域名，不需要在 Claude Code 端切换。
>
> **术语 placeholder**（本文示例使用，统一使用生产环境值）：
> - `apps.ffworkspace.faradayfuture.com` = 平台生产公网通配域名（员工 app 落点）
> - `https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp` = 远程 MCP server 端点 URL（前端 / Claude Code 不硬编码，由 token 颁发接口同时返回）
> - `https://ffworkspace.faradayfuture.com/internal-apps` = FFOA "我的 Apps" 接入页 URL
> - `<TOKEN>` = bearer token 明文（在 `claude mcp add` 命令示例中作为占位；员工实际值由 `https://ffworkspace.faradayfuture.com/internal-apps` 一键复制带入）

---

## 目标与问题

### 要解决的问题

非技术员工（运营 / HR / 财务 等）已经能用 Claude Code 几小时生成一个内部小工具的代码——展示页、数据收集表、自助查询页等。但代码生成出来之后**没地方放**：

- 自己电脑能跑、分享给同事只能截图、关电脑就没了
- 排开发同事帮忙部署违背 AI-first 初衷（AI 生成的工具反而要消耗工程师时间）
- 想沉淀几千到几万条数据，没有持久化的地方

结果：本来 AI 几小时能落地的内部小工具，**最后没落地，事情黄掉**。

### 解决方式（员工视角）

员工**全程只做两件事**：

1. 在 Claude Code 里对话写业务代码
2. 对 Claude Code 说"部署一下" → 拿到一个内网可访问的 URL

中间所有事情——建仓、push、构建、容器化、反代、域名、TLS、备份——员工**完全不感知**。

### 成功指标

MVP 验收（Phase 1 完成时必须满足）：

- 一个真实非技术员工 + 一个真实需求，从打开 Claude Code 到同事能在浏览器访问 ≤ **30 分钟**
- 全过程 **零工程师介入**（不需要任何开发同事点任何按钮、看任何日志）
- 数据持久化跨重启不丢
- 同事登录 FFOA（Azure Entra ID SSO）后内网可直接访问 URL

长期信号（3 个月后回看，决定是否扩大投入）：

- 活跃 app 数 ≥ 20
- 没有发生"AI 部署的 app 导致服务器故障"事件
- 命中硬退出条件（< 20 app / 一周内 ≥ 2 次 SSH 救火 / 自建脚本 > 500 行 bash）任一项时主动评估迁移到 Dokploy 或下线

---

## 功能边界

### In-scope（Phase 1 MVP）

- **部署接口**：OA HTTP API + **远程 MCP server**（Streamable HTTP transport，部署在 FFOA 侧）。Claude Code 通过远程 MCP 调用，员工电脑不安装任何 MCP 包，工具升级对员工完全无感
- **员工接入**：FFOA "我的 Apps" 页（已 Entra ID 登录态）一键生成 bearer token + 复制一行 `claude mcp add --transport http ...` 命令；员工在 shell 终端粘贴执行即完成接入，不下载文件、不改本地配置文件
- **代码托管**：Gitea organization `FFAIApps`；员工说"部署"时平台自动建远端仓库 + 颁发 5 分钟 TTL 的推送凭据，文件由 Claude Code 在员工电脑本地执行 `git push` 送达（详见 §核心约束 §部署文件传输路径）
- **运行时**：① **node**——`package.json` 有 `start` script，平台起容器；② **static**——根目录有 `index.html`、无 `start` script，平台 Caddy 直接 serve 静态文件、不起容器（资源占用接近零）。平台**自动判别**，员工不手工声明
- **URL 形态**：`<employeeSlug>-<app slug>.apps.ffworkspace.faradayfuture.com`（子域名隔离，让员工 app 内部相对/绝对路径与本地开发完全一致，员工不感知反代细节）
- **鉴权**：平台层 SSO 复用 FFOA 已集成的 **Azure Entra ID**（参考 `docs/standards/09-iam-security.md`），未登录用户访问任何 app URL 一律先跳 FFOA 登录
- **数据持久化**：约定路径 `/data/app.db`，平台自动挂载持久化卷，自动 Litestream 备份到对象存储
- **资源限额**：单 app 512M 内存 / 0.5 CPU，OOM 自动重启
- **更新部署**：员工再次说"部署"时，Claude Code 本地 `git push ffapp main` 把当前工作目录新 commit 推到原仓库 → Gitea webhook 触发自动重新构建上线
- **app 列表**：员工通过 MCP 工具或 Claude Code 对话可查询自己已部署的 app（名称 / URL / 状态）
- **销毁 app**：员工说"删掉这个 app"，平台停容器 + 归档 Gitea 仓库 + 保留数据 30 天供恢复
- **构建失败反馈**：MCP 返回结构化错误（构建日志摘要），Claude Code 可以基于日志帮员工继续改

### Out-of-scope（推迟到 V2 或更晚，已在 §V2 路线图记录）

- 部门白名单 / 应用级权限
- 配额升级（员工申请把 app 改成 1G / 1 CPU）
- 监控告警 / 慢请求统计 / 错误率看板
- Python 运行时
- 共享存储（多机调度的前置）
- 引擎可切换能力（撤退到 Dokploy / Coolify 的契约层）
- 完整运维 / 监控 / 部署 Web UI——MVP 仅提供最小 FFOA 接入页（F2.5，含 token 颁发 / 撤销 / 自己 app 列表），日常部署操作仍走 Claude Code 对话
- preview deployments / 多版本并存 / 灰度发布
- 应用之间通信 / 共享数据库
- 把外部用户引入这个平台（公网开放）

### 显式拒绝（永不做）

- **不让平台变成生产业务部署通道**：FFOA 的核心业务模块（审批、IAM、approval-engine 等）**不能**通过本平台部署。这是非技术员工自助小工具的平台，不是 prod 部署系统
- **不让员工 app 直连 FFOA 业务数据库**：任何 schema、任何方式（连接串 / 读副本 / ORM）都不允许。需要数据请走 CSV 导入或专门的内部 API
- **不允许在员工 app 容器里以 root 运行**：所有运行时强制非 root 用户

---

## 术语表

| 术语 | 说明 |
|------|------|
| app | 一个员工部署的小工具。一个员工可以有多个 app |
| 员工 | app 的所有者，等于首次部署者，不可转让（MVP） |
| OA HTTP API | 本平台后端业务接口（含 deploy / list / logs / env / destroy 等方法），由 MCP server 内部调用 |
| MCP server | 部署在 FFOA 侧、Streamable HTTP transport 的远程 MCP，Claude Code 通过 URL + bearer token 直连，本地不安装包 |
| 员工 token | FFOA 在 Entra ID 已登录态下颁发的 bearer，绑定 `employeeSlug`；可在 FFOA 页面 revoke / 重发 |
| employeeSlug | 员工标识，从 Entra ID `mailNickname` 规范化而来（小写 + `[a-z0-9-]`，超 20 字符走前 17 + SHA1 前 3 截断，详见 §核心业务约束），员工首次接入时入库后终身不变 |
| 仓库 | Gitea `FFAIApps` organization 下的代码仓库，部署时由平台自动创建 |
| `/data` 卷 | 平台为每个 app 自动挂载的持久化目录，员工只需要把 SQLite 文件写到 `/data/app.db` |
| 引擎 | 平台内部把员工代码跑起来的组件（MVP = Caddy + Docker + bash 自建组合） |

---

## 版本口径

本文档定义 **Phase 1 MVP** 的业务边界。代码与本文冲突以本文为准。Phase 2 及之后范围另起 PRD（不在本工单 scope）。

---

## 实施阶段（概览）

- **Phase 0 PoC**（1-2 周）：找 1 名真实非技术员工 + 1 个真实小工具，端到端跑通 Claude Code → URL
- **Phase 1 MVP**（3-4 周）：替换为正式 API + MCP + 完整部署脚本 + 备份
- **Phase 2**：见下方 §V2 路线图，本工单 scope 外

> 详细组件划分、退出条件、关键流程图见 [03-architecture.md](./03-architecture.md)。

---

## 核心业务约束

- **员工对话即操作**：所有员工部署 / 运维动作通过 Claude Code 对话表达；FFOA "我的 Apps" 页仅用于 token 颁发 / 撤销 / 查看 app 列表等 onboarding 与管理动作，不承担日常部署流程
- **MCP 接入方式（远程 Streamable HTTP）**：MCP server 部署在 FFOA 服务器侧，员工电脑不安装 MCP 工具包。员工首次接入仅一步——FFOA 页面复制一行 `claude mcp add --transport http ffoa-apps https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp --header "Authorization: Bearer <TOKEN>"` 在 shell 终端执行即可（**不是**在 Claude Code 会话内输 `/mcp add ...`——那是不存在的 slash 命令；HTTP MCP 必须显式 `--transport http`，否则默认 stdio 会把 URL 当本地命令）。MCP server 升级 / bug 修复 / 工具增删对员工完全无感（下一次对话即生效）
- **员工身份与鉴权**：MCP → OA API 调用携带平台颁发的 bearer token；token 由员工在 FFOA 浏览器侧（Entra ID 已登录态）一键生成，server 端绑定到 `employeeSlug` + audit 行；员工可在 FFOA 页面随时 revoke / 重发，IT 管理员可强制吊销任意员工的 token
- **token 有效期**：默认 **90 天**；到期前 **7 天**起 MCP 调用在结构化返回里附 `warning` 字段，Claude Code 据此提示员工"token 还有 N 天过期，去 `https://ffworkspace.faradayfuture.com/internal-apps` 续期"（提示文案**必须包含 FFOA 续期页直链**，避免员工 google 找入口）；过期后 token 立即失效，下次调用返回结构化 `expired` 错误并引导到同一 URL
- **员工标识（employeeSlug）规范化**：源自 Entra ID `mailNickname`，规则为：① 全部转小写；② `[^a-z0-9-]` 字符替换为 `-`；③ 连续 `-` 合并为单个；④ 去掉首尾 `-`；⑤ **若长度 > 20 字符 → 取前 17 + 原 `mailNickname` 的 SHA1 前 3 位**（保证唯一性 + 跨次接入稳定）；⑥ 规范化后仍撞已存在的 slug 时加数字后缀。`employeeSlug` 在员工首次接入时一次性入库后**终身不变**，调岗 / 换部门 / Entra mailNickname 后续变更均不跟随。**不引入部门 / 工号等可变维度作前缀**
- **app 名生成规则**：app 名由 Claude Code 在部署时根据当前业务上下文（package.json 名称 / 代码功能 / 员工对话语义）自动生成合规 slug，员工不手工命名；不满意可对话改名让 Claude Code 重新生成。**本地目录名平台不感知**。slug 校验：`[a-z0-9-]`、3-22 字符、首尾非 `-`、不撞**保留字白名单**（`admin` / `api` / `auth` / `callback` / `health` / `login` / `logout` / `mcp` / `oauth` / `sso` / `static` / `assets` / `_internal` / `internal-app` / `apps` / `www`）；同 `employeeSlug` 内重名 → 走增量部署；不同员工天然靠前缀隔离
- **Gitea 仓库命名**：所有员工 app 仓库归属 Gitea organization **`FFAIApps`**，命名格式 `FFAIApps/<employeeSlug>-<app slug>`（示例：`FFAIApps/zhang-san-birthday-reminder`），与业务仓库 organization 完全隔离
- **子域名长度约束**：DNS label 上限 63 字符；为给前缀 / 后续扩展（如 `preview-` 前缀、`-v2` 灰度后缀）留 buffer，约束 **`employeeSlug` ≤ 20 字符**、**`app slug` ≤ 22 字符**（含中间连接符共 ≤ 43 字符，距 63 留 20 字符 buffer）；规范化超长时尾部截断（employeeSlug 截断规则见上一条；app slug 由 Claude Code 在生成时即遵守上限）
- **部署文件传输路径**：**远程 MCP 不读员工本地文件**（物理上拿不到）。员工本地 → Gitea 唯一传输方式是 **git push 协议**。MCP `deploy_prepare` 工具返回**结构化 JSON**（**不返回 shell 命令字符串**，避免 server 端注入风险）：
  ```
  {
    repoUrl: "<gitea>/FFAIApps/<employeeSlug>-<app>.git",
    pushCredential: { token, expiresAt },   // 5min TTL, scope=该仓库 write:repo
    branch: "main",
    suggestedCommitMessage: "deploy <timestamp>"
  }
  ```
  Claude Code 在本地用自身 Bash 工具**自行拼装**并执行 git 命令（git init / add / commit / remote add / push），文件经 git 原生协议传到 Gitea。**不做 bundle / HTTP 上传等替代通道**——简化架构，单一传输逻辑。员工电脑未装 git 时，由接入页 / Claude Code 提示员工安装（见 §假设）
- **本地 .git 状态处理**：Claude Code 在执行 `git push` 前先探测员工目录 `.git`：① 无 → `git init`；② 有但无 `ffapp` remote → 加平台 remote 用平台凭据；③ 有 `ffapp` remote 已指向本平台同 app 仓库 → 走增量部署；④ 有其他 remote（员工的私人 GitHub 等）→ 不覆盖，新增 `ffapp` remote 与之并存
- **VPN 预探测（部署专用）**：Claude Code 在 `git push` 前先用 `git ls-remote` 探一下 Gitea 连通性（Gitea MVP 在内网 `43.130.59.228`，需要 VPN）。不通 → 友好提示员工连 VPN 后说一声"继续部署"。**注意**：此步**只**在部署链路触发，访问 app URL / 调用其它 MCP 工具（logs / env / list / destroy）均**不要求** VPN（公网可达）
- **部署 staging 对员工无感**：员工是非技术用户（HR / 运营 / 财务），不应理解"敏感凭据 / 大文件"概念。**三层防护，全部对员工透明**：
  - **Layer 1（默认 `.gitignore` 注入，Claude Code 本地）**：员工目录若无 `.gitignore`，Claude Code 自动写入默认模板，覆盖所有常见敏感 / 无用文件：`node_modules/` / `.env` / `.env.*` / `*.pem` / `*.key` / `id_rsa*` / `*.p12` / `*.pfx` / `*.log` / `.DS_Store` / `dist/` / `.cache/` / `.git-credentials` / 单文件 > 50MB 的常见数据格式（`.csv` / `.zip` / `.tar.gz` / `.iso` / `.sqlite`，> 50MB 阈值由 Claude Code 本地 `find -size` 检测后追加到 `.gitignore`）
  - **Layer 2（友好摘要，Claude Code 对话）**：执行 `git push` 前向员工告知一句"准备部署 N 个文件，已自动忽略：node_modules（依赖包） / data/big.csv（数据太大）"——**只告知不询问**，没有"是否继续"按钮
  - **Layer 3（Gitea pre-receive hook 兜底）**：服务端硬拒绝单文件 > 100MB / 含 `.git/` 嵌套目录 / 可执行 binary（`.exe` / `.dll` / `.so`）/ Layer 1 黑名单（防员工手工 `git add -f` 绕过）。hook 触发时返回结构化错误 → Claude Code 自动把违规文件加进 `.gitignore` 重试一次 → 仍失败才向员工出声"部署失败：X 文件无法上传"。**绝大多数员工永远看不到 Layer 3 的存在**
- **不做内容扫描**：MVP 阶段**不做 secret pattern / API key / 高熵字符串等代码内容扫描**。理由：① SSO 闸门把员工 app 实际内容挡在登录后面，公网未登录访问者只能看到 Entra ID 登录页，泄漏面被压缩；② 非技术员工读不懂"建议改成 env 变量"这种 nudge，徒增摩擦；③ 真要做也是 V2 IT-Admin 侧的合规扫描，不在员工部署链路里
- **首次部署 = 建仓 + push**：`deploy_prepare` 时若仓库不存在，OA API 在 Gitea 建远端仓库（`FFAIApps/<employeeSlug>-<app slug>`，初始可见性 `internal`，owner 写权限，IT-Admin 只读 + 强制操作权限）→ 颁发推送凭据 → Claude Code 本地 `git push` 完成首次提交
- **再次部署 = 覆盖式 push**：后续部署颁发新凭据 → Claude Code 本地 `git add . && git commit && git push ffapp main`（force 与否由平台凭据 scope 限定），新增一个 commit
- **runtime 自动判别**：① 根目录有 `package.json` + `start` script → **node** runtime：平台起容器，注入 `PORT` 环境变量，app **必须**监听 `process.env.PORT`（否则健康检查失败）；② 否则根目录有 `index.html` → **static** runtime：Caddy 直接 `file_server`，无容器进程、无端口约定、无 OOM 风险；③ 均无 → 拒绝部署。**员工不需要手工声明 runtime**，Claude Code 生成什么平台就跑什么
- **资源限额是硬约束**：所有容器强制 `--memory=512M --memory-swap=512M --pids-limit=200 --cpus=0.5 --read-only --tmpfs /tmp:50M`，不可豁免
- **数据约定**：员工 app 若需持久化，必须写到 `/data/app.db`（SQLite）。平台只对这条路径做备份；其他写入位置（容器 fs）随容器重建消失
- **失败保护**：构建 / 启动失败不切换现有版本，保留上一成功版本继续服务
- **审计不可绕过**：token 颁发 / 撤销 / 建仓 / 部署 / 销毁 / env 修改 必须写 audit-system 日志

---

## 非技术用户 UX 原则

> 本节是平台所有员工触点的设计宪法，**优先级高于功能完整性**。

目标用户是 HR / 运营 / 财务等非技术员工，他们：
- **不懂** "git" / "remote" / "API key" / "敏感凭据" / "环境变量"等术语
- **不应被要求做安全决策**（如"是否忽略 .env"）
- **只关心两个结局**：URL 拿到 / 出错了

### 原则 1: 默默处理，不问员工

所有"该不该忽略某文件 / 该不该改成更安全写法 / 该不该装某个工具"类决策，**全部由平台 / Claude Code 自行决定**，不弹问题给员工。员工对话里只能出现两类信息：

- ✅ "好了，URL 是 X，发给同事就能用"
- ❌ "出错了，原因 Y，我帮你重试 / 修复"

### 原则 2: 告知不询问

对员工有知情权但无决策权的事项（如"哪些文件被自动忽略"），用一句话**告知**，不出现确认按钮 / "是否继续"提示。员工不需要点任何东西就能让流程往前走。

### 原则 3: 不做安全 nudge

不做"代码里检测到 API key，建议放环境变量"这类好心建议——非技术员工读不懂，反而成为阻塞。MVP 平台靠 SSO 闸门拦未登录访问者，泄漏面被压缩，把代码合规扫描推到 V2 IT-Admin 侧的离线流程，不进员工部署链路。

### 原则 4: 错误必带"我帮你处理"

任何错误返回必须由 Claude Code 翻译成"出错原因 + 建议下一步操作"自然语言，并主动尝试一次自动修复（如 Gitea hook 拒绝大文件 → 自动加 `.gitignore` 重试）。员工最多看到一次错误，不会看到原始错误码 / 堆栈。

### 原则 5: 配置零项

员工部署任何 app 都**不需要填任何表单字段** —— 无 runtime 选择 / 无内存配额 / 无端口 / 无健康检查路径。所有这些由平台默认值 + 自动判别覆盖。员工想"调"什么都直接对话 Claude Code 修代码。

---

## 功能清单

### F1: 部署（首次 + 增量）

| 功能点 | 优先级 | 说明 |
|--------|--------|------|
| F1.1 MCP `deploy_prepare` 工具 | P0 | 员工说"部署" → Claude Code 调用，传 app 名 → MCP 返回结构化 JSON（`repoUrl` / `pushCredential` (5min TTL, scope=仓库 write) / `branch` / `suggestedCommitMessage`，不含 shell 命令字符串）；**MCP 不接收本地文件**，文件由 Claude Code 后续用 Bash 跑 git push 直接送 Gitea（详见 §核心约束 §部署文件传输路径） |
| F1.2 首次部署建仓 | P0 | `deploy_prepare` 时 OA API 调 Gitea REST API 建远端仓库（含 pre-receive hook 安装）+ 颁发首次推送凭据；本地 `git init` / `git add` / `git commit` / `git push` 由 Claude Code 通过 Bash 工具完成 |
| F1.3 增量部署 | P0 | 同 app 名再次 `deploy_prepare`：OA API 复用现有仓库 + 颁发新凭据 → Claude Code 本地 `git commit && git push ffapp main` → Gitea webhook 触发后续部署 |
| F1.4 构建 | P0 | **node runtime**：基础镜像统一 **`node:22-alpine`**（LTS，平台固定，**不读 `package.json` 的 `engines.node`**——避免员工 app 把镜像版本当配置项漂移），按 `package.json` 装依赖 + 启容器（强制资源限额 + 网络隔离）；**static runtime**：直接发布到 Caddy 静态目录，无构建 |
| F1.5 健康检查 | P0 | **node runtime**：启动后轮询 `:$PORT/`，**HTTP status < 500 即通过**（不强求 2xx——SPA 根路径常返回 302/404 也算正常）；超时 **3 秒** / 间隔 **2 秒** / 重试 **30 次**（共 ~60 秒预算）；全部失败 → 标记部署失败、保留旧版本、把最后 3 次响应摘要回写给 Claude Code。**static runtime**：检查 `index.html` 文件存在 + Caddy reload 成功即可 |
| F1.6 失败保护 | P0 | 构建 / 启动失败保留上一版本，返回结构化错误日志给 Claude Code |
| F1.7 部署完成返回 URL | P0 | MCP 返回 `{ url, status, deployedAt }`，Claude Code 直接把 URL 转告员工 |

### F2: 运维（员工自助）

| 功能点 | 优先级 | 说明 |
|--------|--------|------|
| F2.1 MCP `logs` 工具 | P0 | 查近 100 行容器日志，Claude Code 可基于日志帮员工调试 |
| F2.2 MCP `env` 工具 | P0 | 设置 / 查看 app 的环境变量（如第三方 API key）。**写操作触发自动滚动重启**：复用现有镜像 + 新 env 启新容器 → 健康检查通过切流量 → 停旧容器（与"再次部署"同流程但跳过 build，~5 秒级零停机）。**员工不需要再 `deploy`** |
| F2.3 MCP `list` 工具 | P0 | 列出员工自己的**活跃 app**（名称 / URL / 状态 / 最后部署时间）。默认不含已销毁 app；如需查看 30 天恢复期内的销毁记录，调用方可传 `include=destroyed`（FFOA 接入页 §05 即用此参数） |
| F2.4 MCP `destroy` 工具 | P0 | 销毁 app：停容器 + 归档 Gitea 仓库 + 数据保留 30 天 |
| F2.5 FFOA 接入页 | P0 | "我的 Apps" 页提供：① 一键生成 / 撤销 / 重发 bearer token（Entra ID 已登录态）；② 生成时把完整 `claude mcp add --transport http ...` 命令（含 token 明文）一次性写入剪贴板，token 明文**永不渲染到 DOM**（详见 05 §2.1 安全模型）；③ 自己已部署 app 列表（详细 UI 规格见 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md)） |
| F2.6 我的活动（事件流自读） | P1（Phase 1.5 后端，UI Phase 2.0） | 员工查看自己 app 的全部生命周期事件（deploy / env_set / token / 被 admin 操作），含 admin 强制操作的 reason；后端 API `GET /me/events` 已就绪 |

### F3: 访问与鉴权

| 功能点 | 优先级 | 说明 |
|--------|--------|------|
| F3.1 SSO 反代拦截 | P0 | 所有 `*.apps.ffworkspace.faradayfuture.com/*` 请求先过 SSO，未登录跳登录页（wildcard 匹配所有员工子域名） |
| F3.2 注入用户信息 header | P0 | 已登录请求带 `X-User-Email` 等 header 给员工 app，员工要用就用 |
| F3.3 子域名反代 | P0 | Caddy 单一 wildcard host 块按 `Host` 提取 `<employeeSlug>-<app slug>`，转发到 node runtime 同名容器 `:$PORT/` 或 static runtime 静态目录；**新增 app 不改 Caddy 配置**（靠 Docker network 内部 DNS 自动解析） |
| F3.4 404 友好页 | P1 | app 不存在 / 已销毁 / 构建失败 时显示友好页 |

### F4: 平台运维（IT 管理员）

| 功能点 | 优先级 | 说明 |
|--------|--------|------|
| F4.1 所有 app 列表（CLI / API） | P0 | 跨员工查看所有 app，含状态、最后部署、资源占用 |
| F4.2 强制停用 | P0 | 紧急下架某个 app（违规 / 资源失控），不删数据 |
| F4.3 强制销毁 | P1 | 覆盖员工权限删除 app |
| F4.4 部署 / 审计历史查询 | P1 | 任意 app 的部署记录与 audit 日志 |
| F4.5 全生命周期事件流 | P0（Phase 1.5） | 跨员工 / 跨 app 的状态变更事件单一来源，含 token / deploy / env / destroy / admin 操作；admin 可按 employee / app / 类型 / 时间过滤；详见 [06-data-model §2.6](./06-data-model.md#26-internal_app_events--全生命周期事件流admin-视图--员工活动) |

> MVP 阶段 F4.1-F4.4 用 CLI / 直接 API 调用即可，不做 Web UI；F4.5 事件流先做后端 + admin/me 读取接口（Phase 1.5），UI 在 Phase 2.0 跟进。

### F5: 备份与恢复

| 功能点 | 优先级 | 说明 |
|--------|--------|------|
| F5.1 Litestream 持续备份 | P0 | 每个 app 的 `/data/app.db` 持续复制到对象存储 |
| F5.2 销毁后数据保留 30 天 | P0 | `destroy` 后保留对象存储中的备份 30 天，过期自动清理 |
| F5.3 数据恢复 | P1 | MVP 阶段为 IT 管理员手工流程，员工提工单即可；V2 再考虑自助 |

---

## 角色权限矩阵

| 功能 | 普通员工（自己的 app） | 普通员工（他人 app） | IT 管理员 |
|------|:---:|:---:|:---:|
| 部署 app（首次 + 增量） | ✅ | ❌ | ✅ |
| 查 logs / env / list | ✅ | ❌ | ✅（任意 app） |
| 修改 env | ✅ | ❌ | ✅ |
| destroy app | ✅ | ❌ | ✅ |
| 访问 app URL（运行中态）| ✅ | ✅ | ✅ |
| 强制停用 / 销毁他人 app | ❌ | ❌ | ✅ |
| 看所有 app 列表 | ❌ | ❌ | ✅ |

> "访问 app URL"对所有 FFOA Entra ID 已登录员工开放（含他人部署的 app），这是平台"内部协作"的基本前提；MVP 不做应用级 ACL（V2 部门白名单见路线图）。

> 权限基于 RBAC，权限码规划放到 状态 3 `07-api.md` 中详定。

---

## 访问网络模型

**MVP 锁定：双轨模型**——访问 app 走公网，部署代码走 VPN。

### A. 访问 app URL（高频，居家友好）= 公网 + SSO

| 场景 | 动作 | 备注 |
|---|---|---|
| 公司内办公 | 直接访问 `<employeeSlug>-<app slug>.apps.ffworkspace.faradayfuture.com` → 已 SSO 登录态自动延伸 → 进入 app | 无感 |
| 居家办公 | 同上，**不需要 VPN** | SSO 未登录 → Caddy 跳 Entra ID 登录页 |
| 出差 / 客户场地 | 同上 | 同上 |

> 适用对象：**所有访问者**（部署员工自己 + 同事 + 任何 FFOA Entra ID 已登录员工）。

### B. 员工部署代码（低频，仅部署瞬间）= MCP 公网 + git push VPN

| 步骤 | 网络要求 | 原因 |
|---|---|---|
| Claude Code 调 MCP（`deploy_prepare` / `logs` / `env` / `destroy` / `list_apps`）| **公网可达**，无 VPN | MCP server 与 FFOA 主站同 backend，部署在 `https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp` |
| Claude Code 本地 `git push` 到 Gitea | **需要 VPN**（Gitea 在内网 `43.130.59.228`）| Gitea MVP 期不暴露公网，部署是低频操作 |

> 适用对象：**只有正在部署代码的员工**。同事 / 浏览者 永远走 A 路径，不需要 VPN。

#### 部署时的 VPN 摩擦缓解

Claude Code 在执行 `git push` 前**先用 `git ls-remote` 探测 Gitea 连通性**：
- 通 → 直接 push
- 不通 → 友好提示："Gitea 还连不上，居家办公请先连公司 VPN，然后说一声'继续部署'"
- 员工连上 VPN 后说"继续部署" → Claude Code 重试 push 即可

> **UX 设计原则**：不要让员工"为了访问自己的 app 链接"连 VPN（高频痛点），只让员工"为了推代码"连 VPN（低频，且每次只是部署前的 30 秒）。同事访问、自己看自己 app、改 env、查 logs、destroy 全部公网可达。

**为什么走公网**（与之前"仅内网"草案不同）：
- FFOA 主站已经是"公网 + SSO"模式，员工不需要学两套访问方式
- 居家 / 出差去 VPN 的摩擦 > 公网暴露面带来的攻击风险（SSO 闸门 + Caddy 反代层已经把员工 app 实际内容挡在登录后面，公网爬虫扫不到）
- 公网域名归运维统一管理 + 公网 wildcard 证书（Let's Encrypt DNS-01）已经是公司标配

**公网带来的额外防护**（必须在 MVP 前实装）：
- **Caddy 层 rate-limit**：登录页 + SSO 回调按 IP 限流（默认 60 次/分钟），防爆破
- **SSO 失败告警**：同 IP / 同用户 5 分钟内 ≥ 10 次 SSO 失败 → 触发 IT 告警邮件
- **Caddy 版本跟进**：Caddy 安全补丁纳入运维巡检（具体由运维定义节奏）
- **强制 audit**：所有 SSO 登录失败 / 成功事件写 audit-system，便于事后审计

**风险权衡**（与 §不做内容扫描 的关系）：
- 之前"仅内网隔离泄漏面有限"的理由 → 改为"SSO 闸门隔离泄漏面"，理由强度略降但仍成立（员工 app 实际内容只对**已登录公司员工**可见，公网未登录访问者只能看到 SSO 登录页）
- 内容扫描决策**不变**——非技术员工读不懂 nudge + 推到 V2 IT-Admin 侧的离线扫描，与公网/内网无关

---

## 员工生命周期联动

平台员工身份直接绑定 Entra ID，状态变更必须自动联动：

| 触发事件 | 平台行为 |
|---|---|
| Entra ID disable（离职 / 调离 / 长期休假）| ① 该员工所有 token 立即失效（下一次 MCP 调用直接 `expired`）<br/>② 已部署 app 容器**保留运行 30 天**（同事还在用）<br/>③ 30 天到期后自动停容器，数据按 §F5 走"保留 30 天"通道（合计最长 60 天可恢复）<br/>④ Gitea 仓库 transfer 给 Gitea organization **`FFAIApps-Archive`**（专用归档 org，所有人 read-only；归档不删，留作合规与可追溯）；接管账号为 IT-Admin RBAC 角色映射的 Gitea team，可强制操作但默认不修改<br/>⑤ 全程写 audit-system |
| Entra ID re-enable（误操作 / 离职复职） | ① 30 天内复职：app 自动重启 + 仓库 transfer 回员工<br/>② 超过 30 天：员工需手工重部署（数据可能已清理） |
| Entra mailNickname 变更 | `employeeSlug` **不变**（首次接入入库后冻结），URL 与仓库名稳定不漂移 |

> 离职窗口 30 天的设置见 §F5.2 "销毁后数据保留 30 天" 同步策略；如需调整需 IT + HR 共同评估。

---

## 边界规则

> 本节定义 AI agent 实现本模块时的行为边界。

### Always Do（始终执行，无需确认）

- 所有 MCP 工具调用先校验 bearer token 有效性 + 解出 `employeeSlug`；token 失效 / 已撤销 → 结构化错误回 Claude Code 引导员工去 FFOA 重新生成
- 部署 / 销毁 / env 修改 写 audit-system
- 容器创建时强制带齐 `--memory=512M --memory-swap=512M --pids-limit=200 --cpus=0.5 --read-only --tmpfs /tmp:50M --user <non-root>`
- 部署脚本 `set -euo pipefail` + `trap ERR` + `flock` 串行化同一 app 的部署
- TLS 用 DNS-01 challenge 一次签 wildcard 证书，禁用 Caddy on-demand TLS
- SQLite 备份用 Litestream，不用 `cp` / `rsync`（WAL 模式直接复制会损坏）
- 部署失败时保留上一成功版本，新版本不切流量
- git push 凭据 TTL 固定 **5 分钟**，scope 限定到**单仓库 + write 权限**——颁发逻辑不可豁免；过期凭据被 Claude Code 用到时 OA API 返回 `credential_expired` 让客户端重新 `deploy_prepare`

### Ask First（需确认后执行）

- 任何"扩大运行时支持"的诉求（加 Python / Bun / Deno / Go 等）——属于 V2 范围，需重新立项
- 给员工 app 提供 FFOA 业务数据访问能力（破坏 In-scope 显式拒绝）
- 把任何 app 暴露到公网
- 修改平台资源限额硬约束（任何 app 想要 > 512M / > 0.5 CPU）
- 增加共享存储 / 多机调度

### Never Do（硬性禁止）

- 不得让员工 app 直连 FFOA 业务数据库（任何 schema、任何方式）
- 不得在容器内以 root 运行员工代码
- 不得跳过资源限额（即使员工说"这个 app 我需要更多"）
- 不得让员工 app 监听 80/443 之外的对外端口（一律走 Caddy 反代）
- **容器网络隔离**：每个 node app 容器独占一个 network namespace，**禁止 app 之间互通**；出口允许访问公网（用于调 OpenAI / 三方 API），**禁止**访问 RFC1918（`10.0.0.0/8` / `172.16.0.0/12` / `192.168.0.0/16`）与公司内部域名（用 iptables / Docker network 实现）
- 不得通过本平台部署 FFOA 生产业务功能
- 不得在没有员工授权的情况下读取 / 下载 / 查看 app 内 SQLite 数据（IT 管理员只能停用 / 销毁，不能"看里面数据"）
- 不得颁发**跨仓库 / 跨员工 / 长 TTL（> 5 分钟）/ admin scope** 的 git 凭据——`deploy_prepare` 凭据只能匹配当前 `employeeSlug` 拥有的目标仓库 + `write:repo` 单一 scope；任何凭据复用 / 凭据加权 / 凭据持久化都属违例

---

## V2 路线图（已显式记录，不在 MVP scope）

| 项 | 说明 | 触发条件 |
|---|------|---------|
| 部门白名单 | 员工可设"这个 app 只给 HR 部门访问"，靠 SSO 中的部门归属判断 | MVP 上线后第一批员工提出 |
| 配额升级 | 员工通过 MCP 申请 `--memory=1G --cpus=1.0`，平台按规则自动批 / 走审批 | 出现真实 app 撞 OOM 且业务确实需要 |
| 监控告警 | Grafana 看板 + 错误率 / 重启次数告警 | **app 数 ≥ 10**（先于 §成功指标 ≥ 20 的扩规阈值，确保监控能力先于规模到位）或出现首次"app 挂了 IT 才发现" |
| Python 运行时 | 引入 Python + uv 工具链 | 出现真实 Python 需求（数据处理类） |
| 共享存储 | NFS / 对象存储挂载，支持 app 跨机迁移 | 单机资源不够、需要多机调度 |
| 引擎可切换 | 把"自建 Caddy + Docker + bash"抽象成 engine plugin，可平滑切到 Dokploy | 命中硬退出条件之一 |
| 数据恢复自助 | 员工通过 MCP `restore` 直接恢复最近 30 天的备份 | F5.3 手工流程频次 ≥ 每周 1 次 |
| 完整 Web UI | 含部署 / 日志 / env 修改的网页操作面板（超出 MVP 接入页的范围） | 员工反馈 Claude Code 不在身边时无法运维 |

---

## 风险与假设

### 假设

- FFOA 已集成 Azure Entra ID SSO（事实，见 `docs/standards/09-iam-security.md`），本平台直接复用，无需新接 SSO
- 服务器单机资源足够 MVP 期：按 25 个 node app 估算，平均负载 ≈ 0.1 CPU + 200MB / app（员工小工具大多 idle）= 2.5 CPU + 5GB，**8C16G 容忍峰值**；静态 app 几乎不占运行时资源；CPU 是上限 quota 不预留，不会真的撞 12.5C（25 × 0.5）。app 数 ≥ 20 时复核此假设
- 公网域名 `apps.ffworkspace.faradayfuture.com` 由运维负责申请 + 公网 DNS NS + 公网 wildcard TLS 证书（Let's Encrypt DNS-01 challenge）
- 现有 Gitea 实例可承载新增的"员工 app 仓库"（命名规则需要约定，避免和业务仓库混在一起）
- 现有 audit-system 接口稳定，MVP 期不改
- **员工电脑装有 git**（Mac / Linux 默认带；Windows 装 Git for Windows）。Claude Code 用户群基本满足。首次接入时若 `git --version` 失败 → 接入页 / Claude Code 给员工对应平台的下载链接，员工自行安装后重新 onboard。**不做自动安装 / 不做 bundle 降级通道**——架构保持单一传输路径，初次摩擦换长期维护简单
- **Gitea v1.26.1**（当前生产部署版本，`http://43.130.59.228` 实测）支持 fine-grained OAuth token：scope 可限定到单仓库 + write 权限 + 自定义 expiration。本平台凭据颁发逻辑直接依赖这套 API；Gitea 后续升级保持向前兼容即可，未来若发生迁移 / 大版本回退（极不可能）需重新评估

### 风险

- **PoC 找不到真实员工 / 真实需求**：MVP 验收的核心锚点。Phase 0 必须先解决，否则不进 Phase 1
- **Claude Code 生成的 Node 代码质量参差**：可能频繁构建失败 → 员工挫败。需要在 MCP 错误返回里给出结构化、AI 友好的日志摘要，让 Claude Code 能接着帮员工修
- **数据自负的法务边界**：员工把同事敏感数据存到 app 里出泄漏，平台责任边界需法务确认（Phase 1 上线前明确，PRD 阶段不阻塞）
- **bash 部署脚本膨胀**：自建路线的最大失败模式。硬退出条件之一是"脚本 > 500 行"，命中即评估迁 Dokploy
- **Claude Code 远程 MCP 公网可达性**：Streamable HTTP transport 通过公网 `https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp` 暴露，可能被公司代理 / 浏览器 TLS 中间件 / 客户端运行环境拦截影响。**Phase 0 PoC 的第一件事**就是真实办公电脑实测此链路。不通则 **Phase 0 终止并重新评估方案**（不引入本地 npx 包等替代通道——见 §假设"不做自动安装 / 不做 bundle 降级"统一原则）
- **Gitea `git push` 在员工电脑可达性（VPN 通道）**：员工电脑**连 VPN 后**访问 Gitea 内网 IP `http://43.130.59.228`。VPN 客户端 / 公司证书链 / git 代理设置可能影响。Phase 0 PoC 同步验证。**注意**：Gitea 不挂公网是有意设计——部署是低频操作 + 代码 push 比 app 访问敏感度高，VPN 增量摩擦可接受
- **公网暴露面**：平台改走公网后，攻击面从"VPN 内网"扩展到"公网"。SSO 闸门 + Caddy rate-limit + SSO 失败告警是主要防护手段（见 §访问网络模型）。Caddy 自身漏洞要纳入运维巡检节奏

---

## 关键依赖

| 依赖 | 集成点 |
|---|---|
| Azure Entra ID（经 FFOA iam） | 复用 FFOA 已集成的 SSO，员工 FFOA 登录态延伸到本平台；Caddy 层校验 session 后注入 `X-User-Email` header 到员工 app |
| Gitea | 员工 app 仓库托管；MVP 通过 Gitea REST API 建仓 + 装 pre-receive hook + 颁发 fine-grained OAuth push 凭据；实际 `git push` 由员工电脑 Claude Code 本地执行（平台不代 push） |
| audit-system | 部署 / 销毁 / env 修改 写 audit 日志 |
| 服务器层（运维） | Caddy 反代规则、Docker daemon、wildcard TLS 证书、对象存储桶 |
| Claude Code（员工侧） | MCP 工具分发到员工的 Claude Code 配置，员工通过对话调用 |

---

## 待定项

### 已敲定（2026-05-13）

- ✅ bearer token 形态 = **opaque** `ffoa_<32 base32>`，SHA256 哈希入库 — 见 [07-api.md §8.2](./07-api.md#82-token-形态--opaque非-jwt)
- ✅ MCP server 路径 + transport = `POST /api/v1/internal-apps/mcp` + **Streamable HTTP** — 见 [07-api.md §8.1](./07-api.md#81-mcp-transport--streamable-http非-sse)
- ✅ 权限码命名 = `internal-app:deploy` / `internal-app:token:manage` / `internal-app:admin` — 见 [07-api.md §1.2](./07-api.md#12-权限码rbac)
- ✅ Schema 文件 + 表设计 = `backend/prisma/schema/platform_internal_apps.prisma`，5 张表 — 见 [06-data-model.md](./06-data-model.md)
- ✅ FFOA token 续期页 URL = `https://ffworkspace.faradayfuture.com/internal-apps` = `/internal-apps`（与"我的 Apps"页同 URL，banner 引导续期）— 见 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md)
- ✅ Gitea webhook secret = 单一 secret + organization-level webhook — 见 [07-api.md §8.4](./07-api.md#84-gitea-webhook-secret--单一-secret组织级-webhook)
- ✅ 单员工 app 配额 = 默认 20（env 可调）— 见 [07-api.md §8.5](./07-api.md#85-单员工-app-配额--20可调)

### 也已敲定（2026-05-13，本轮新增）

- ✅ 公网域名 = **`apps.ffworkspace.faradayfuture.com`**（与 FFOA 主站同根域，运维已就绪）
- ✅ 网络模型 = **双轨模型**：访问 app URL = 公网 + SSO（无 VPN），部署代码 = MCP 公网 + git push 走 VPN — 见 §访问网络模型
- ✅ 法务认可 = **直属领导已批准**，跳过法务流程
- ✅ Phase 0 PoC 目标 = **HR 员工 + 生日提醒小工具**（具体人选由 HR 团队内部分配）
- ✅ 对象存储 = **自建 MinIO（docker compose）**，桶名 `internal-apps-backups`，挂载 `/srv/minio/data`；运维负责起容器 + 配 Litestream 凭据
- ✅ KMS 集成 = **优先复用 `platform_iam` 现有 KMS 客户端**；若 iam 无现成客户端，MVP 期降级为 `INTERNAL_APP_ENV_MASTER_KEY` env（AES-GCM master key，运维 secret 管理），V2 再接云 KMS

### Phase 1 实施前仍需外部输入

- [x] **Phase 0 必验**：Claude Code Streamable HTTP MCP 公网可达性 ✅ 2026-05-13 实测通过（HTTP/2 + TLS 1.3 + Let's Encrypt 证书未被代理篡改 + 1ms backend）
- [x] **Phase 0 必验**：员工电脑 git push 可达性 ✅ 已通过（日常使用 SSH 通道稳定，HTTP API 37ms 总响应）；居家场景由 VPN 兜底
- [ ] HR 同事 PoC 末期 UX 验收（Phase 0 ~2 周完工时拿到 demo 后约人）
- [ ] 确认 `platform_iam` 是否已有可复用 KMS 客户端 — 若有则集成，无则按上面"降级到 env master key"走

### "两条可达性实测"是什么

Phase 0 启动前**必须先做**的两个真机测试，验证整个 AI-first 部署链路的两个网络断点：

| 测试 | 命令 | 通过标准 | 不通的后果 |
|------|-----|---------|----------|
| **A. MCP 公网可达** | 员工电脑在 shell 跑 `claude mcp add --transport http ffoa-apps https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp --header "Authorization: Bearer <test-token>"` 后调任意工具 | 工具返回正常 JSON | 公司 TLS 中间代理 / 杀软 / Chrome 策略拦截 → 员工接入不了 → 整个方案推倒重审（不引入本地 npx 包降级） |
| **B. Gitea git push（连 VPN 后）** | 员工电脑**先连 VPN**，再跑 `git push http://x-access-token:<test-token>@43.130.59.228/FFAIApps/test-repo.git` | push 成功 + 远端有 commit | 即使连了 VPN 也 push 不上 → 需要排查 Gitea fine-grained token 配置 / 员工电脑 git 代理设置 |

**实测方法**：找 1-2 台真实员工电脑（**至少 1 台办公室 + 1 台居家**），按上表跑两条命令，记录通/不通 + 错误信息。预计 30 分钟。

**未跑这两条之前，不要进 Phase 1。**
