# 应用

通过 SDK 和 React 创建、部署和控制 Beeuni 应用。

Canonical page: https://beeuni.ai/docs/zh/sdk/apps

Apps 是项目范围内的部署，每个都有一个固定 URL，支持不可变版本、
按进程组划分的机器，以及持久化卷。根据 `app.capabilities` 进行分支处理。本页介绍 SDK 接口：
项目句柄上的 `apps` 外观接口，制品、版本、机器和卷的
调用，登录令牌、导出类型以及 React hooks。

有关模型、版本配置、来源类型、CLI、固定 URL
和冷启动唤醒行为，请阅读 [Apps](/docs/zh/feature-flags/apps)。

```ts
const apps = beeuni.project(projectId).apps;
```

> **Apps 是一个功能开关**
>
> 在项目启用 Apps 之前，所有 Apps 路由都返回 `403`，并带有 `{ error, code: "feature_disabled", feature: "apps" }`。
> 使用 `isFeatureDisabledError(error)` 据此进行分支处理。
> 参见[功能开关](/docs/zh/feature-flags)。

<a id="the-apps-facade"></a>

## apps 外观接口

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.list()` | `GET /projects/:pid/apps` | 列出调用者可打开的 Apps（项目管理员可看到全部） |
| `apps.create(input)` | `POST …/apps` | 创建 App 并为其分配固定 URL |
| `apps.get(appId)` | `GET …/apps/:id` | 读取一个 App |
| `apps.update(appId, input)` | `PATCH …/apps/:id` | 重命名，或更改机器、空闲超时、运行模式（`always_on`）、`uses` 或 `labels`。运行模式在 5 分钟内生效；机器变更在下一个版本生效 |
| `apps.remove(appId)` | `DELETE …/apps/:id` | 删除 App、其机器和所有版本镜像；返回 `images: {released, pending}`。其卷保留 `final` 快照 7 天 |
| `apps.start(appId)` | `POST …/apps/:id/start` | 将 `desired_state` 设为 `running` 并预热运行时。静态 App 返回 `409 static_app_no_runtime` |
| `apps.stop(appId)` | `POST …/apps/:id/stop` | 立即暂停计算；下一个请求会恢复计算。静态 App 返回 `409 static_app_no_runtime`：它没有运行时，只要有活跃部署就会提供服务 |
| `apps.rollback(appId, deploymentId)` | `POST …/apps/:id/rollback` | 从一个就绪版本的制品和配置创建新版本，并像部署一样推出（`202`）。卷保留其数据 |

制品是部署的不可变输入：

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.artifacts.register(input)` | `POST …/apps/artifacts` | 注册 `archive` 或 `oci_image`；对于归档文件，返回上传 URL |
| `apps.artifacts.uploadArchive(bytes, options?)` | — | 在一次调用中注册、上传、计算哈希并完成一个 `.tar.gz` |
| `apps.artifacts.finalize(artifactId, input)` | `POST …/apps/artifacts/:id/finalize` | 确认手动上传的 `sha256` 和 `size_bytes` |

版本（部署）不可变且带有编号：

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.deployments.create(appId, input)` | `POST …/apps/:id/deployments` | 根据制品、来源和可选的版本 `config` 启动一个版本 |
| `apps.deployments.list(appId)` | `GET …/apps/:id/deployments` | 列出部署历史 |
| `apps.deployments.get(appId, deploymentId)` | `GET …/deployments/:did` | 读取一个部署及其事件 |
| `apps.deployments.logs(appId, deploymentId, options?)` | `GET …/deployments/:did/logs` | 使用游标读取运行时日志 |
| `apps.deployments.remove(appId, deploymentId)` | `DELETE …/deployments/:did` | 删除一个部署及其镜像；对于正在提供服务的部署或进行中的构建，返回 `409`。返回 `image: 'released' \| 'pending' \| 'none'` |

机器、扩缩容和 exec（服务端 App；没有机器的 App 返回
`409 app_no_machines`）：

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.machines.list(appId)` | `GET …/apps/:id/machines` | 每台机器：`machine_id`、`process_group`、`state`、`release`、`vm`、`volume` |
| `apps.machines.start/stop/restart(appId, machineId)` | `POST …/machines/:mid/{start,stop,restart}` | 对一台机器执行操作；完成后返回机器列表 |
| `apps.machines.destroy(appId, machineId)` | `DELETE …/machines/:mid` | 销毁一台机器。其卷保留；其组运行的机器减少一台 |
| `apps.scale(appId, count)` | `PUT …/apps/:id/scale` | 每个进程组的机器数（`{ worker: 3 }`，0–20），在后台应用（`202`）。完成后返回 `scale` 和 `machines` |
| `apps.exec(appId, { command, machine_id?, timeout_s? })` | `POST …/apps/:id/exec` | 在运行中的机器上执行一条命令（`project.app.admin`；经过审计）。`timeout_s` 为 1–300，默认 60。完成后返回 `exit_code`、`stdout`、`stderr`（各 1 MiB）、`truncated` |

卷（持久化磁盘）：

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.volumes.list(appId)` | `GET …/apps/:id/volumes` | 每个卷：`name`、`process_group`、`machine_id`、`size_gb`、`status`、`attached`、`snapshot_retention_days` |
| `apps.volumes.create(appId, { name, size_gb, process_group?, snapshot_retention_days? })` | `POST …/volumes` | 预先创建卷；之后其组中具有同名挂载的机器会使用该卷 |
| `apps.volumes.update(appId, volumeId, { size_gb?, snapshot_retention_days? })` | `PATCH …/volumes/:vid` | 扩大卷（不可缩小；最大 100 GB）或更改其保留期（1–60 天） |
| `apps.volumes.destroy(appId, volumeId, confirmName)` | `DELETE …/volumes/:vid?confirm=<name>` | `project.app.admin`。机器挂载该卷时，会以 `409 volume_attached` 拒绝。保留 `final` 快照 7 天 |
| `apps.volumes.snapshots(appId, volumeId)` | `GET …/volumes/:vid/snapshots` | `daily`、`manual` 和 `final` 快照 |
| `apps.volumes.snapshot(appId, volumeId)` | `POST …/volumes/:vid/snapshots` | 立即创建 `manual` 快照 |
| `apps.volumes.restore(appId, volumeId, snapshotId)` | `POST …/volumes/:vid/restore` | `project.app.admin`。机器停止，卷恢复到快照，机器在该卷上启动（`202`） |
| `apps.volumes.fork(appId, volumeId, { name, snapshot_id? })` | `POST …/volumes/:vid/fork` | 从快照或当前内容，在同一个 App 中创建一个未挂接的新卷 |

登录令牌（能力 `member_tokens`；App 缺少该能力时
返回 `409 app_capability_unsupported`）：

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.token(appId)` | `POST …/apps/:id/token` | 为 App 提供有效期 15 分钟的登录令牌，其中标明调用者 |

访问权限由 App 自身的授权策略决定：

| 方法 | 封装 | 功能 |
|---|---|---|
| `apps.access.get(appId)` | `GET …/apps/:id/access` | 读取策略。需要 `project.app.read` |
| `apps.access.update(appId, input)` | `PATCH …/apps/:id/access` | 替换策略并递增其修订号 |
| `apps.access.session(appId)` | `POST …/apps/:id/access-session` | 生成有效期五分钟的 URL，可用来换取仅限主机的 cookie |

<a id="deploy-a-static-site"></a>

## 部署静态站点

`uploadArchive` 完成整个制品握手流程：注册制品，
依据 `max_bytes` 检查制品，通过 `PUT` 上传字节，计算 SHA-256，然后
完成制品确认。

```ts
const apps = beeuni.project(projectId).apps;

const app = await apps.create({ slug: 'docs', name: 'Docs' });
const artifact = await apps.artifacts.uploadArchive(tarGzBytes, {
  onProgress: (uploaded, total) => console.log(`${uploaded}/${total}`),
});

const deployment = await apps.deployments.create(app.app_id, {
  artifact_id: artifact.artifact_id,
  source: { kind: 'static', spa: true },
});

console.log(app.url, deployment.status); // https://…apps.beeuni.ai  queued
```

`create` 也接受机器字段：`cpu`、`memory_gb`、
`disk_gb`、`idle_timeout_seconds` 和 `always_on`。省略这些字段即可使用默认值。
Apps 没有月度预算：计算费用由账户额度支付。常驻运行的
服务端 App 全天候计费；按需运行的 App 仅在唤醒期间计费。
`always_on: true` 让服务端 App 全天候运行
（websockets、后台循环）；`false` 在 `idle_timeout_seconds` 后停止它，
并在下一个请求到来时唤醒。静态 App 没有运行时：`always_on` 为
`false`。

如果旧客户端发送 `monthly_budget_usd`，API 会忽略它（`200`，不
报错）。任何 App 响应都不包含它。

`uses` 按 slug 列出此 App
可以获取登录令牌并通过其绑定挂载访问的 Apps；
默认为空，`update(appId, { uses })` 会替换该列表（参见
[App 使用的 Apps](/docs/zh/feature-flags/apps#apps-an-app-uses-sign-in-tokens-and-bindings)）。

`create` 和 `update` 返回 `warnings`，其值始终为 `[]`。

```ts
const app = await beeuni.project(projectId).apps.create({ slug: 'worker', name: 'Worker', always_on: true });
console.log(app.always_on, app.hosting_type); // true null (until the first deploy)
```

通过轮询状态等待部署完成：

```ts
async function waitForReady(appId: string, deploymentId: string) {
  for (;;) {
    const { deployment } = await apps.deployments.get(appId, deploymentId);
    if (deployment.status === 'ready') return deployment;
    if (deployment.status === 'failed' || deployment.status === 'cancelled') {
      throw new Error(deployment.error ?? deployment.error_code ?? deployment.status);
    }
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}
```

状态值为 `queued`、`validating`、`building`、`provisioning`、
`checking`、`ready`、`failed` 和 `cancelled`。

<a id="deploy-an-oci-image"></a>

## 部署 OCI 镜像

注册不可变镜像引用，然后声明进程命令和
公开的目标端口：

```ts
const registered = await apps.artifacts.register({
  kind: 'oci_image',
  image: 'ghcr.io/hekmat/service:2026-08-07',
});

await apps.deployments.create(app.app_id, {
  artifact_id: registered.artifact.artifact_id,
  source: {
    kind: 'oci_image',
    image: 'ghcr.io/hekmat/service:2026-08-07',
    command: ['node', 'server.js'],
    port: 3000,
    readiness_path: '/health',
  },
});
```

对于 `oci_image`，`register` 返回 `upload: null`。只有 `archive` 会获得
上传 URL。

`CreateAppDeploymentInput` 还接受 `environment`（非机密的运行时
值）、`secrets`（运行时键到项目机密名称的映射）和 `config`（`AppReleaseConfigInput`，
版本配置）。无效的 `config`
会返回 `400 app_config_invalid`，并带有 `issues: [{ path, message }]`；在不支持卷的运行环境中
挂载会返回 `400 app_volumes_unsupported`。

```ts
await apps.deployments.create(app.app_id, {
  artifact_id: registered.artifact.artifact_id,
  source: { kind: 'oci_image', image, command: ['node', 'server.js'] },
  config: {
    processes: { app: '', worker: 'node worker.js' },
    http_service: { internal_port: 3000, access: 'app', processes: ['app'] },
    mounts: [{ source: 'data', destination: '/data', initial_size_gb: 10, processes: ['app'] }],
    deploy: { release_command: 'node migrate.js' },
  },
});
```

<a id="read-runtime-logs"></a>

## 读取运行时日志

```ts
let cursor = 0;
for (;;) {
  const page = await apps.deployments.logs(app.app_id, deployment.deployment_id, {
    after: cursor,
    limit: 200,
  });
  for (const entry of page.entries) console.log(entry.source, entry.line);
  cursor = page.next_cursor;
  if (page.entries.length === 0) break;
}
```

每个条目都包含 `cursor`、`time`、`source`（`app`、`appd`、`caddy`）以及
`line`。`caddy` 行是访问日志：方法、URI、状态、耗时和
响应头。它从不包含请求头，因为请求头携带
网关的凭据（访问者令牌和身份、提供方入口令牌）。

<a id="machines-scale-and-volumes"></a>

## 机器、扩缩容和卷

```ts
const apps = beeuni.project(projectId).apps;

const machines = await apps.machines.list(app.app_id);
await apps.scale(app.app_id, { worker: 3 });                     // 202: applied in the background

const { exit_code, stdout } = await apps.exec(app.app_id, { command: ['ls', '/data'] });

const [data] = await apps.volumes.list(app.app_id);
const snapshot = await apps.volumes.snapshot(app.app_id, data.volume_id);
await apps.volumes.restore(app.app_id, data.volume_id, snapshot.snapshot_id);   // project.app.admin
const copy = await apps.volumes.fork(app.app_id, data.volume_id, { name: 'data_copy' });
```

机器在各次发布中保留其 `machine_id`；每次发布都会替换其
box（`instance_id`）。同一时间只有一台机器挂载一个卷。恢复会生成
卷的新版本：被替换的内容会保留在历史记录中。
已挂载卷的快照可能遗漏最后约 1 秒的写入。

<a id="a-convex-backend"></a>

## Convex 后端

Convex 后端是一个带有卷的普通服务器 App，使用
`convex` 模板创建（`beeuni apps launch --template convex --name db`；参见
[模板 `convex`](/docs/zh/feature-flags/apps#template-convex-a-convex-backend)）。
SDK 没有 Convex 专用调用。使用
`exec` 从机器读取管理员密钥，并通过 `uses` 让 UI App 访问后端：

```ts
const db = (await apps.list()).find((a) => a.slug === 'db')!;
const { stdout } = await apps.exec(db.app_id, { command: ['sh', '-c', 'cd /convex && ./generate_admin_key.sh'] });
// The admin key controls all code and data: never log it or ship it to a browser.

await apps.update(site.app_id, { uses: ['db'] }); // the site may mint db tokens and bind to it
```

`db.services` 保存后端的 URL：`http`（Convex 客户端 URL）和
`site`（HTTP actions）。

<a id="your-app-already-knows-who-is-looking"></a>

## 你的 App 已经知道谁在访问

打开 Beeuni 托管 App 的人，Beeuni **已经让其登录**。Apps
网关会在提供第一个字节之前验证其身份，因此你的 App
无需自己的登录流程——没有第二个密码、没有授权同意页面，也没有重定向。

在浏览器中：

```ts
import { createBeeuni, beeuniAppViewerToken } from '@beeuni/sdk';
import { useBeeuniAppViewer } from '@beeuni/sdk/react';

const beeuni = createBeeuni({
  backendUrl: '/_beeuni/api/v1',             // the gate, on your App's own origin
  getToken: beeuniAppViewerToken(),          // the viewer's own App-scoped token
});

function Header() {
  const { status, viewer } = useBeeuniAppViewer();
  return <span>{status === 'viewer' ? viewer.email : 'Signed out'}</span>;
}
```

浏览器在你的 App 自身的源上调用 `/_beeuni/api/v1/*`，绝不
直接调用 `https://api.beeuni.ai/v1`：API 不响应来自
App 源的 CORS 预检，因此直接调用会在浏览器中失败。网关使用访问者的 App 范围
令牌，以已登录访问者的身份，将每次调用
转发到 Beeuni API。规则如下：

- 必须有 `viewer_token_scope: 'api'`。否则返回
  `403 viewer_api_disabled`。
- 仅代表网关已让其登录的访问者执行操作（通过 Beeuni 登录或访问
  链接）。没有此类访问者时，返回 `401 no_viewer_identity`。
- 仅响应来自 App 自身源的请求。跨站请求
  返回 `403 cross_site_request`。
- 转发 `/v1/*`，但 `/v1/oauth/*` 和 MCP 端点除外，它们返回
  `404`。你的 App 的 Cookie 和自身的 `Authorization` 请求头永远不会到达
  API，API 也无法在你的 App 上设置 Cookie。

在你的 App 服务器上，网关会将签名后的身份写入每个请求：

```ts
import { readAppViewer, createAppViewerBeeuni } from '@beeuni/sdk/server';

const viewer = await readAppViewer(request);
// { userId, email, name, picture, groupIds, groups, role, projectId, accountId, appId, accessMode, token }
if (!viewer) return new Response('Not found', { status: 404 });

// and, for an `api`-scoped App, act as them:
const beeuni = await createAppViewerBeeuni(request, { backendUrl });
await beeuni.projects.list();               // their projects, their role
```

`readAppViewer` 使用 `BEEUNI_APP_VIEWER_SECRET` 验证请求头的 HMAC，
Beeuni 会在部署时将后者注入你的 App。伪造的请求头永远无法通过验证：
网关会在转发前删除客户端提供的任何副本，并使用
为每个 App 派生的密钥生成签名。

<a id="how-much-your-app-is-told"></a>

### 你的 App 能获知多少信息

由 App 访问策略中的一个设置决定——Beeuni 中的 **Settings → Access**、
`beeuni apps access <app> --viewer off|identity|api`，或 `PATCH /projects/:id/apps/:appId/access`
上的 `viewer_token_scope`：

| 范围 | 你的 App 收到的信息 |
|---|---|
| `identity`（默认） | 访问者的 id、电子邮件、姓名、头像、账户角色和 Beeuni 群组（id 和名称，仅限此 App 所属账户中的群组），以及一个 `profile email` 令牌。足以向每个人展示其自己的数据，并按群组控制访问。 |
| `api` | 以上信息，以及一个在 Beeuni API 上**以该用户身份**操作的令牌——权限受其自身角色限制。 |
| `off` | 无。 |

令牌绝不是用户的 Beeuni 会话：它有效期为一小时，仅包含
这些范围；当 App 被删除或其
访问策略更改时，该 App 签发的每个令牌都会失效。

<a id="using-the-viewer-token"></a>

### 使用访问者令牌

- **在服务器上，每次请求都读取令牌。** 网关会在
  每个代理请求的 `x-beeuni-app-viewer-token` 中放入有效令牌。将其作为
  `Authorization: Bearer` 发送给 Beeuni API——`createAppViewerBeeuni(request)`
  正是这样做的。每个请求创建一个客户端；不要存储令牌。
- **访问策略更改后，旧令牌不再有效。** 通过网关的下一次请求
  会携带新令牌。在浏览器中，`beeuniAppViewerToken()`
  会自行恢复：当 API 拒绝其令牌时，SDK 会重新读取
  `/_beeuni/viewer`，并重试该调用一次。
- **访问者的角色是权限上限。** 令牌只能执行
  访问者在 Beeuni 中能执行的操作，绝不会更多。在具有智能体权限的项目中，
  `member` 需要获得你的 App 所启动智能体的授权：没有授权时，
  `POST /projects/:id/sessions` 返回 `403 no_agent_access`。向
  访问者授予该智能体的权限，而不是更宽泛的角色。
- **谁具有访问者身份。** 使用 Beeuni 登录打开 App 的人——或
  使用访问链接打开 App 的人（链接会让其以该用户身份登录）——具有访问者身份。
  `password` App 没有访问者身份：密码证明的是共享密钥，而不是个人身份。
  `public` App 仅对通过访问链接进入的访客具有访问者身份；
  直接访问 URL 仍为匿名。
- **它是凭据。** Beeuni 不会将请求头写入 App 的访问
  日志（`beeuni apps logs`）。不要在自己的代码中记录该请求头。

通过**自己的域名**（而非 `*.apps.beeuni.ai`）提供服务的 App 前面没有网关
——请在那里改用 [使用 Beeuni 登录](/docs/zh/sdk/sign-in)。

<a id="one-member-on-every-runtime"></a>

## 在每种运行时中使用同一成员身份

Beeuni 在三个地方为同一个人的身份担保：浏览器向网关请求
（`/_beeuni/viewer`），App 服务器读取签名请求头，而验证
登录令牌的 App（Convex 后端或你自己的服务器）会收到
Beeuni 签名的令牌。`readBeeuniMember` 将这些不同形式都转换为同一种
`BeeuniMember`，而 `requireBeeuniMember` 决定谁可以继续。无需
框架，也无依赖：同一套代码可在浏览器、Node、Bun、
Worker 和 Convex 函数中运行。

```ts
import {
  readBeeuniMember, requireBeeuniMember, verifyBeeuniToken,
  beeuniToken, beeuniBinding, fetchBeeuniAppViewer, BeeuniMemberError,
} from '@beeuni/sdk';

// Browser: who is looking (null when nobody).
const me = readBeeuniMember(await fetchBeeuniAppViewer());

// Browser → an App this App uses, on the same origin: its URL and a token fetcher.
const db = beeuniBinding('db');
const convex = new ConvexReactClient(db.url);
convex.setAuth(db.token);
// React: read the member with useQuery(api.members.me), never useConvexAuth() (see below).

// Browser: a token for this App itself, or for an App it uses.
const token = await beeuniToken()();
const dbToken = beeuniToken({ audience: 'db' });

// A Convex function: the runtime verified the token; enforce the rules.
const member = requireBeeuniMember(await ctx.auth.getUserIdentity(), { groups: ['Finance'] });

// A server App: verify the bearer. No options = the BEEUNI_AUTH_* env Beeuni injects.
// Any other server: pass the App's `auth` values.
const caller = await verifyBeeuniToken(bearer, { issuer: app.auth.issuer, audience: app.auth.audience, jwks: app.auth.jwks_uri });
```

在 React 中，不要调用 `useConvexAuth()`：它在普通的
`ConvexProvider` 下会抛出异常（它需要 `ConvexProviderWithAuth`，且该提供方不会
验证 `beeuniBinding`）。通过查询读取已登录成员：

```ts
// convex/members.ts
export const me = query({ args: {}, handler: async (ctx) => readBeeuniMember(await ctx.auth.getUserIdentity()) });
```

```tsx
const me = useQuery(api.members.me);   // undefined: loading. null: signed out. Otherwise the member.
```

对于匿名调用者，`readBeeuniMember` 返回 `null`。仅在
函数必须抛出异常的地方使用 `requireBeeuniMember`。

| 字段 | 值 |
|---|---|
| `userId` | Beeuni 用户 id。稳定不变；用它作为数据行的键。 |
| `email`、`name`、`picture` | 来自成员的个人资料。未知时为 `null`。 |
| `groups`、`groupIds` | 成员在此账户中的 Beeuni 群组，分别按名称和 id 表示。 |
| `role` | 账户角色：`owner`、`admin` 或 `member`。 |
| `accountId`、`projectId` | App 所在的位置。 |

`requireBeeuniMember(claims, { groups, roles })` 返回成员，或者抛出
`BeeuniMemberError`，其 `code` 为 `unauthenticated`（没有用户，或令牌
未通过检查）或 `forbidden`（不属于指定群组或角色）。每个列表都表示
“其中任意一个”；群组按名称或 id 匹配；空列表不允许任何人访问。

<a id="sign-in-tokens"></a>

### 登录令牌

每个项目有一个签发方，为每个 App 的令牌签名：`iss` =
`<API origin>/v1/projects/<projectId>`，`aud` = 令牌所属 App 的 id，
有效期为 15 分钟。其密钥集公开于 `<issuer>/jwks.json`，由
`<issuer>/.well-known/openid-configuration` 标识。每个 App 都携带用于
验证其令牌的值：`app.auth.issuer`、`app.auth.audience`、`app.auth.jwks_uri`。
令牌包含 `sub`、`email`、`name`、`picture`、`groups`、`group_ids`、
`role`、`account_id` 和 `project_id`；智能体的令牌包含
`kind: 'agent'`，不包含群组或角色。

| 函数 | 功能 |
|---|---|
| `beeuniToken({ audience? })` | 用于 App 自身（默认）或其使用的 App 的令牌获取函数（实时客户端用作认证回调的形式）。在 App 自身的源上读取 `/_beeuni/token`。缓存至即将过期时；`{ forceRefreshToken: true }` 会重新请求。没有已登录的访问者时返回 `null`；对于其未使用的 App，返回 `null` 并在控制台输出一次警告（`403 app_not_linked`）。从不抛出异常。 |
| `beeuniBinding(slug, { origin? })` | 此 App 使用的 App 的 `{ url, token }`：`url` = `<origin>/_beeuni/apps/<slug>`（HTTP 和 WebSocket，同源，无 CORS），`token` = `beeuniToken({ audience: slug })`。 |
| `verifyBeeuniToken(bearer, { issuer?, audience?, jwks? })` | 检查 ES256 签名、有效期（允许 60 秒时钟偏差）、签发者和受众。默认值：`BEEUNI_AUTH_ISSUER`、`BEEUNI_AUTH_AUDIENCE`、`BEEUNI_AUTH_JWKS`。Beeuni 将这三项注入每个服务端 App 的运行时（签发者：项目；受众：App 自身的 id；密钥集：一个 `data:` URI），因此 `verifyBeeuniToken(bearer)` 在其中无需任何选项即可使用。切勿在服务端 App 的 `env` 中设置 `BEEUNI_AUTH_*`：`BEEUNI_` 前缀为保留前缀，`beeuni validate` 会拒绝它。密钥集可以是对象、JSON 文本、`data:` URI 或 https URL（`auth.jwks_uri`，每个进程获取一次）。任何失败都会抛出 `BeeuniMemberError`，包括缺少密钥集或受众：一个项目密钥为所有 App 的令牌签名，因此 `aud` 才是阻止其他 App 令牌进入的机制。`audience: false` 接受项目中任意 App 的令牌。 |
| `apps.token(appId)` | 来自 API、标明调用者的令牌（`POST …/apps/:id/token`）。智能体会话获得的令牌会标明其智能体。 |

从项目或群组中移除的成员会在一个令牌有效期内失去访问权限；App 访问控制会在其下次加载页面时拒绝访问。Beeuni 群组需要 Enterprise 套餐；角色适用于所有套餐。

<a id="manage-access"></a>

## 管理访问权限

```ts
await apps.access.update(app.app_id, {
  mode: 'restricted',
  member_ids: [memberId],
  group_ids: [groupId],
});

const preview = await apps.access.session(app.app_id);
window.open(preview.url); // valid for five minutes
```

`AppAccessConfig` 返回 `password_configured`，绝不返回密码或其哈希。使用 `{ mode: 'password', password }` 设置密码。每次更新都会递增 `revision`，从而撤销现有的 App Cookie。

<a id="types"></a>

## 类型

以下所有类型均从 `@beeuni/sdk` 导出。

| 类型 | 包含的内容 |
|---|---|
| `App` | 身份、`capabilities`、`labels`、`scale`、`services`（`AppServiceSummary[]`）、`machines`（`total`、`running`、`stopped`）、`volumes`（`count`、`size_gb`）、`config`（线上发布版本的 `AppReleaseConfig`，或 `null`）、`uses`、`used_by`、`auth`、`url`、`access_mode`、`access_revision`、`desired_state`、`active_deployment_id`、`hosting_type`（当前发布版本的：`static`、`sandbox`，首次部署前为 `null`）、`machine`（线上发布版本中提供服务的组的规模）、`idle_timeout_seconds`、`always_on`、`retained_deployments`、`warnings`（仅限创建和更新；始终为 `[]`）、`last_request_at`、`viewer_can_access` |
| `AppCapability` | `'deployments' \| 'rollback' \| 'preview' \| 'sleep' \| 'static' \| 'member_tokens' \| 'machines' \| 'scale' \| 'exec' \| 'volumes' \| 'snapshots'` |
| `AppReleaseConfig` · `AppReleaseConfigInput` | 解析后的发布配置（`processes`、`services`、`mounts`、`vm`、`deploy`）· 编写形式（`processes`、`http_service`、`services`、`mounts`、`vm`、`deploy`，以及简写 `port`、`command`、`readiness_path`、`always_on`、`resources`） |
| `AppServiceConfig` · `AppServiceAccess` · `AppAutoStop` · `AppCheck` | `name`、`internal_port`、`protocol`、`access`、`auto_stop_machines`、`auto_start_machines`、`min_machines_running`、`processes`、`checks` · `'public' \| 'app' \| 'admins'` · `'off' \| 'stop' \| 'suspend'` · `type`、`port`、`path`、`interval_s`、`timeout_s`、`grace_period_s` |
| `AppMountConfig` · `AppVmConfig` · `AppDeployStrategy` | `source`、`destination`、`initial_size_gb`、`snapshot_retention_days`、`processes` · `cpu`、`memory_gb`、`disk_gb`、`processes` · `'rolling' \| 'immediate' \| 'bluegreen' \| 'canary'` |
| `AppServiceSummary` | `name`、`protocol`、`access`、`internal_port`、`processes`、`url`（主机标签过长时为 `null`） |
| `AppMachine` · `AppMachineState` | `machine_id`、`instance_id`、`process_group`、`state`、`release`（`deployment_id`、`version`）、`vm`、`volume`、`created_at`、`started_at`、`stopped_at` · `'started' \| 'starting' \| 'stopped' \| 'stopping' \| 'provisioning'` |
| `AppVolume` · `AppVolumeSnapshot` · `CreateAppVolumeInput` | `volume_id`、`name`、`process_group`、`machine_id`、`size_gb`、`status`、`attached`、`snapshot_retention_days`、`created_at` · `snapshot_id`、`kind`（`daily`、`manual`、`final`）、`commit_id`、`created_at` · `name`、`size_gb`、`process_group`、`snapshot_retention_days` |
| `AppExecInput` · `AppExecResult` | `command`、`machine_id`、`timeout_s` · `machine_id`、`exit_code`、`stdout`、`stderr`、`truncated` |
| `AppAuth` | `issuer`、`audience`（App id）、`jwks_uri`：用于验证 App 登录令牌的公开值 |
| `AppToken` | `token`、`expires_at` |
| `BeeuniTokenOptions` · `BeeuniTokenFetcher` · `BeeuniBinding` · `VerifyBeeuniTokenOptions` | 上述登录令牌辅助函数 |
| `AppDeployment` | `version`、`status`、`source_kind`、`hosting_type`（`static`：从存储提供服务，无运行时；`sandbox`：机器）、`hosting_provider`、`runtime_spec`、`build_spec`、`error_code`、`attempt_count`、`created_by`、`actor_type`、`source_session_id`、`config`（发布版本解析后的配置）、`strategy`、`release_command_status`（`running`、`succeeded`、`failed` 或 `null`） |
| `AppRollback` | `apps.rollback` 成功完成时返回的值：`App` 加上 `deployment` 中排队的发布版本 |
| `AppDeploymentDetail` | 一个 `deployment` 及其 `events`：生命周期事件和构建日志（`build_log` 行，以及构建输出超过 5,000 行时的一个 `log_truncated` 事件） |
| `AppAccessConfig` | `mode`、`revision`、`member_ids`、`group_ids`、`password_configured` |
| `AppAccessMode` | `'private' \| 'project' \| 'restricted' \| 'public' \| 'password'` |
| `AppSource` | `StaticAppSource \| BundleAppSource \| DockerfileAppSource \| OciImageAppSource` |
| `AppArtifact` | `kind`、`status`、`sha256`、`size_bytes`、`image_reference` |
| `AppLogEntry` · `AppLogsResponse` | 一行日志，以及一页数据加上 `next_cursor` |

`apps.list()` 和 `useProjectApps` 仅返回调用者根据各 App 的访问策略可以打开的 App（私有：创建者；受限：列出的成员和群组；项目、密码和公开：项目团队）。项目管理员可以看到所有 App，因此即使创建者离开，私有 App 仍可管理。因此，每个列出的 App 的 `viewer_can_access` 都为 `true`。对调用者无法打开的 App 执行 `get` 时，返回 `404`，而非 `403`。应将 `undefined` 视为未知，而非拒绝访问。

`AppAccessMode` 是角色模型之上的按资源可见性设置，而不是角色。`restricted` 指定用户和群组——与角色模型使用的主体类型相同。参见
[账户与访问权限](/docs/zh/accounts#per-feature-access-settings)。

`DockerfileAppSource` 和 `OciImageAppSource` 需要 `command`。`port` 是主服务的端口：仅在只有 worker 的发布版本（带有 `processes` 且没有服务的 `config`）中可以省略。`StaticAppSource` 和 `BundleAppSource` 均不接受这两项。

## React hooks

`@beeuni/sdk/react` 导出七个用于 App 的 hook。

### useProjectApps(projectId)

调用者可以打开的 App，以及其生命周期变更操作。每个变更操作成功后都会使清单缓存失效。

```tsx
import { useProjectApps } from '@beeuni/sdk/react';

function AppList({ projectId }: { projectId: string }) {
  const apps = useProjectApps(projectId);
  if (!apps.data) return null;

  return (
    <ul>
      {apps.data.map((app) => (
        <li key={app.app_id}>
          <a href={app.url}>{app.slug}</a>
          <button onClick={() => apps.stop.mutate(app.app_id)}>Stop</button>
        </li>
      ))}
    </ul>
  );
}
```

它返回查询字段，以及 `create`、`update`、`start`、`stop` 和 `remove`。

### useAppDeployments(projectId, appId)

不可变的部署历史，每 5 秒重新获取一次，使正在运行的构建自动更新进度。

```tsx
const deployments = useAppDeployments(projectId, appId);

await deployments.deploy.mutateAsync({
  artifact_id: artifact.artifact_id,
  source: { kind: 'static', spa: true },
});
await deployments.rollback.mutateAsync(previousDeploymentId);
```

两个变更操作都会使部署列表和 App 清单缓存失效。

### useAppDeployment(projectId, appId, deploymentId)

单个部署及其事件，包括构建日志（`AppDeploymentDetail`）。每 2 秒轮询一次，直到部署为 `ready`、`failed` 或 `cancelled`。将 `null` 作为 `deploymentId` 传入，可使其保持空闲。

```tsx
const detail = useAppDeployment(projectId, appId, deploymentId);
const log = (detail.data?.events ?? [])
  .map((event) => (event.type === 'build_log' ? event.message : `[${event.type}] ${event.message}`))
  .join('\n');
```

一次构建会保留最前面的 2,500 行和最后面的 2,500 行。日志行以批次写入，
最迟在输出后 1 秒内写入。失败或取消的部署
会将构建日志保留 14 天。

### useAppAccess(projectId, appId, options?)

访问策略和短期访问会话。两部分是独立的
查询，且均为可选。

```tsx
const access = useAppAccess(projectId, appId, {
  policy: canEditAccess,
  session: app.viewer_can_access,
});

access.policy.data;  // AppAccessConfig
access.session.data; // { url, expires_at }
await access.update.mutateAsync({ mode: 'project' });
```

| 选项 | 默认值 | 何时使用 `false` |
|---|---|---|
| `policy` | `true` | 界面仅预览 App。`GET …/access` 是管理性读取，对没有项目管理权限的调用者返回 `403`。 |
| `session` | `true` | 调用者可能无权打开 App。传入 `app.viewer_can_access`（对于已列出的 App，始终为 `true`）。 |

如果 App 网格将两个选项都保留为 `true`，则会为每个 App 发起一次策略读取和一次
会话创建；对于无权打开该 App 的成员，两者都会返回
`403`。

### useAppMachines(projectId, appId)

App 的机器，每 5 秒轮询一次，另有 `start`、`stop`、`restart`、
`destroy`（各接受一个 `machineId`）和 `scale`（接受
`{ [group]: count }`）。每次变更操作都会刷新机器和 App
清单。`appMachinesKey(projectId, appId)` 是查询键。

```tsx
const machines = useAppMachines(projectId, app.app_id);
await machines.scale.mutateAsync({ worker: 2 });
await machines.restart.mutateAsync(machineId);
```

### useAppVolumes(projectId, appId)

App 的卷，另有 `snapshot`（`volumeId`）、`restore`
（`{ volumeId, snapshotId }`）、`fork`（`{ volumeId, name, snapshotId? }`）、
`extend`（`{ volumeId, sizeGb }`）和 `destroy`（`{ volumeId, name }`）。每次
变更操作都会刷新卷。`appVolumesKey(projectId, appId)` 是查询
键。

```tsx
const volumes = useAppVolumes(projectId, app.app_id);
await volumes.snapshot.mutateAsync(volumeId);
await volumes.restore.mutateAsync({ volumeId, snapshotId });
```

### useAppVolumeSnapshots(projectId, appId, volumeId)

一个卷的快照。将 `null` 作为 `volumeId` 传入，可使其保持空闲。

<a id="errors"></a>

## 错误

```ts
import { featureDisabledKey, isFeatureDisabledError } from '@beeuni/sdk';

try {
  await beeuni.project(projectId).apps.list();
} catch (error) {
  if (isFeatureDisabledError(error)) {
    console.log(`${featureDisabledKey(error)} is off for this project`);
  }
}
```

你还应处理以下响应：

- slug 重复时返回 `409`；账户达到 App 数量上限时返回 `402`，并附带
  `app_quota_exceeded`；规格超出范围时返回 `400`，并附带 `app_machine_out_of_range`。
- `uses` 中存在未知 slug 时返回 `400 app_not_found`（`slugs`）。
- 调用 App 不提供的功能时返回
  `409 app_capability_unsupported`（`capability`）。
- 部署：`400 app_config_invalid`（`issues`）、`400 app_volumes_unsupported`。
  回滚：`409 release_not_ready`。
- 机器和 exec：`409 app_no_machines`、`404 machine_not_found`、
  `409 machine_not_running`、`400 process_group_unknown`、
  `400 scale_out_of_range`、`400 command_required`。
- 卷：`400 volume_name_invalid`、`volume_size_invalid`、
  `volume_shrink_refused`、`volume_retention_invalid`、`snapshot_invalid`、
  `confirmation_required`；`404 volume_not_found`；`409 volume_attached`。

`token()` 返回 `Cache-Control: no-store`。
