# 内部小工具自助部署平台 - API

> **module**: internal-app-platform
> **doc_type**: API
> **status**: Draft
> **owner**: lijian.dai
> **upstream_docs**: 01-prd.md, 03-architecture.md, 06-data-model.md
> **last_verified**: 2026-05-13
>
> **事实源**: 本文档定义 MVP 阶段全部 HTTP API + MCP 工具签名 + 错误码 + 权限码；与 06 schema 字段一一对应。

---

## 0. 概述

本模块对外暴露三类接口：

| 接口类型 | 调用方 | 协议 / 路径 | 用途 |
|---------|-------|------------|------|
| **MCP 工具** | Claude Code（员工电脑） | Streamable HTTP，`https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp` | 部署 / 列表 / 日志 / env / 销毁 |
| **OA HTTP API** | FFOA Web（"我的 Apps" 页） | REST `/api/v1/internal-apps/*` | token 颁发/撤销，员工自己 app 列表 |
| **管理 API** | IT-Admin CLI（`curl` / 内部脚本） | REST `/api/v1/internal-apps/admin/*` | 跨员工列表 / 强制停用 / 强制销毁 |
| **Webhook 入口** | Gitea | POST `/api/v1/internal-apps/webhook/gitea` | push 事件触发部署脚本 |

> MCP 工具内部转调 OA HTTP API（同进程，零网络跳）；OA HTTP API 不直接对外暴露给 Claude Code。

> **环境约定**：本文档示例统一使用**生产域名** `apps.ffworkspace.faradayfuture.com`。测试/UAT 拓扑见 [01-prd.md 头部环境表](./01-prd.md)；**员工 onboarding 与 MCP 端点固定走生产**，不在 Claude Code 端切换环境。测试/UAT 仅平台开发者自用验证。

---

## 1. 认证与权限

### 1.1 三种认证方式

| 调用方 | 认证 | 校验逻辑 |
|-------|-----|---------|
| Claude Code → MCP | Bearer token（员工 token） | SHA256 哈希查 `employee_tokens` 表，解出 `employeeSlug`；过期/撤销/disabled → 结构化错误 |
| FFOA Web → OA API | FFOA Entra ID session cookie | 复用 FFOA iam middleware；session 注入 `req.user` |
| IT-Admin CLI → Admin API | FFOA Entra ID session（管理员账号） | 同上 + 权限码 `internal-app:admin` |
| Gitea webhook → OA API | HMAC（`X-Gitea-Signature`） | shared secret 存 `INTERNAL_APP_GITEA_WEBHOOK_SECRET` env，不匹配返回 401 |

### 1.2 权限码（RBAC）

| 权限码 | 说明 | 默认角色 |
|-------|-----|---------|
| `internal-app:deploy` | 部署 / 列表 / 修改 env / 销毁自己的 app | 所有员工（自动授权） |
| `internal-app:token:manage` | 颁发 / 撤销 / 查看自己的 token | 所有员工（自动授权） |
| `internal-app:admin` | 跨员工查所有 app / 强制停用 / 强制销毁 | IT-Admin 角色 |

> 普通员工权限随登录态自动生效，不需要在 `platform_iam.roles` 里逐人配置（在 seed 阶段把这两个权限码挂到默认员工角色）。

### 1.3 owner 越权防护

所有 MCP / 用户端 API 在权限码通过后，**还要再校验** `app.createdById === currentUser.id`（owner 是 app 创建者，不可转让）。仅 `internal-app:admin` 权限可绕过此校验。

---

## 2. 通用响应结构

### 2.1 MCP 工具返回（JSON-RPC 2.0 + MCP content 包装）

MCP 端点遵循 [MCP spec](https://spec.modelcontextprotocol.io/) 的 **JSON-RPC 2.0 + Streamable HTTP** 协议。controller 用 `@modelcontextprotocol/sdk` 处理握手 / 协议版本 / 错误码标准化（详见 §8.1）。

**线上传输格式**（成功 / 失败均为 HTTP 200 + SSE event-stream）：

```typescript
// 成功
{
  jsonrpc: "2.0",
  id: <request id>,
  result: {
    content: [
      { type: "text", text: '<工具特定 payload 的 JSON 字符串>' },
    ],
    structuredContent: { /* 工具特定 payload，与 content[0].text 解码后等价 */ },
  },
}

// 失败（业务错误）
{
  jsonrpc: "2.0",
  id: <request id>,
  result: {
    content: [{ type: "text", text: "error[<code>]: <message>" }],
    isError: true,
    structuredContent: { error: { code, message, details?, onboardUrl? } },
  },
}

// 协议错误（鉴权失败 / 解析失败 / SDK 拒绝）
{
  jsonrpc: "2.0",
  id: <request id or null>,
  error: {
    code: <integer JSON-RPC 错误码：-32001 鉴权失败 / -32602 参数错 / -32603 内部错 等>,
    message: string,
    data?: { onboardUrl?: string, ... },
  },
}
```

**业务 payload 形状**：§3 各工具节下"返回"字段描述的就是 `structuredContent`（也是 `content[0].text` 反序列化后）的 payload；为简化阅读，§3 用 `{ data: {...} }` 形式承载工具语义结构，**不是**线上 JSON 字符串原样。

**Internal service 层契约**：`InternalAppMcpToolsService.callTool()` 内部仍返 `{ok: true, data: T} | {ok: false, error: {...}}`（便于直接单测 / 集成测 / 跳过 MCP 协议层验证业务逻辑），由 controller 一层转成上述 MCP content 包装。

历史：旧版自创了 `{ok, data}` 作为线上 shape，与 MCP spec 不兼容，Claude Code 握手失败。详见 [`.learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md`](../../../.learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md)。

### 2.2 OA HTTP API 返回

遵循 FFOA 统一响应格式（与 feedback / form-engine 等模块一致）：

```typescript
// 成功
{
  success: true,
  data: T,
  message?: string,
  timestamp: string,          // ISO 8601 UTC
  path: string,
}

// 失败
{
  success: false,
  error: { code, message, details? },
  timestamp: string,
  path: string,
}
```

---

## 3. MCP 工具签名

> MCP server 通过 **Streamable HTTP transport** 暴露在 `POST https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp`。决策依据见 §8.1。

### 3.1 `list_apps` — 列出员工的 app

| 项 | 值 |
|----|-----|
| 用途 | 列出当前 token 持有人的 app（PRD F2.3） |
| 鉴权 | bearer token |
| owner 校验 | 自动按 `employeeSlug` 过滤 |

**入参**

```typescript
{
  includeDestroyed?: boolean,    // 默认 false；true 时含 30 天恢复期内的销毁记录
}
```

**返回**

```typescript
{
  ok: true,
  data: {
    apps: Array<{
      id: string,                       // UUID
      appSlug: string,                  // 'birthday-reminder'
      displayName: string | null,
      runtime: 'node' | 'static',
      status: 'PENDING' | 'BUILDING' | 'HEALTHY' | 'FAILED' | 'DISABLED'
            | 'DESTROYED' | 'DISABLED_ARCHIVED' | 'PURGED',
      url: string,                      // 'zhang-san-birthday-reminder.apps.ffworkspace.faradayfuture.com'
      lastDeployedAt: string | null,    // ISO 8601
      destroyedAt: string | null,
      retentionUntil: string | null,    // 仅 destroyed 时有值
    }>,
  },
}
```

> **token 过期警示**：token 即将过期时（≤7 天）由 `tokenSvc.verify` 返 `warning` 字符串，controller 入口挂到 `req.auth.extra.tokenWarning`，每个 tool 的 MCP 响应里**三路透传**：
> - `content[0]` 前置一行 `⚠️ tokenWarning: ...`（Claude 会读到并主动告知用户）
> - `structuredContent.tokenWarning` 字段（支持 MCP 2025-06-18+ 的客户端能编程读取）
> - 服务端 `logger.warn`（运维侧也能看到 token 即将过期的员工）

---

### 3.2 `deploy_prepare` — 部署准备（首次 + 增量统一入口）

| 项 | 值 |
|----|-----|
| 用途 | 建仓（若不存在） + 颁发推送凭据，PRD F1.1/F1.2/F1.3 统一入口 |
| 鉴权 | bearer token |
| owner 校验 | 若 app 已存在，必须 owner = 当前员工 |

**入参**

```typescript
{
  appSlug: string,                  // Claude Code 生成的 slug, 3-22 字符 [a-z0-9-]
  displayName?: string,             // 可选人类可读名（不影响 URL）
  detected?: {                      // Claude Code 本地探测结果，平台据此判别 runtime
    hasPackageJson: boolean,
    hasStartScript: boolean,
    hasIndexHtml: boolean,
    hasDockerfile?: boolean,        // 仅供 unsupported_runtime 提示用
    hasRequirementsTxt?: boolean,
    hasGoMod?: boolean,
    hasPomXml?: boolean,
  },
}
```

**成功返回**

```typescript
{
  ok: true,
  data: {
    appId: string,                  // UUID；deploy_prepare 同事务 upsert internal_apps 行（首次 CREATE / 增量 UPDATE），webhook handler 反查此行触发部署
    isFirstDeploy: boolean,         // true = 刚建仓；false = 增量
    runtime: 'node' | 'static',     // 平台判别结果
    repoUrl: string,                // 'http://43.130.59.228/FFAIApps/<slug>-<app>.git'
    pushCredential: {
      token: string,                // 5min TTL OAuth token, scope = 该仓库 write:repo
      expiresAt: string,            // ISO 8601, 必定 = now + 5min
    },
    branch: 'main',
    suggestedCommitMessage: string, // 'deploy 2026-05-13T10:00:00Z'
    url: string,                    // 部署成功后的访问 URL（提前告知）
    postDeployHint: string,         // 给 Claude 的事后话术：push≠上线，提示用户 5 分钟后验证 url / 主动调 logs；防止 AI 立刻宣称"部署成功"
  },
}
```

**失败返回（关键错误码）**

```typescript
// case A: runtime 不支持（不是 node/static）
{
  ok: false,
  error: {
    code: 'unsupported_runtime',
    message: '未识别 runtime——需要 package.json + start script（node）或 index.html（static）',
    details: {
      detected: {
        hasPackageJson: false,
        hasStartScript: false,
        hasIndexHtml: false,
        hasDockerfile: true,
        hasRequirementsTxt: false,
        hasGoMod: false,
        hasPomXml: false,
      },
      hint: '检测到 Dockerfile，但当前平台 MVP 只支持 Node + static HTML，自定义 Docker 镜像不支持。建议改用 Node 重写或等待 V2 Python/容器镜像支持。',
    },
  },
}

// case B: slug 撞保留字白名单
{
  ok: false,
  error: {
    code: 'reserved_slug',
    message: 'app 名 "admin" 是平台保留字，请换一个',
    details: { reservedList: ['admin', 'api', 'auth', '...'] },
  },
}

// case C: slug 格式不合规
{
  ok: false,
  error: {
    code: 'invalid_slug',
    message: 'app 名必须是 3-22 个小写字母/数字/中划线，首尾不能是中划线',
    details: { receivedSlug: 'Birthday-Reminder!' },
  },
}

// case D: app 已存在且非当前员工 owner
{
  ok: false,
  error: {
    code: 'app_not_owned',
    message: '这个 app 名已被其他员工占用，请换一个',
  },
}

// case E: app 处于终态（DESTROYED / DISABLED_ARCHIVED / PURGED），不可重新部署
//   语义：MVP 不支持销毁后恢复（PRD §F2.4）；同 slug 重 deploy 会被拒，员工换 slug 即可。
//   防御目的：避免 Gitea getRepo 返 exists=true 但 DB 行已是 DESTROYED → push 后 webhook 静默 ignore 的 silent failure。
{
  ok: false,
  error: {
    code: 'app_in_terminal_state',
    message: 'app "<slug>" 处于 DESTROYED 状态，无法重新部署。MVP 不支持销毁后恢复——请换一个 app slug 重新部署',
    details: { status: 'DESTROYED', destroyedAt: '...', retentionUntil: '...' },
  },
}

// case F: token 失效（统一处理见 §6.1）
{
  ok: false,
  error: {
    code: 'invalid_token' | 'expired_token' | 'revoked_token' | 'disabled_token',
    message: '...',
    onboardUrl: 'https://ffworkspace.faradayfuture.com/internal-apps',
  },
}
```

> **`unsupported_runtime` 是核心决策**——MCP 拿到 `detected` 后**优先**判 node/static，**均不命中**才走 unsupported 分支并附 `hint`。Claude Code 据此向员工说"我看到你有 Dockerfile，平台不支持，要不要我改写成 Node？"，符合 PRD §UX 原则 4（错误必带"我帮你处理"）。

> Claude Code 拿到 pushCredential 后，**自行在本地用 Bash 拼装 git 命令**（见 PRD §核心约束 §部署文件传输路径）：
> ```bash
> git init && git add . && git commit -m "$SUGGESTED_MSG"
> git remote add ffapp "http://x-access-token:$TOKEN@43.130.59.228/FFAIApps/<repo>.git"
> git push ffapp main
> ```
> **平台不返回该命令字符串**，避免 server 端拼接污染。

---

### 3.3 `logs` — 查容器日志

| 项 | 值 |
|----|-----|
| 用途 | 查近 N 行容器日志（PRD F2.1） |
| 鉴权 | bearer token + owner 校验 |

**入参**

```typescript
{
  appSlug: string,
  lines?: number,         // 默认 100，上限 500
}
```

**返回**

```typescript
{
  ok: true,
  data: {
    appSlug: string,
    runtime: 'node' | 'static',
    logs: string,              // 多行文本；static runtime 时返回 'static runtime 无运行时日志' 占位
    truncated: boolean,        // 若超过 8KB 上限是否截断
    fetchedAt: string,
  },
}
```

**特殊错误**

```typescript
// app 状态 = DESTROYED / PURGED
{ ok: false, error: { code: 'app_destroyed', message: '...' } }

// 容器不存在 — 自动回填最近一次 build 失败事件（2026-05-19 起）
// 触发场景：build 阶段失败（npm install / image build 等），容器从未起来
{
  ok: false,
  error: {
    code: 'container_not_found',
    message: '容器 ffoa-app-... 不存在 — 最近一次部署失败（2026-05-19T...）。errorCode=npm_install_failed；详见 details.lastDeployFailure',
    details: {
      containerName: 'ffoa-app-<slug>-<app>',
      appStatus: 'PENDING' | 'BUILDING' | 'FAILED' | ...,
      lastDeployFailure: {                  // null = 该 app 从未有 deploy_failed 事件（首次部署还没开始 / 仍在构建中）
        errorCode: string | null,           // 如 'npm_install_failed' / 'docker_run_failed' / 'name_too_long'
        message: string | null,             // 已截断 ≤ 500 字符
        commitSha: string | null,
        failedAt: string,                   // ISO 8601
      } | null,
    },
  },
}
```

> **Claude 行为约定**：拿到 `container_not_found` + `lastDeployFailure ≠ null` 时，必须把 `errorCode` + `message` 转达给用户，不要再让用户去翻 events 表。详见 [.learnings/2026-05-19-logs-tool-needs-build-stage-fallback.md](../../../.learnings/2026-05-19-logs-tool-needs-build-stage-fallback.md)。

---

### 3.4 `env` — 读 / 写环境变量

| 项 | 值 |
|----|-----|
| 用途 | PRD F2.2；**写操作触发滚动重启**（复用现有镜像 + 新 env，~5 秒级零停机） |
| 鉴权 | bearer token + owner 校验 |

#### 3.4.1 子动作 `list`

入参：`{ appSlug: string, action: 'list' }`

返回：
```typescript
{
  ok: true,
  data: {
    appSlug: string,
    envVars: Array<{ key: string, valuePreview: string }>,   // valuePreview = '****' 或前 4 字符 + '****'
  },
}
```

#### 3.4.2 子动作 `get`

入参：`{ appSlug: string, action: 'get', key: string }`

返回：
```typescript
{
  ok: true,
  data: { key: string, value: string },   // 明文，仅 owner 可读
}
```

#### 3.4.3 子动作 `set`

入参：`{ appSlug: string, action: 'set', key: string, value: string }`

校验：
- `key` 匹配 `^[A-Z_][A-Z0-9_]{0,63}$`，否则返回 `invalid_env_key`
- `key` 不能以 `FFOA_` / `PLATFORM_` 开头，否则返回 `reserved_env_prefix`
- `value` 长度 ≤ 4KB 明文，否则返回 `env_value_too_large`

返回：
```typescript
{
  ok: true,
  data: {
    key: string,
    appliedAt: string,
    rolloverDeploymentId: string,   // 触发的滚动重启 deployment id
  },
}
```

#### 3.4.4 子动作 `unset`

入参：`{ appSlug: string, action: 'unset', key: string }`

返回：同 `set`（也走滚动重启）。

---

### 3.5 `destroy` — 销毁 app

| 项 | 值 |
|----|-----|
| 用途 | PRD F2.4，停容器 + 归档 Gitea 仓库 + 数据保留 30 天 |
| 鉴权 | bearer token + owner 校验 |

**入参**

```typescript
{
  appSlug: string,
  confirm: true,        // 强制要求显式 true，避免误删
}
```

**返回**

```typescript
{
  ok: true,
  data: {
    appSlug: string,
    status: 'DESTROYED',
    destroyedAt: string,
    retentionUntil: string,        // = destroyedAt + 30d，员工可凭此日期申诉恢复
    restoreInstruction: '如需在 30 天内恢复，请提工单给 IT 管理员，附 app 名和销毁日期',
  },
}
```

---

## 4. OA HTTP API（FFOA Web 调用）

### 4.1 token 管理

#### POST `/api/v1/internal-apps/tokens` — 颁发 token

- **鉴权**：Entra ID session
- **权限**：`internal-app:token:manage`
- **副作用**：撤销旧的 ACTIVE token（事务原子）；写 audit `token.issued`；规范化 `employeeSlug` 首次入库

请求体：`{}`（无需参数，slug 从 session 解出）

成功响应：
```typescript
{
  success: true,
  data: {
    tokenPlaintext: string,                  // 'ffoa_<32 chars>'，仅此一次返回
    mcpEndpoint: string,                     // https://ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp
    mcpAddCommand: string,                   // 完整 'claude mcp add --transport http ffoa-apps <endpoint> --header "Authorization: Bearer <token>"' （shell 子命令，不是 slash 命令）
    expiresAt: string,                       // = now + 90d
    employeeSlug: string,                    // 让 FFOA 页可展示给员工知道自己的 slug
  },
}
```

> **`tokenPlaintext` 永不入库 / 永不渲染到 DOM**（前端用 `navigator.clipboard.writeText` 直写剪贴板，内存 5 分钟后清空）。详见 [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) §2.1。

#### POST `/api/v1/internal-apps/tokens/revoke` — 撤销当前 token

- **鉴权**：Entra ID session
- **权限**：`internal-app:token:manage`
- **副作用**：当前 employeeSlug 的 ACTIVE token → REVOKED，`revoked_reason='self'`；写 audit `token.revoked`

请求体：`{ confirmText: 'REVOKE' }`（强确认，UI 上要员工输入）

成功响应：`{ success: true, data: { revokedAt: string } }`

#### GET `/api/v1/internal-apps/tokens/me` — 查我的 token 状态

成功响应：
```typescript
{
  success: true,
  data: {
    hasToken: boolean,
    status: 'ACTIVE' | 'REVOKED' | 'DISABLED' | 'EXPIRED' | null,
    prefix: string | null,             // 'ffoa_abc1'，UI 展示用
    issuedAt: string | null,
    expiresAt: string | null,
    expiringInDays: number | null,     // 计算字段，≤ 7 时 UI 提示续期
    lastUsedAt: string | null,
  },
}
```

---

### 4.2 我的 app 列表

#### GET `/api/v1/internal-apps/me/apps`

- **鉴权**：Entra ID session
- **权限**：`internal-app:deploy`

Query：`?includeDestroyed=true|false`（默认 false）

响应同 MCP `list_apps`（结构对齐，让 FFOA Web 和 MCP 共用 DTO）。

---

### 4.3 IT-Admin 管理端

> ✅ **Phase 1 已实现**（2026-05-14） — 见 [`admin.controller.ts`](../../../backend/src/modules/internal-app-platform/admin.controller.ts)。
> 3 个端点全部需 `internal-app:admin` 权限，变更端点自动写 audit。

#### GET `/api/v1/internal-apps/admin/apps` — 所有 app 列表

- **权限**：`internal-app:admin`
- Query：`?employeeSlug=&status=&page=&pageSize=`
- 响应分页结构，含 owner email / 资源占用 / 最后部署时间

#### POST `/api/v1/internal-apps/admin/apps/:appId/disable` — 强制停用

- **权限**：`internal-app:admin`
- 请求体：`{ reason: string }`（必填，≤ 500 字符，记 audit）
- 副作用：`app.status = DISABLED`，停容器，移除 Caddy 路由；邮件通知 owner

#### DELETE `/api/v1/internal-apps/admin/apps/:appId` — 强制销毁

- **权限**：`internal-app:admin`
- Query：`?reason=`
- 副作用：等同 owner `destroy` + 跳过 owner 校验 + 邮件通知 owner & IT 全员

---

### 4.4 Gitea Webhook 入口

#### POST `/api/v1/internal-apps/webhook/gitea`

- **鉴权**：HMAC（header `X-Gitea-Signature` = HMAC-SHA256 with secret `INTERNAL_APP_GITEA_WEBHOOK_SECRET`）
- **触发事件**：`push` 到 `FFAIApps/*` 仓库的 `refs/heads/main`
- 副作用：创建 `deployments` 行 (status=`BUILDING`) → 触发部署脚本（`scripts/internal-app-platform/deploy.sh`）→ 异步回写部署结果

请求体（Gitea 标准格式，仅列用到的字段）：
```typescript
{
  ref: 'refs/heads/main',
  after: '<commit-sha>',                 // 新 HEAD
  repository: {
    full_name: 'FFAIApps/zhang-san-birthday-reminder',
    clone_url: 'http://43.130.59.228/FFAIApps/zhang-san-birthday-reminder.git',
  },
  pusher: { username, email },
}
```

响应：`{ success: true, data: { deploymentId: string, status: 'BUILDING' } }`，**立即返回**，构建异步进行。

---

### 4.5 事件流（Phase 1.5）

> 全生命周期事件流的读取接口，写入由内部 `EventsService.emit()` 在 7 个业务写入点完成（不暴露 emit 端点）。
> 数据模型 / 字段约束 / event_type 枚举见 [06-data-model §2.6](./06-data-model.md#26-internal_app_events--全生命周期事件流admin-视图--员工活动)。
> 运营事件流 vs 合规 audit-system 分工见 [06-data-model §7.1](./06-data-model.md#71-事件流-vs-audit-system-的分工)。

#### GET `/api/v1/internal-apps/admin/events` — 全员事件流（admin）

- **鉴权**：Entra ID session
- **权限**：`internal-app:admin`
- Query：`?appId=&employeeSlug=&actorRole=&eventType=&outcome=&from=&to=&page=&pageSize=`
  - `actorRole`: `OWNER` / `ADMIN` / `SYSTEM`（可选）
  - `eventType`: `token.issued` 等点号字符串，多值用逗号
  - `from` / `to`: ISO8601；默认最近 7 天
  - `pageSize`: 默认 50，max 200
- **响应**（分页）：

```typescript
{
  success: true,
  data: {
    items: Array<{
      id: string;
      appId: string | null;
      appSlug: string | null;        // join 自 internal_apps，便于前端直接显示
      employeeSlug: string | null;
      actorId: string | null;
      actorEmail: string | null;     // join 自 users
      actorRole: 'OWNER' | 'ADMIN' | 'SYSTEM';
      eventType: string;             // 'token.issued' / 'app.deploy_failed' / ...
      outcome: 'OK' | 'FAIL';
      errorCode: string | null;
      durationMs: number | null;
      payload: Record<string, unknown>;
      ipAddr: string | null;
      createdAt: string;             // ISO
    }>;
    total: number;
    page: number;
    pageSize: number;
  }
}
```

#### GET `/api/v1/internal-apps/me/events` — 我的活动（员工自助）

- **鉴权**：Entra ID session
- **权限**：`internal-app:deploy`（任意接入员工可读）
- Query：`?appId=&eventType=&from=&to=&page=&pageSize=`（注意：**强制 `employeeSlug = currentUser`** 由后端注入，员工不能查别人）
- 响应结构同上，但 `actorEmail` 仅在 `actorRole === ADMIN` 时返回（员工看到"IT 对自己的 app 做了什么"，含执行人邮箱便于联系；OWNER 事件 actorEmail 始终为自己，省略）。

#### 错误码

- 401 `unauthorized` — 未登录
- 403 `forbidden` — 缺权限码（admin 端点）/ 越权查别人（me 端点尝试过滤别的 employeeSlug）
- 400 `invalid_query` — 参数格式错（时间不合法 / pageSize 越界 / eventType 不在枚举）

---

## 5. 错误码总表

### 5.1 token 类（统一附 `onboardUrl`）

| code | HTTP | 含义 | Claude Code 默认行为 |
|------|------|-----|---------------------|
| `invalid_token` | 401 | hash 不匹配 / 格式不对 | 提示员工去 `https://ffworkspace.faradayfuture.com/internal-apps` 重新生成 |
| `expired_token` | 401 | 已超 90 天 | 同上 |
| `revoked_token` | 401 | 员工自助撤销 | 同上 |
| `disabled_token` | 401 | Entra ID disable 联动 | 提示员工联系 IT |

### 5.2 资源类

| code | HTTP | 含义 |
|------|------|-----|
| `app_not_found` | 404 | app 不存在或已 PURGED |
| `app_not_owned` | 403 | 越权访问他人 app |
| `app_destroyed` | 410 | app 在 30 天恢复期内，不可操作 |
| `app_in_terminal_state` | 410 | deploy_prepare 拒绝重 deploy 已 DESTROYED / DISABLED_ARCHIVED / PURGED 的 app（MVP 不支持恢复，换 slug） |
| `app_disabled_by_admin` | 423 | IT-Admin 强制停用，操作被冻结 |

### 5.3 校验类

| code | HTTP | 含义 |
|------|------|-----|
| `invalid_slug` | 400 | slug 格式不合规 |
| `reserved_slug` | 400 | 撞保留字白名单 |
| `unsupported_runtime` | 400 | 不是 node/static — 附 `detected` + `hint`，详见 §3.2 case A |
| `invalid_env_key` | 400 | env key 不符合 `[A-Z_][A-Z0-9_]*` |
| `reserved_env_prefix` | 400 | env key 用了 `FFOA_` / `PLATFORM_` 前缀 |
| `env_value_too_large` | 400 | env value > 4KB |

### 5.4 系统类

| code | HTTP | 含义 |
|------|------|-----|
| `gitea_unreachable` | 502 | Gitea API 不可达 |
| `build_failed` | 200（MCP）| 构建失败，附 `build_log_summary` |
| `health_check_failed` | 200（MCP）| 30 次健康检查全失败，保留旧版本 |
| `credential_expired` | 401 | 5 分钟 push 凭据已过期，需 Claude Code 重新 `deploy_prepare` |
| `rate_limited` | 429 | 同 app 1 分钟内 ≥ 3 次部署被节流 |
| `internal_error` | 500 | 未分类错误，附 `traceId` 便于查日志 |

> **关键约定**：构建 / 健康检查失败 MCP 返回 HTTP **200** + `ok: false`，而不是 5xx——避免 Claude Code 把"业务失败"当"网络错误"重试。

---

## 6. 资源限额与节流

| 维度 | 阈值 | 处理 |
|------|-----|------|
| 同 app 部署频率 | 1 分钟 ≥ 3 次 | 返回 `rate_limited` + Retry-After |
| 同员工总 app 数 | 默认 **20** | 超限时新建 app 返回 `app_quota_exceeded`（提示员工先销毁旧 app；IT-Admin 可调高） |
| token 颁发频率 | 1 小时 ≥ 5 次 | 返回 `rate_limited`（避免 token 被无限重置攻击） |
| 单次 `logs` 请求 | 500 行 / 8KB | 截断 + `truncated: true` |
| MCP 单次响应体 | 1MB | 截断 `build_log_summary` |

---

## 7. 审计事件清单

OA API 在以下事件**必须**写 audit-system（PRD §核心约束 §审计不可绕过）：

| 事件 | 触发点 | 关键字段 |
|------|-------|---------|
| `internal_app.token.issued` | `/tokens` 颁发 | employeeSlug, prefix, expiresAt |
| `internal_app.token.revoked` | `/tokens/revoke` / Entra disable | employeeSlug, revokedReason |
| `internal_app.app.created` | `deploy_prepare` 首次建仓 | employeeSlug, appSlug, giteaRepoFullName |
| `internal_app.app.deployed` | 部署成功 | appId, deploymentId, commitSha, trigger |
| `internal_app.app.deploy_failed` | 部署失败 | appId, deploymentId, failureReason |
| `internal_app.app.env_changed` | env set/unset | appId, key（**不记 value**） |
| `internal_app.app.destroyed` | F2.4 / F4.3 | appId, byAdmin: bool, reason |
| `internal_app.app.force_disabled` | F4.2 | appId, adminUserId, reason |
| `internal_app.app.purged` | TTL Sweeper | appId, purgedAt |

---

## 8. 已敲定的关键决策（2026-05-13 锁定）

### 8.1 MCP transport = **Streamable HTTP**（非 SSE）+ 用官方 SDK

- **路径**：`POST /api/v1/internal-apps/mcp`（与 OA API 同 base path，复用 NestJS auth/audit 中间件）
- **协议层实现**：用 [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) 的 `McpServer` + `StreamableHTTPServerTransport`，**不自创协议变体**。SDK 自动处理 JSON-RPC 2.0 包装 / `initialize` 握手 / `protocolVersion` 协商 / 标准错误码 / SSE event-stream 输出。每个请求新建 stateless server + transport（不维护 session 状态）。
- **理由**：
  - SSE 长连接在公司代理 / 防火墙 / TLS 中间件下容易被截断（PRD §风险已记录"MCP 内网可达性"为 Phase 0 必验项）
  - Streamable HTTP 是 MCP 协议的较新标准 transport，单次请求-响应模式，可达性更稳、易调试、易抓包
  - Claude Code 客户端 1.x+ 已稳定支持 Streamable HTTP transport
- **替代方案**：若 Phase 0 PoC 实测 Streamable HTTP 通过率 < 100%，回退至 SSE；不引入本地 npx 包降级通道（PRD §假设）
- **不再自创 `{ok, data}` shape**：旧版控制器自创了项目变体，Claude Code 握手失败。详见 [`.learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md`](../../../.learnings/2026-05-18-mcp-controller-not-jsonrpc-compliant.md)。验证必须**用真实 `claude mcp list` 看到 `✓ Connected`**，不能仅用 curl 看响应字段。

### 8.2 token 形态 = **opaque**（非 JWT）

- **格式**：`ffoa_<32 字符 base32>`，总长 37 字符
- **存储**：SHA256(token) 入 `employee_tokens.token_hash`，明文永不入库
- **理由**：
  - **撤销即时生效**——员工 / IT-Admin / Entra disable 任一触发都能立即让旧 token 失效（JWT 需要黑名单表，反而破坏 JWT 无状态优势）
  - 长度短——更适合粘到 `claude mcp add --transport http ... --header "Authorization: Bearer ffoa_xxx"` 命令里
  - 与 PRD §核心约束"员工可随时 revoke"+ §Always Do"token 失效结构化错误"硬契约对齐

### 8.3 MCP server 集成 = **NestJS Controller**（同进程）

- **位置**：`backend/src/modules/internal-app-platform/mcp.controller.ts`
- **理由**：
  - 同进程复用 NestJS 已有的 auth 中间件（bearer 解析）+ `@Auditable()` 装饰器，零额外胶水代码
  - 单进程意味着 token hash 校验、DB 连接池、KMS 客户端全部共享
  - 不引入独立 MCP server 进程，避免运维多一个服务要监控

### 8.4 Gitea webhook = **org-level + per-repo 双保险**

- **配置**：
  - env `INTERNAL_APP_GITEA_WEBHOOK_SECRET`：HMAC secret，所有 webhook 共用
  - env `INTERNAL_APP_WEBHOOK_BASE_URL`（fallback `FRONTEND_URL`）：本环境 backend 公网入口，组合出最终 URL = `${BASE}/api/v1/internal-apps/webhook/gitea`
  - 组织级（IT 一次性手工配）：Gitea organization `FFAIApps` 装一条 push webhook（v1.26.1+ 支持）
  - **per-repo（deploy_prepare 自动 ensure）**：每次调用 `deploy_prepare` 都会幂等检查目标仓库是否有匹配本环境期望 URL 的 push hook，没有就装，已有就跳过

- **为什么要双保险**：历史事故 2026-05-19 — org-level webhook URL 漂到死 IP（Phase 0 老地址 `43.166.182.155`），所有"只有 org-level 没 per-repo 兜底"的仓库 push 完静默丢消息，员工拿到 200 空 body 死胡同。per-repo hook 由 `deploy_prepare` 自动维护，跟环境 backend URL 同步，不会随 ops 改 org hook 而失效。

- **理由（保留 org-level 同时新增 per-repo）**：
  - org-level 仍是"新员工首次随便 push 也能工作"的兜底
  - per-repo 由代码自动维护，是真正可靠的执行路径
  - 双方都用同一个 secret，HMAC 校验逻辑无分支
  - 删 / 改任一不影响另一方

- **风险缓解**：HMAC-SHA256 校验 + 在 webhook handler 里**还要再校验** `repository.full_name` 必须以 `FFAIApps/` 开头（防 secret 泄漏后被滥用到其他仓库）

- **新错误码**：
  - `gitea_webhook_install_failed` — ensureWebhook 调 Gitea API 创建 hook 失败（status / cause 在 details）
  - `gitea_webhook_base_url_missing` — `INTERNAL_APP_WEBHOOK_BASE_URL` 和 `FRONTEND_URL` 都未配，无法决定 hook URL

- **详见**：[.learnings/2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md](../../../.learnings/2026-05-19-gitea-webhook-must-be-per-repo-or-org-drift-kills-deploys.md)

### 8.5 单员工 app 配额 = **20**（可调）

- **配置**：env `INTERNAL_APP_QUOTA_PER_EMPLOYEE`，默认 `20`
- **超额行为**：返回 `app_quota_exceeded` + `details.currentCount` + `details.limit`，提示员工先 destroy 旧 app
- **理由**：PRD §假设容量按 25 app/平台估算；单员工 20 是宽松上限，实际员工通常 ≤ 5 个；IT-Admin 可对特殊员工调高（V2 加"每员工配额覆盖表"，MVP 期用全局 env 即可）
- **Phase 0 PoC 后复核**：若实测一周内有员工撞到 20 → 上调到 30；若所有员工 ≤ 3 → 不调

### 8.6 token `last_used_at` 更新 = **60 秒去抖**

- **逻辑**：每次 MCP 调用解出 token id 后，若 `now - last_used_at > 60s` 才发起 UPDATE；否则跳过
- **理由**：避免热点 token 行被高频 UPDATE 拖慢；60 秒精度对"最近活跃" 审计完全够用
- **降级**：UPDATE 失败 / 超时不阻塞 MCP 主流程，写日志即可（弱审计参考）

### 8.7 Caddy `_health` 内部端点 = **MVP 不暴露**

- **MVP**：Caddy wildcard host 全部反代到员工 app，不预留平台健康检查路径
- **V2**：监控引入时再用单独子域名（如 `_health.platform.faradayfuture.com`），不污染员工 app 命名空间

---

## 9. 仍待定项（依赖外部输入）

> 以下需运维 / 法务 / HR 共同决定，不在本文档技术决策范围。详见 [01-prd.md §待定项](./01-prd.md#待定项phase-1-实施前必须解决)。

- [ ] 内部域名 `apps.ffworkspace.faradayfuture.com` 最终选定 —— 运维
- [ ] Phase 0 PoC 真实员工 + 真实 app 选定 —— HR + 业务
- [ ] **Phase 0 必验**：Claude Code Streamable HTTP MCP 内网可达性
- [ ] 法务认可"员工自助部署 + 数据自负声明"
- [ ] 对象存储桶选型（自建 MinIO / 现有桶？）—— 运维

---

## 10. 与现有模块的契约面

| 上游模块 | 调用方式 | 契约要点 |
|---------|---------|---------|
| `platform_iam` | `@RequirePermissions()` + `req.user` | 新增三个权限码（§1.2）需在 iam seed 注册 |
| `audit-system` | `@Auditable()` 装饰器 | 9 个 audit event（§7）需注册 event 名 |
| Azure Entra ID（经 FFOA iam） | `req.session.user.mailNickname` | 首次接入用此字段规范化为 `employeeSlug` |
| Gitea | REST API + Webhook | 依赖 v1.26.1+ fine-grained OAuth token（PRD §假设） |

---

## 11. 相关文档

- [01-prd.md](./01-prd.md) — 功能边界 / 业务约束
- [03-architecture.md](./03-architecture.md) — 架构分层 / 流程图
- [05-ui-interaction-spec.md](./05-ui-interaction-spec.md) — FFOA "我的 Apps" 页交互（消费 §4.1/4.2 API）
- [06-data-model.md](./06-data-model.md) — Schema 字段（与本文档 DTO 一一对应）
- [09-test-scenarios.md](./09-test-scenarios.md) — 集成测试场景（下一步起草）
