# Aplicaciones

Crea, despliega y controla Beeuni Apps desde el SDK y desde React.

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

Las Apps son despliegues de ámbito de proyecto, cada uno con una URL estable,
versiones inmutables, máquinas por grupo de procesos y volúmenes persistentes. Bifurca según `app.capabilities`. Esta página cubre la interfaz del SDK: la
fachada `apps` en un identificador de proyecto, las llamadas de artefactos, versiones, máquinas y volúmenes,
los tokens de inicio de sesión, los tipos exportados y los hooks de React.

Para conocer el modelo, la configuración de versiones, los tipos de origen, la CLI, la URL estable
y el comportamiento de activación en frío, consulta [Apps](/docs/es/feature-flags/apps).

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

> **Apps es una bandera de funcionalidad**
>
> Todas las rutas de Apps responden `403` con `{ error, code: "feature_disabled", feature: "apps" }`
> hasta que el proyecto active Apps. Usa `isFeatureDisabledError(error)` para bifurcar
> según su valor. Consulta [Banderas de funcionalidad](/docs/es/feature-flags).

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

## La fachada apps

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.list()` | `GET /projects/:pid/apps` | Enumera las Apps que el solicitante puede abrir (un administrador del proyecto ve todas) |
| `apps.create(input)` | `POST …/apps` | Crea una App y le asigna su URL estable |
| `apps.get(appId)` | `GET …/apps/:id` | Lee una App |
| `apps.update(appId, input)` | `PATCH …/apps/:id` | La renombra o cambia la máquina, el tiempo de inactividad, el modo de ejecución (`always_on`), `uses` o `labels`. El modo de ejecución se aplica en un plazo de 5 minutos; un cambio de máquina se aplica a la siguiente versión |
| `apps.remove(appId)` | `DELETE …/apps/:id` | Elimina la App, sus máquinas y todas las imágenes de versiones; devuelve `images: {released, pending}`. Sus volúmenes conservan una instantánea `final` durante 7 días |
| `apps.start(appId)` | `POST …/apps/:id/start` | Establece `desired_state` en `running` y prepara el entorno de ejecución. Una App estática responde `409 static_app_no_runtime` |
| `apps.stop(appId)` | `POST …/apps/:id/stop` | Suspende el cómputo de inmediato; la siguiente solicitud lo reanuda. Una App estática responde `409 static_app_no_runtime`: no tiene entorno de ejecución y sirve contenido mientras tenga un despliegue activo |
| `apps.rollback(appId, deploymentId)` | `POST …/apps/:id/rollback` | Crea una versión NUEVA a partir del artefacto y la configuración de una versión lista y la publica como un despliegue (`202`). Los volúmenes conservan sus datos |

Los artefactos son la entrada inmutable de un despliegue:

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.artifacts.register(input)` | `POST …/apps/artifacts` | Registra un `archive` o un `oci_image`; devuelve la URL de carga para un archivo |
| `apps.artifacts.uploadArchive(bytes, options?)` | — | Registra, carga, calcula el hash y finaliza un `.tar.gz` en una sola llamada |
| `apps.artifacts.finalize(artifactId, input)` | `POST …/apps/artifacts/:id/finalize` | Confirma `sha256` y `size_bytes` para una carga manual |

Las versiones (despliegues) son inmutables y están numeradas:

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.deployments.create(appId, input)` | `POST …/apps/:id/deployments` | Inicia una versión a partir de un artefacto, un origen y un `config` de versión opcional |
| `apps.deployments.list(appId)` | `GET …/apps/:id/deployments` | Enumera el historial de despliegues |
| `apps.deployments.get(appId, deploymentId)` | `GET …/deployments/:did` | Lee un despliegue junto con sus eventos |
| `apps.deployments.logs(appId, deploymentId, options?)` | `GET …/deployments/:did/logs` | Lee los registros de ejecución con un cursor |
| `apps.deployments.remove(appId, deploymentId)` | `DELETE …/deployments/:did` | Elimina un despliegue y su imagen; `409` para el despliegue activo o una compilación en curso. Devuelve `image: 'released' \| 'pending' \| 'none'` |

Máquinas, escalado y exec (una App de servidor; una App sin máquinas responde
`409 app_no_machines`):

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.machines.list(appId)` | `GET …/apps/:id/machines` | Todas las máquinas: `machine_id`, `process_group`, `state`, `release`, `vm`, `volume` |
| `apps.machines.start/stop/restart(appId, machineId)` | `POST …/machines/:mid/{start,stop,restart}` | Actúa sobre una máquina; se resuelve con la lista de máquinas |
| `apps.machines.destroy(appId, machineId)` | `DELETE …/machines/:mid` | Destruye una máquina. Su volumen se conserva; su grupo ejecuta una máquina menos |
| `apps.scale(appId, count)` | `PUT …/apps/:id/scale` | Máquinas por grupo de procesos (`{ worker: 3 }`, 0–20), aplicado en segundo plano (`202`). Se resuelve con `scale` y `machines` |
| `apps.exec(appId, { command, machine_id?, timeout_s? })` | `POST …/apps/:id/exec` | Ejecuta un comando en una máquina en ejecución (`project.app.admin`; auditado). `timeout_s` 1–300, valor predeterminado 60. Se resuelve con `exit_code`, `stdout`, `stderr` (1 MiB cada uno), `truncated` |

Volúmenes (discos persistentes):

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.volumes.list(appId)` | `GET …/apps/:id/volumes` | Todos los volúmenes: `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` | Crea un volumen de antemano; una máquina posterior de su grupo con un montaje de ese nombre lo toma |
| `apps.volumes.update(appId, volumeId, { size_gb?, snapshot_retention_days? })` | `PATCH …/volumes/:vid` | Amplía un volumen (nunca lo reduce; máximo 100 GB) o cambia su retención (1–60 días) |
| `apps.volumes.destroy(appId, volumeId, confirmName)` | `DELETE …/volumes/:vid?confirm=<name>` | `project.app.admin`. Se rechaza con `409 volume_attached` mientras una máquina lo tenga montado. Conserva una instantánea `final` durante 7 días |
| `apps.volumes.snapshots(appId, volumeId)` | `GET …/volumes/:vid/snapshots` | Instantáneas `daily`, `manual` y `final` |
| `apps.volumes.snapshot(appId, volumeId)` | `POST …/volumes/:vid/snapshots` | Una instantánea `manual` ahora |
| `apps.volumes.restore(appId, volumeId, snapshotId)` | `POST …/volumes/:vid/restore` | `project.app.admin`. La máquina se detiene, el volumen vuelve a la instantánea y la máquina arranca con él (`202`) |
| `apps.volumes.fork(appId, volumeId, { name, snapshot_id? })` | `POST …/volumes/:vid/fork` | Un volumen nuevo, sin conectar, en la misma App, a partir de una instantánea o del contenido actual |

Tokens de inicio de sesión (capacidad `member_tokens`; `409 app_capability_unsupported`
cuando la App carece de ella):

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.token(appId)` | `POST …/apps/:id/token` | Un token de inicio de sesión de 15 minutos para la App, que identifica al solicitante |

El acceso es la política de autorización propia de la App:

| Método | Encapsula | Qué hace |
|---|---|---|
| `apps.access.get(appId)` | `GET …/apps/:id/access` | Lee la política. Requiere `project.app.read` |
| `apps.access.update(appId, input)` | `PATCH …/apps/:id/access` | Sustituye la política e incrementa su revisión |
| `apps.access.session(appId)` | `POST …/apps/:id/access-session` | Genera una URL de cinco minutos que se canjea por una cookie exclusiva del host |

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

## Desplegar un sitio estático

`uploadArchive` realiza todo el protocolo del artefacto: registra el artefacto,
lo verifica frente a `max_bytes`, envía los bytes mediante `PUT`, calcula el SHA-256 y
finaliza.

```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` también acepta los campos de máquina: `cpu`, `memory_gb`,
`disk_gb`, `idle_timeout_seconds` y `always_on`. Omítelos para usar los valores predeterminados.
Las Apps no tienen presupuesto mensual: los créditos de la cuenta pagan el cómputo. Una App de servidor
siempre activa se factura las 24 horas del día; una bajo demanda, solo mientras está activa.
`always_on: true` ejecuta una App de servidor las 24 horas del día
(websockets, bucles en segundo plano); `false` la detiene después de `idle_timeout_seconds`
y la activa con la siguiente solicitud. Una App estática no tiene entorno de ejecución: `always_on` es
`false`.

La API ignora `monthly_budget_usd` si un cliente antiguo lo envía (`200`, sin
error). Ninguna respuesta de App lo incluye.

`uses` enumera, por slug, las Apps
para las que esta App puede obtener tokens de inicio de sesión y a las que puede acceder mediante su montaje de enlaces; el
valor predeterminado es ninguna, y `update(appId, { uses })` sustituye la lista (consulta
[Apps que usa una App](/docs/es/feature-flags/apps#apps-an-app-uses-sign-in-tokens-and-bindings)).

`create` y `update` devuelven `warnings`, que siempre es `[]`.

```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)
```

Espera al despliegue consultando periódicamente su estado:

```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));
  }
}
```

Los valores de estado son `queued`, `validating`, `building`, `provisioning`,
`checking`, `ready`, `failed` y `cancelled`.

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

## Desplegar una imagen OCI

Registra la referencia inmutable de la imagen y luego declara el comando del proceso y
el puerto de destino público:

```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',
  },
});
```

`register` devuelve `upload: null` para un `oci_image`. Solo un `archive` obtiene una
URL de carga.

`CreateAppDeploymentInput` también acepta `environment` (valores de ejecución
no secretos), `secrets` (clave de ejecución a nombre de secreto del proyecto) y `config`
(`AppReleaseConfigInput`, la configuración de la versión). Un `config` no válido
responde `400 app_config_invalid` con `issues: [{ path, message }]`; un montaje
en un entorno de ejecución sin volúmenes responde `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>

## Leer los registros de ejecución

```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;
}
```

Cada entrada contiene `cursor`, `time`, `source` (`app`, `appd`, `caddy`) y
`line`. Una línea `caddy` es el registro de acceso: método, URI, estado, duración y
cabeceras de respuesta. Nunca contiene cabeceras de solicitud, porque estas incluyen las
credenciales de la puerta de acceso (el token y la identidad del visitante, el token de entrada del proveedor).

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

## Máquinas, escalado y volúmenes

```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' });
```

Una máquina conserva su `machine_id` entre versiones; cada versión sustituye su
contenedor (`instance_id`). Una máquina monta un volumen a la vez. Una restauración es una
nueva versión del volumen: el contenido sustituido permanece en su historial. Una
instantánea de un volumen montado puede omitir el último ~1 s de escrituras.

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

## Un backend de Convex

Un backend de Convex es una App de servidor normal con un volumen, creada a partir de la
plantilla `convex` (`beeuni apps launch --template convex --name db`; consulta
[Plantilla `convex`](/docs/es/feature-flags/apps#template-convex-a-convex-backend)).
El SDK no tiene ninguna llamada específica de Convex. Lee la clave de administrador de la máquina con
`exec` y da acceso al backend a una App de interfaz con `uses`:

```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` contiene las URL del backend: `http` (la URL del cliente de Convex) y
`site` (acciones HTTP).

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

## Tu App ya sabe quién la está viendo

Quien abre una App alojada en Beeuni es alguien que **ya ha iniciado sesión** en Beeuni. La
puerta de acceso de Apps autentica a esa persona antes de que se sirva el primer byte, por lo que tu App no necesita
un inicio de sesión propio: ni una segunda contraseña, ni una pantalla de consentimiento, ni una redirección.

En el navegador:

```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>;
}
```

El navegador llama a `/_beeuni/api/v1/*` en el origen de tu propia App, nunca a
`https://api.beeuni.ai/v1` directamente: la API no responde a solicitudes de comprobación previa CORS desde
el origen de una App, por lo que una llamada directa falla en el navegador. La puerta de acceso reenvía cada
llamada a la API de Beeuni como el visitante que ha iniciado sesión, con su token
limitado a la App. Reglas:

- Necesita `viewer_token_scope: 'api'`. De lo contrario, responde
  `403 viewer_api_disabled`.
- Actúa solo para un visitante al que la puerta de acceso haya iniciado sesión (mediante el inicio de sesión de Beeuni o un enlace
  de acceso). Si no hay ninguno, responde `401 no_viewer_identity`.
- Solo responde a solicitudes del origen de la propia App. Una solicitud desde otro sitio
  recibe `403 cross_site_request`.
- Reenvía `/v1/*` excepto `/v1/oauth/*` y los endpoints de MCP, que responden
  `404`. La cookie de tu App y su propia cabecera `Authorization` nunca llegan a la
  API, y la API no puede establecer una cookie en tu App.

En el servidor de tu App, la puerta de acceso incluye la identidad firmada en cada solicitud:

```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` verifica un HMAC de la cabecera con `BEEUNI_APP_VIEWER_SECRET`,
que Beeuni inyecta en tu App al desplegarla. Una cabecera falsificada nunca supera la verificación: la
puerta de acceso elimina cualquier copia proporcionada por el cliente antes de reenviarla, y la firma se
crea con un secreto derivado para cada App.

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

### Cuánta información recibe tu App

Un ajuste de la política de acceso de la App: **Settings → Access** en Beeuni,
`beeuni apps access <app> --viewer off|identity|api` o `viewer_token_scope`
en `PATCH /projects/:id/apps/:appId/access`:

| Ámbito | Tu App recibe |
|---|---|
| `identity` (predeterminado) | El id, correo electrónico, nombre, imagen, rol de cuenta y grupos de Beeuni del visitante (ids y nombres, solo en la cuenta de esta App), además de un token `profile email`. Suficiente para mostrar a cada persona sus propios datos y controlar el acceso por grupo. |
| `api` | Lo anterior y un token que actúa **como** esa persona en la API de Beeuni, limitado por su propio rol. |
| `off` | Nada. |

El token nunca es la sesión de Beeuni del usuario: dura una hora, incluye solo
esos ámbitos y todos los tokens emitidos por una App dejan de funcionar cuando se elimina la App o cambia su
política de acceso.

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

### Uso del token del visitante

- **En tu servidor, léelo en cada solicitud.** La puerta de acceso coloca un token válido en
  `x-beeuni-app-viewer-token` en cada solicitud enviada a través del proxy. Envíalo como
  `Authorization: Bearer` a la API de Beeuni: `createAppViewerBeeuni(request)`
  hace exactamente eso. Crea un cliente por solicitud; no almacenes el token.
- **Tras un cambio en la política de acceso, los tokens antiguos dejan de funcionar.** La siguiente solicitud
  a través de la puerta de acceso lleva uno nuevo. En el navegador, `beeuniAppViewerToken()`
  se recupera por sí solo: cuando la API rechaza su token, el SDK vuelve a leer
  `/_beeuni/viewer` y repite la llamada una vez.
- **El rol del visitante es el límite.** El token puede hacer exactamente lo que el
  visitante puede hacer en Beeuni, nunca más. En un proyecto con permisos de agente, un
  `member` necesita un permiso sobre el agente que inicia tu App: sin él,
  `POST /projects/:id/sessions` responde `403 no_agent_access`. Concede a tus
  visitantes acceso al agente, no un rol más amplio.
- **Quién tiene un visitante identificado.** Una persona que abre la App con su inicio de sesión de Beeuni, o
  con un enlace de acceso que inicia sesión como esa persona, lo tiene. Una
  App `password` no lo tiene: la contraseña demuestra un secreto compartido, no una identidad personal. Una
  App `public` solo lo tiene para un visitante que haya llegado mediante un enlace de acceso;
  la URL sin más sigue siendo anónima.
- **Es una credencial.** Beeuni excluye las cabeceras de solicitud del registro de acceso de la App
  (`beeuni apps logs`). No registres la cabecera en tu propio código.

Una App servida en su **propio dominio** (no en `*.apps.beeuni.ai`) no tiene una puerta de acceso
por delante: usa [Iniciar sesión con Beeuni](/docs/es/sdk/sign-in) allí en su lugar.

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

## Un miembro, en todos los entornos de ejecución

Beeuni avala a la misma persona en tres lugares: el navegador consulta a la puerta de acceso
(`/_beeuni/viewer`), el servidor de la App lee la cabecera firmada y una App que
verifica tokens de inicio de sesión (un backend de Convex o tu propio servidor) recibe un
token firmado por Beeuni. `readBeeuniMember` convierte cada uno de esos formatos en un
`BeeuniMember` y `requireBeeuniMember` decide quién puede continuar. Sin
framework ni dependencias: el mismo código se ejecuta en un navegador, Node, Bun, un
Worker y una función de 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 });
```

En React, no llames a `useConvexAuth()`: lanza una excepción con un
`ConvexProvider` simple (necesita `ConvexProviderWithAuth` y `beeuniBinding` no se
verifica con ese proveedor). Lee el miembro que ha iniciado sesión mediante una consulta:

```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` devuelve `null` para una llamada anónima. Usa
`requireBeeuniMember` solo cuando la función deba lanzar una excepción.

| Campo | Valor |
|---|---|
| `userId` | Id de usuario de Beeuni. Estable; úsalo como clave de las filas. |
| `email`, `name`, `picture` | Del perfil del miembro. `null` cuando se desconoce. |
| `groups`, `groupIds` | Los grupos de Beeuni del miembro en esta cuenta, por nombre y por id. |
| `role` | Rol de cuenta: `owner`, `admin` o `member`. |
| `accountId`, `projectId` | Dónde reside la App. |

`requireBeeuniMember(claims, { groups, roles })` devuelve el miembro o lanza
`BeeuniMemberError` con `code` `unauthenticated` (nadie, o un token que
no superó una comprobación) o `forbidden` (fuera de los grupos o roles). Cada lista significa
"cualquiera de estos"; un grupo coincide por nombre o id; una lista vacía no admite a nadie.

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

### Tokens de inicio de sesión

Un emisor por proyecto firma los tokens de todas las Apps: `iss` =
`<API origin>/v1/projects/<projectId>`, `aud` = el id de la App a la que está destinado el token,
15 minutos. Su conjunto de claves es público en `<issuer>/jwks.json`, identificado por
`<issuer>/.well-known/openid-configuration`. Cada App incluye los valores que
verifican sus tokens: `app.auth.issuer`, `app.auth.audience`, `app.auth.jwks_uri`.
Un token contiene `sub`, `email`, `name`, `picture`, `groups`, `group_ids`,
`role`, `account_id` y `project_id`; el token de un agente contiene
`kind: 'agent'` y ningún grupo ni rol.

| Función | Qué hace |
|---|---|
| `beeuniToken({ audience? })` | Un proveedor de tokens (con la forma que los clientes en tiempo real aceptan como callback de autenticación) para la propia App (por defecto) o una App que utiliza. Lee `/_beeuni/token` en el origen de la propia App. Se almacena en caché hasta poco antes de caducar; `{ forceRefreshToken: true }` vuelve a solicitarlo. Devuelve `null` si no hay un usuario con sesión iniciada, y `null` más una advertencia en la consola para una App que no utiliza (`403 app_not_linked`). Nunca lanza excepciones. |
| `beeuniBinding(slug, { origin? })` | `{ url, token }` para una App que esta App utiliza: `url` = `<origin>/_beeuni/apps/<slug>` (HTTP y WebSocket, mismo origen, sin CORS), `token` = `beeuniToken({ audience: slug })`. |
| `verifyBeeuniToken(bearer, { issuer?, audience?, jwks? })` | Comprueba la firma ES256, la caducidad (60 s de tolerancia), el emisor y la audiencia. Valores predeterminados: `BEEUNI_AUTH_ISSUER`, `BEEUNI_AUTH_AUDIENCE`, `BEEUNI_AUTH_JWKS`. Beeuni inyecta los tres en el entorno de ejecución de cada App de servidor (emisor: el proyecto; audiencia: el id de la propia App; conjunto de claves: un URI `data:`), por lo que `verifyBeeuniToken(bearer)` funciona allí sin opciones. Nunca establezcas `BEEUNI_AUTH_*` en `env` de una App de servidor: el prefijo `BEEUNI_` está reservado y `beeuni validate` lo rechaza. El conjunto de claves es un objeto, texto JSON, un URI `data:` o una URL https (`auth.jwks_uri`, obtenida una vez por proceso). Cada fallo lanza `BeeuniMemberError`, incluidos la ausencia de un conjunto de claves o de una audiencia: una sola clave del proyecto firma los tokens de todas las Apps, por lo que `aud` es lo que impide aceptar el token de otra App. `audience: false` acepta un token para cualquier App del proyecto. |
| `apps.token(appId)` | Un token que identifica al invocador, procedente de la API (`POST …/apps/:id/token`). Una sesión de agente recibe uno que identifica a su agente. |

Un miembro que elimines del proyecto o de un grupo pierde el acceso en el plazo de una
vida útil del token; el control de acceso de la App lo rechaza en la siguiente carga de página. Los grupos de Beeuni requieren
el plan Enterprise; los roles funcionan en todos los planes.

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

## Gestionar el acceso

```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` informa de `password_configured`, nunca de la contraseña ni de su
hash. Establece una contraseña con `{ mode: 'password', password }`. Cada actualización
incrementa `revision`, lo que revoca las cookies existentes de la App.

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

## Tipos

Todos los tipos siguientes se exportan desde `@beeuni/sdk`.

| Tipo | Qué contiene |
|---|---|
| `App` | Identidad, `capabilities`, `labels`, `scale`, `services` (`AppServiceSummary[]`), `machines` (`total`, `running`, `stopped`), `volumes` (`count`, `size_gb`), `config` (`AppReleaseConfig` de la versión en producción, o `null`), `uses`, `used_by`, `auth`, `url`, `access_mode`, `access_revision`, `desired_state`, `active_deployment_id`, `hosting_type` (de la versión activa: `static`, `sandbox` o `null` antes del primer despliegue), `machine` (el tamaño del grupo que atiende las solicitudes en la versión en producción), `idle_timeout_seconds`, `always_on`, `retained_deployments`, `warnings` (solo creación y actualización; siempre `[]`), `last_request_at`, `viewer_can_access` |
| `AppCapability` | `'deployments' \| 'rollback' \| 'preview' \| 'sleep' \| 'static' \| 'member_tokens' \| 'machines' \| 'scale' \| 'exec' \| 'volumes' \| 'snapshots'` |
| `AppReleaseConfig` · `AppReleaseConfigInput` | La configuración resuelta de la versión (`processes`, `services`, `mounts`, `vm`, `deploy`) · la forma redactada (`processes`, `http_service`, `services`, `mounts`, `vm`, `deploy` y las formas abreviadas `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` cuando la etiqueta del host sería demasiado larga) |
| `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` (el id de la App), `jwks_uri`: los valores públicos que verifican los tokens de inicio de sesión de la App |
| `AppToken` | `token`, `expires_at` |
| `BeeuniTokenOptions` · `BeeuniTokenFetcher` · `BeeuniBinding` · `VerifyBeeuniTokenOptions` | Las funciones auxiliares para tokens de inicio de sesión descritas arriba |
| `AppDeployment` | `version`, `status`, `source_kind`, `hosting_type` (`static`: servido desde el almacenamiento, sin entorno de ejecución; `sandbox`: máquinas), `hosting_provider`, `runtime_spec`, `build_spec`, `error_code`, `attempt_count`, `created_by`, `actor_type`, `source_session_id`, `config` (la configuración resuelta de la versión), `strategy`, `release_command_status` (`running`, `succeeded`, `failed` o `null`) |
| `AppRollback` | El valor con el que se resuelve `apps.rollback`: `App` más la versión en cola en `deployment` |
| `AppDeploymentDetail` | Un `deployment` más su `events`: eventos del ciclo de vida y el registro de compilación (líneas `build_log` y un evento `log_truncated` cuando la compilación imprimió más de 5.000 líneas) |
| `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` | Una línea de registro y una página más `next_cursor` |

`apps.list()` y `useProjectApps` devuelven solo las Apps que el invocador puede abrir
según la política de acceso de cada App (privada: el creador; restringida: los
miembros y grupos indicados; proyecto, contraseña y pública: el equipo del proyecto). Un responsable del proyecto
ve todas las Apps, por lo que una privada sigue siendo administrable cuando su creador
se marcha. Por tanto, `viewer_can_access` es `true` en todas las Apps enumeradas. Un `get` de
una App que el invocador no puede abrir responde con `404`, no con `403`. Trata `undefined` como
desconocido, no como denegado.

`AppAccessMode` es un ajuste de visibilidad por recurso que se superpone al modelo de roles,
no un rol. `restricted` identifica usuarios y grupos: los mismos tipos de entidades que
usa el modelo de roles. Consulta
[Cuentas y acceso](/docs/es/accounts#per-feature-access-settings).

`DockerfileAppSource` y `OciImageAppSource` requieren `command`. `port` es el
puerto del servicio principal: omítelo solo para una versión que tenga únicamente workers (un `config`
con `processes` y sin servicio). `StaticAppSource` y `BundleAppSource` no aceptan
ninguno de los dos.

<a id="react-hooks"></a>

## Hooks de React

`@beeuni/sdk/react` exporta siete hooks para Apps.

### useProjectApps(projectId)

Las Apps que el invocador puede abrir, más sus mutaciones del ciclo de vida. Cada mutación
invalida el inventario cuando se completa correctamente.

```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>
  );
}
```

Devuelve los campos de la consulta más `create`, `update`, `start`, `stop` y
`remove`.

### useAppDeployments(projectId, appId)

El historial inmutable de despliegues, que se vuelve a obtener cada 5 s para que una compilación en curso
avance por sí sola.

```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);
```

Ambas mutaciones invalidan la lista de despliegues y el inventario de Apps.

### useAppDeployment(projectId, appId, deploymentId)

Un despliegue y sus eventos, incluido el registro de compilación (`AppDeploymentDetail`). Realiza
consultas cada 2 s hasta que el despliegue sea `ready`, `failed` o `cancelled`. Pasa
`null` como `deploymentId` para mantenerlo inactivo.

```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');
```

Una compilación conserva sus primeras 2.500 líneas y sus últimas 2.500 líneas. Las líneas se escriben en
lotes, como máximo 1 s después de imprimirse. Un despliegue fallido o cancelado
conserva su registro de compilación durante 14 días.

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

La política de acceso y una sesión de acceso de corta duración. Ambas partes son
consultas independientes y cada una es opcional.

```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' });
```

| Opción | Valor predeterminado | Usa `false` cuando |
|---|---|---|
| `policy` | `true` | La interfaz solo muestra una vista previa de la App. `GET …/access` es una lectura administrativa y responde `403` a quien realiza la llamada sin permisos de administrador del proyecto. |
| `session` | `true` | Quien realiza la llamada no tiene permiso para abrir la App. Pasa `app.viewer_can_access` (siempre `true` para una App incluida en la lista). |

Una cuadrícula de Apps que deja ambas opciones en `true` ejecuta una lectura de la política y una
creación de sesión por App, y cada una devuelve `403` para un miembro que no tiene permiso para abrir esa
App.

### useAppMachines(projectId, appId)

Las máquinas de una App, consultadas cada 5 s, junto con `start`, `stop`, `restart`,
`destroy` (cada una recibe un `machineId`) y `scale` (recibe
`{ [group]: count }`). Cada mutación actualiza las máquinas y el inventario de
Apps. `appMachinesKey(projectId, appId)` es la clave de consulta.

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

### useAppVolumes(projectId, appId)

Los volúmenes de una App, junto con `snapshot` (`volumeId`), `restore`
(`{ volumeId, snapshotId }`), `fork` (`{ volumeId, name, snapshotId? }`),
`extend` (`{ volumeId, sizeGb }`) y `destroy` (`{ volumeId, name }`). Cada
mutación actualiza los volúmenes. `appVolumesKey(projectId, appId)` es la clave de
consulta.

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

### useAppVolumeSnapshots(projectId, appId, volumeId)

Las instantáneas de un volumen. Pasa `null` como `volumeId` para mantener la consulta inactiva.

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

## Errores

```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`);
  }
}
```

Otras respuestas que debes gestionar:

- `409` para un slug duplicado, `402` con `app_quota_exceeded` cuando la cuenta
  alcanza su límite de Apps y `400` con `app_machine_out_of_range` para una especificación fuera de sus límites.
- `400 app_not_found` (`slugs`) para un slug desconocido en `uses`.
- `409 app_capability_unsupported` (`capability`) para una llamada que la App no
  ofrece.
- Un despliegue: `400 app_config_invalid` (`issues`), `400 app_volumes_unsupported`.
  Una reversión: `409 release_not_ready`.
- Máquinas y 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`.
- Volúmenes: `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()` responde con `Cache-Control: no-store`.
