# Agent API Key 接入契约

用户在账号中心 `/account/api-keys` 创建密钥，勾选允许访问的应用，再配置到 Codex 等工具、插件或命令行的安全凭证配置中。密钥代表个人的统一身份；每个应用自行管理个人账号、组织成员、角色、业务权限和操作审计。

## 开通与使用

1. 管理员登记自有应用，产品后端完成本文校验接口接入后，在“接入应用 → 编辑应用”开启“允许 API Key 接入”。默认关闭。
2. 用户创建密钥，填写名称、有效期并选择应用。默认90天，可选30天、365天或长期；默认不勾选任何应用，新应用不自动授权。
3. 明文仅在创建结果展示一次。工具只保存用户 API Key；产品 client_secret 留在产品后端，不能配置到 Agent。
4. 工具向产品 API 发送 `Authorization: Bearer <API Key>`。产品后端实时校验，再用 `(issuer, sub)` 查询本地用户，执行产品自己的权限检查。

密钥通过校验只代表身份和应用访问资格，不代表有权执行当前业务。涉及组织时，请求必须明确组织上下文，应用验证成员资格、角色和数据归属。不按手机号、昵称自动关联用户，不信任请求中的 userId 或角色。用户尚未关联产品账号时，产品引导其走自己的正常注册／登录流程。

第一版没有单独的只读／写入权限：在授权应用中，密钥可以执行用户通过 API 被允许执行的操作。业务会员过期、角色移除等变化由应用及时生效。

## 从创建到子系统使用：完整链路

假设子系统地址是 `https://product.example`，要开放自己的 `GET /api/me`。账号中心不会自动替子系统开放这个接口，也不会主动把用户密钥推送给子系统。

```text
管理员：登记子系统应用 → 子系统实现校验 → 开启该应用 API Key 接入
用户：在子系统正常登录一次，建立本地用户与中央 issuer/sub 的关联
用户：到账号中心创建 API Key，勾选该子系统 → 将密钥保存到工具
工具：携带 Bearer API Key → 请求子系统 GET /api/me
子系统后端：携带自身 Basic 凭证 + 用户 API Key → 请求中心 verify
中心：核验密钥、有效期、账号状态及是否授权当前子系统 → 返回身份
子系统后端：按 issuer/sub 查本地用户 → 检查业务权限 → 返回业务结果
```

同一密钥可以授权多个子系统，各子系统使用自己的应用凭证校验。例如密钥只授权 A，B 即使拿到该密钥，用 B 的凭证校验也会得到 `active=false`。子系统无需同步或保存用户密钥库；只处理本次请求的密钥，保存本地身份关联和必要的 keyId 审计信息。

### 子系统开发者需要实现什么

1. 在子系统设置页增加「管理 API Key」链接，指向 `https://login.lingtuzaowu.com/account/api-keys`；当前由用户自行返回子系统或工具完成配置，没有自动回传明文密钥的跳转协议。
2. 为需要开放的业务 API 接入 API Key 认证中间件。现有浏览器 Cookie 登录不会自动使这些接口支持 API Key。
3. 将中心返回的 `(issuer, sub)` 映射到 OIDC 登录时已建立的本地用户，复用自己的组织与业务权限检查；不要把 keyId 当用户 ID。
4. 为每个开放接口定义允许的操作及输入，执行后在本地记录业务审计。涉及算力扣费时，继续使用既有算力接口与用户扣费授权。

用户 API Key 不放入子系统环境变量。子系统后端配置的是自己的应用凭证：

```dotenv
IDENTITY_ORIGIN=https://login.lingtuzaowu.com
OIDC_ISSUER=https://login.lingtuzaowu.com/oauth
OIDC_CLIENT_ID=当前子系统登记的应用ID
OIDC_CLIENT_SECRET=当前子系统自己的后端密钥
```

### 子系统后端接入示例

Node.js 22+ 可将 `packages/api-key-sdk` 作为独立包安装或发布到内部 npm，包名为 `@lingtu/api-key-sdk`；当前不假定它已经发布到公共 npm。以下为基于标准 Request/Response 的处理函数，接到子系统自己的 `GET /api/me` 路由即可。`findLocalUser` 是子系统需要提供的本地数据库适配函数，不是账号中心接口。

```js
import { createApiKeyVerifier } from '@lingtu/api-key-sdk';

// 每个服务实例初始化一次；每次业务请求都调用 verify。
const verify = createApiKeyVerifier({
  identityOrigin: process.env.IDENTITY_ORIGIN,
  issuer: process.env.OIDC_ISSUER,
  clientId: process.env.OIDC_CLIENT_ID,
  clientSecret: process.env.OIDC_CLIENT_SECRET,
});

export async function getMe(request, { findLocalUser }) {
  const json = (status, body) => Response.json(body, {
    status, headers: { 'Cache-Control': 'no-store' },
  });
  const match = /^Bearer (ltak_[A-Za-z0-9_-]{43})$/i.exec(
    request.headers.get('authorization') || '',
  );
  if (!match) return json(401, { message: '请提供用户 API Key' });

  let identity;
  try {
    identity = await verify(match[1]);
  } catch {
    // 不记录或返回密钥、请求正文及底层异常。
    return json(503, { message: '身份校验暂时不可用' });
  }
  if (!identity) return json(401, { message: '密钥无效或未授权本应用' });

  const user = await findLocalUser(identity.issuer, identity.sub);
  if (!user || user.disabled) {
    return json(403, { message: '请先登录本产品关联账号，或联系管理员' });
  }
  // /api/me 只读取本人资料；组织/写入接口在此继续检查角色与数据归属。
  return json(200, { userId: user.id, nickname: user.nickname });
}
```

如果子系统使用其他语言，无需 SDK，也可以按下文协议直接调用中心 verify。中心校验只判断身份和当前应用访问资格，不执行子系统的业务请求。

### 用户在工具中怎么调用

将创建结果保存到工具的安全凭证配置，并把 API 地址设置为**子系统的业务地址**。下面的 `AGENT_API_KEY` 由安全环境提供，避免将明文粘贴到命令历史：

```sh
curl 'https://product.example/api/me' \
  --header "Authorization: Bearer $AGENT_API_KEY"
```

用户无需提供子系统的 client_secret，也无需先拿 API Key 换 OIDC Token。子系统收到请求后按上述流程校验；密钥管理页没有“填写子系统服务器密钥”的步骤。

### 接入完成后如何验收

- 用户已关联本地账号，密钥授权当前子系统：`/api/me` 返回本地用户资料。
- 密钥没勾选当前子系统、过期或撤销：业务接口返回 401，不执行业务。
- 中心校验成功，但本地用户未关联、被禁用或缺少组织权限：返回 403。
- 中心校验超时或不可用：返回 503，不沿用旧身份结果。
- 同一密钥授权 A、B 后分别调用：身份 issuer/sub 相同，业务权限分别由 A、B 判断。

可运行的双应用版本见 `examples/api-key-client`；其中本地用户与组织是测试夹具，接入真实子系统时需替换为自己的数据库查询。

## 产品后端校验

`POST /api/client/api-keys/verify`，由产品后端请求账号域名。

- HTTP Basic 使用已登记的 `client_id / client_secret`，沿用 OIDC `client_secret_basic` 的表单编码规则。
- `Content-Type: application/json`；请求体只接受 `{ "apiKey": "ltak_..." }`。
- 不接受请求体自报 clientId、用户或组织。应用身份取自 Basic 认证。
- 所有响应禁止缓存。生产使用HTTPS。不要记录请求体、Authorization、Cookie、创建结果或底层异常；代理与接入应用也须遵守。

有效凭证返回200：

```json
{
  "active": true,
  "issuer": "https://login.lingtuzaowu.com/oauth",
  "sub": "1",
  "keyId": "密钥记录ID",
  "expiresAt": "2027-01-23T00:00:00.000Z"
}
```

长期有效时 expiresAt 为 null。issuer 与 OIDC 固定 issuer 相同，sub 为十进制字符串，与 OIDC sub 使用同一账号。keyId 用于产品业务审计，不是可用于认证的秘密。

| 结果 | 含义及产品处理 |
| --- | --- |
| 200，active=true | 再检查本地用户、组织和业务权限 |
| 200，active=false | 密钥无效、过期、撤销、未授权，或账号／应用不可用；产品返回401，不执行操作 |
| 401 | 产品自身认证失败，检查后端配置；不能把用户当作已认证 |
| 400 | 请求结构无效 |
| 429 | 触发限流，遵循 Retry-After；不执行操作 |
| 503／超时／响应不合法 | 校验不可用，产品返回503，不使用旧结果继续执行 |

每次业务请求实时校验，不缓存、不以本地JWT或用户传入ID替代校验。撤销阻止之后的校验，不能回滚已开始的操作；长连接或任务中的后续独立操作须重新校验，连接本身至少每60秒检查一次。账号服务最近校验时间只记录成功校验，不代表业务成功。

当前默认入口限流6000次/IP/分钟、校验3000次/应用/分钟，账号密钥管理60次/用户/分钟；入口依赖可信边缘覆盖 X-Real-IP。超过额度返回429，SDK按不可用处理，不绕过校验。

## SDK

Node.js 22+ 产品后端可独立安装本项目 `packages/api-key-sdk`，或将其打包为私有 npm 包分发，无需依赖本项目数据库、Web 或源码树。

```js
import { createApiKeyVerifier } from '@lingtu/api-key-sdk';
const verify = createApiKeyVerifier({
  identityOrigin: process.env.IDENTITY_ORIGIN,
  issuer: process.env.OIDC_ISSUER,
  clientId: process.env.OIDC_CLIENT_ID,
  clientSecret: process.env.OIDC_CLIENT_SECRET,
});
const identity = await verify(apiKey); // null: 无效；抛错: 校验不可用
```

SDK默认5秒超时，不缓存、不自动重试、不跟随重定向，严格检查 issuer、sub 与响应结构。identityOrigin 是访问地址；issuer 为固定身份命名空间，即使走内部地址也不可改写。HTTP仅允许localhost/127.0.0.1/::1本机开发。SDK异常仅包含固定错误码，不转发响应体或底层异常。

双应用本地示例见 `examples/api-key-client/README.md`。A展示个人身份，B演示不同组织角色下允许／拒绝创建草稿，不执行真实发布或扣费。

## 生命周期及授权边界

- 密钥独立于中心登录会话和 authVersion。修改／找回密码、管理员重置密码、退出全部设备均不影响API Key。
- 用户可单独撤销或撤销全部密钥。更换时先创建新密钥、更新工具，再撤销旧密钥；允许短期同时有效。
- 有效期创建后不可延长；过期或撤销的密钥不能编辑恢复。
- 禁用账号时拒绝调用，重新启用后未撤销且未过期的密钥可继续使用。
- 删除或合并账号会永久撤销其密钥；恢复账号或解除合并也不恢复，密钥不转移到目标用户。
- 应用暂停、取消或关闭 API Key 接入均拒绝调用；暂停／接入开关恢复后原有授权可继续使用，取消应用不能恢复。
- OIDC登录授权与API Key授权独立。撤销“已授权应用”的登录授权不会移除密钥授权。该既有操作会关闭产品扣费授权，后续收费调用仍须被拒绝。
- API Key验证不创建 ApplicationAccount、OIDC授权、CreditsGrant 或任何业务会员。算力调用沿用已有产品权限、用户扣费授权及额度。

## 账号页接口

以下接口只接受中心登录会话。写入同时验证请求 Origin；API Key 或产品 Basic 不能替代用户会话。

| 接口 | 请求／响应 |
| --- | --- |
| GET /api/account/api-key-applications | applications：允许接入、启用且未取消应用的公开资料 |
| GET /api/account/api-keys?page=1 | keys、total、page、pageSize（20）；只查当前用户，记录不含摘要或明文 |
| POST /api/account/api-keys | name（1–100字符）、clientIds（至少1个，最多100个）、expiresInDays（30/90/365/null，默认90）；201返回 key 和一次性 apiKey |
| PATCH /api/account/api-keys/:id | name、clientIds；替换授权列表，不接受有效期字段 |
| POST /api/account/api-keys/:id/revoke | 幂等撤销；返回 revoked=true |
| POST /api/account/api-keys/revoke-all | 撤销当前用户全部现有密钥；返回 revoked=true |

创建、调整授权和撤销同事务写审计，不记录秘密。删除／合并的永久撤销由数据库触发器覆盖各写入路径。
