---
title: "Apps"
description: "Erstelle, deploye und steuere Beeuni Apps über das SDK und React."
---

Apps sind projektgebundene Bereitstellungen mit jeweils einer stabilen URL,
unveränderlichen Releases, Maschinen pro Prozessgruppe und persistenten Volumes. Verzweige anhand von `app.capabilities`. Diese Seite behandelt die SDK-Schnittstelle: die
`apps`-Fassade auf einem Projekt-Handle, die Aufrufe für Artefakte, Releases, Maschinen und Volumes,
Anmeldetokens, die exportierten Typen und die React-Hooks.

Informationen zum Modell, zur Release-Konfiguration, zu den Quelltypen, zur CLI, zur stabilen URL
und zum Aufwecken aus dem Ruhezustand findest du unter [Apps](/docs/de/feature-flags/apps).

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

:::info[Apps ist ein Feature-Flag]
Jede Apps-Route antwortet mit `403` und `{ error, code: "feature_disabled", feature: "apps" }`,
bis das Projekt Apps aktiviert. Verwende `isFeatureDisabledError(error)`, um
anhand dessen zu verzweigen. Siehe [Feature-Flags](/docs/de/feature-flags).
:::

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

## Die apps-Fassade

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.list()` | `GET /projects/:pid/apps` | Listet die Apps auf, die der Aufrufer öffnen darf (ein Projektmanager sieht alle) |
| `apps.create(input)` | `POST …/apps` | Erstellt eine App und weist ihr eine stabile URL zu |
| `apps.get(appId)` | `GET …/apps/:id` | Liest eine App aus |
| `apps.update(appId, input)` | `PATCH …/apps/:id` | Benennt sie um oder ändert Maschine, Leerlaufzeitlimit, Betriebsmodus (`always_on`), `uses` oder `labels`. Der Betriebsmodus wird innerhalb von 5 Minuten wirksam; eine Maschinenänderung gilt ab dem nächsten Release |
| `apps.remove(appId)` | `DELETE …/apps/:id` | Löscht die App, ihre Maschinen und alle Release-Images; gibt `images: {released, pending}` zurück. Ihre Volumes behalten einen `final`-Snapshot für 7 Tage |
| `apps.start(appId)` | `POST …/apps/:id/start` | Setzt `desired_state` auf `running` und fährt die Laufzeitumgebung hoch. Eine statische App antwortet mit `409 static_app_no_runtime` |
| `apps.stop(appId)` | `POST …/apps/:id/stop` | Hält die Rechenleistung sofort an; die nächste Anfrage setzt sie fort. Eine statische App antwortet mit `409 static_app_no_runtime`: Sie hat keine Laufzeitumgebung und liefert Inhalte aus, solange sie eine aktive Bereitstellung hat |
| `apps.rollback(appId, deploymentId)` | `POST …/apps/:id/rollback` | Erstellt ein NEUES Release aus dem Artefakt und der Konfiguration eines bereiten Releases und rollt es wie eine Bereitstellung aus (`202`). Volumes behalten ihre Daten |

Artefakte sind die unveränderliche Eingabe für eine Bereitstellung:

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.artifacts.register(input)` | `POST …/apps/artifacts` | Registriert ein `archive` oder ein `oci_image`; gibt für ein Archiv die Upload-URL zurück |
| `apps.artifacts.uploadArchive(bytes, options?)` | — | Registriert, lädt hoch, hasht und finalisiert ein `.tar.gz` in einem einzigen Aufruf |
| `apps.artifacts.finalize(artifactId, input)` | `POST …/apps/artifacts/:id/finalize` | Bestätigt `sha256` und `size_bytes` für einen manuellen Upload |

Releases (Bereitstellungen) sind unveränderlich und nummeriert:

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.deployments.create(appId, input)` | `POST …/apps/:id/deployments` | Startet ein Release aus einem Artefakt, einer Quelle und einem optionalen Release-`config` |
| `apps.deployments.list(appId)` | `GET …/apps/:id/deployments` | Listet den Bereitstellungsverlauf auf |
| `apps.deployments.get(appId, deploymentId)` | `GET …/deployments/:did` | Liest eine Bereitstellung samt ihren Ereignissen aus |
| `apps.deployments.logs(appId, deploymentId, options?)` | `GET …/deployments/:did/logs` | Liest Laufzeitprotokolle mit einem Cursor aus |
| `apps.deployments.remove(appId, deploymentId)` | `DELETE …/deployments/:did` | Löscht eine Bereitstellung und ihr Image; `409` bei der aktiven Bereitstellung oder einem laufenden Build. Gibt `image: 'released' \| 'pending' \| 'none'` zurück |

Maschinen, Skalierung und exec (eine Server-App; eine App ohne Maschinen antwortet mit
`409 app_no_machines`):

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.machines.list(appId)` | `GET …/apps/:id/machines` | Jede Maschine: `machine_id`, `process_group`, `state`, `release`, `vm`, `volume` |
| `apps.machines.start/stop/restart(appId, machineId)` | `POST …/machines/:mid/{start,stop,restart}` | Führt eine Aktion auf einer Maschine aus; liefert die Maschinenliste zurück |
| `apps.machines.destroy(appId, machineId)` | `DELETE …/machines/:mid` | Zerstört eine Maschine. Ihr Volume bleibt erhalten; ihre Gruppe betreibt eine Maschine weniger |
| `apps.scale(appId, count)` | `PUT …/apps/:id/scale` | Maschinen pro Prozessgruppe (`{ worker: 3 }`, 0–20), im Hintergrund angewendet (`202`). Liefert `scale` und `machines` zurück |
| `apps.exec(appId, { command, machine_id?, timeout_s? })` | `POST …/apps/:id/exec` | Führt einen Befehl in einer laufenden Maschine aus (`project.app.admin`; auditprotokolliert). `timeout_s` 1–300, Standard 60. Liefert `exit_code`, `stdout`, `stderr` (jeweils 1 MiB), `truncated` zurück |

Volumes (persistente Datenträger):

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.volumes.list(appId)` | `GET …/apps/:id/volumes` | Jedes Volume: `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` | Erstellt ein Volume vorab; eine spätere Maschine seiner Gruppe mit einem Mount dieses Namens übernimmt es |
| `apps.volumes.update(appId, volumeId, { size_gb?, snapshot_retention_days? })` | `PATCH …/volumes/:vid` | Vergrößert ein Volume (verkleinert es nie; höchstens 100 GB) oder ändert seine Aufbewahrungsdauer (1–60 Tage) |
| `apps.volumes.destroy(appId, volumeId, confirmName)` | `DELETE …/volumes/:vid?confirm=<name>` | `project.app.admin`. Wird mit `409 volume_attached` abgelehnt, solange eine Maschine es eingebunden hat. Behält einen `final`-Snapshot für 7 Tage |
| `apps.volumes.snapshots(appId, volumeId)` | `GET …/volumes/:vid/snapshots` | `daily`-, `manual`- und `final`-Snapshots |
| `apps.volumes.snapshot(appId, volumeId)` | `POST …/volumes/:vid/snapshots` | Ein `manual`-Snapshot jetzt |
| `apps.volumes.restore(appId, volumeId, snapshotId)` | `POST …/volumes/:vid/restore` | `project.app.admin`. Die Maschine stoppt, das Volume wird auf den Snapshot zurückgesetzt, die Maschine startet darauf (`202`) |
| `apps.volumes.fork(appId, volumeId, { name, snapshot_id? })` | `POST …/volumes/:vid/fork` | Ein neues, nicht angehängtes Volume in derselben App, aus einem Snapshot oder dem aktuellen Inhalt |

Anmeldetokens (Capability `member_tokens`; `409 app_capability_unsupported`,
wenn sie der App fehlt):

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.token(appId)` | `POST …/apps/:id/token` | Ein 15 Minuten gültiges Anmeldetoken für die App, das den Aufrufer benennt |

Access ist die eigene Autorisierungsrichtlinie der App:

| Methode | Kapselt | Funktion |
|---|---|---|
| `apps.access.get(appId)` | `GET …/apps/:id/access` | Liest die Richtlinie aus. Erfordert `project.app.read` |
| `apps.access.update(appId, input)` | `PATCH …/apps/:id/access` | Ersetzt die Richtlinie und erhöht ihre Revision |
| `apps.access.session(appId)` | `POST …/apps/:id/access-session` | Erstellt eine fünf Minuten gültige URL, die gegen ein Host-only-Cookie eingetauscht wird |

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

## Eine statische Website bereitstellen

`uploadArchive` übernimmt den gesamten Artefakt-Handshake: Es registriert das Artefakt,
prüft es gegen `max_bytes`, überträgt die Bytes per `PUT`, berechnet den SHA-256-Hash und
finalisiert es.

```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` akzeptiert auch die Maschinenfelder: `cpu`, `memory_gb`,
`disk_gb`, `idle_timeout_seconds` und `always_on`. Lass sie weg, um die Standardwerte zu verwenden.
Apps haben kein monatliches Budget: Kontoguthaben bezahlt die Rechenleistung. Eine dauerhaft aktive
Server-App wird rund um die Uhr abgerechnet; eine bedarfsgesteuerte nur, solange sie aktiv ist.
`always_on: true` betreibt eine Server-App rund um die Uhr
(WebSockets, Hintergrundschleifen); `false` stoppt sie nach `idle_timeout_seconds`
und weckt sie bei der nächsten Anfrage auf. Eine statische App hat keine Laufzeitumgebung: `always_on` ist
`false`.

Die API ignoriert `monthly_budget_usd`, wenn ein älterer Client es sendet (`200`, kein
Fehler). Keine App-Antwort enthält es.

`uses` listet anhand ihres Slugs die Apps auf,
für die diese App Anmeldetokens erhalten und die sie über ihren Bindings-Mount erreichen darf; die
Standardeinstellung ist keine, und `update(appId, { uses })` ersetzt die Liste (siehe
[Apps, die eine App verwendet](/docs/de/feature-flags/apps#apps-an-app-uses-sign-in-tokens-and-bindings)).

`create` und `update` geben `warnings` zurück, das immer `[]` ist.

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

Warte auf die Bereitstellung, indem du ihren Status regelmäßig abfragst:

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

Die Statuswerte sind `queued`, `validating`, `building`, `provisioning`,
`checking`, `ready`, `failed` und `cancelled`.

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

## Ein OCI-Image bereitstellen

Registriere die unveränderliche Image-Referenz und deklariere anschließend den Prozessbefehl und
den öffentlichen Zielport:

```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` gibt für ein `oci_image` den Wert `upload: null` zurück. Nur ein `archive` erhält eine
Upload-URL.

`CreateAppDeploymentInput` akzeptiert außerdem `environment` (nicht geheime Laufzeitwerte),
`secrets` (Laufzeitschlüssel zu Projekt-Secret-Namen) und `config` (`AppReleaseConfigInput`, die
Release-Konfiguration). Ein ungültiges `config`
antwortet mit `400 app_config_invalid` und `issues: [{ path, message }]`; ein Mount
in einer Laufzeitumgebung ohne Volumes antwortet mit `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>

## Laufzeitprotokolle auslesen

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

Jeder Eintrag enthält `cursor`, `time`, `source` (`app`, `appd`, `caddy`) und
`line`. Eine `caddy`-Zeile ist das Zugriffsprotokoll: Methode, URI, Status, Dauer und
Response-Header. Sie enthält niemals Request-Header, da diese die
Zugangsdaten des Gates enthalten (das Viewer-Token und die Identität, das Ingress-Token des Providers).

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

## Maschinen, Skalierung und Volumes

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

Eine Maschine behält ihr `machine_id` über Releases hinweg; jedes Release ersetzt ihre
Box (`instance_id`). Eine Maschine bindet jeweils ein Volume ein. Eine Wiederherstellung ist eine
neue Version des Volumes: Die ersetzten Inhalte bleiben in dessen Verlauf erhalten. Ein
Snapshot eines eingebundenen Volumes kann die letzten etwa 1 s an Schreibvorgängen
verpassen.

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

## Ein Convex-Backend

Ein Convex-Backend ist eine gewöhnliche Server-App mit einem Volume, erstellt aus der
Vorlage `convex` (`beeuni apps launch --template convex --name db`; siehe
[Vorlage `convex`](/docs/de/feature-flags/apps#template-convex-a-convex-backend)).
Das SDK verfügt über keinen Convex-spezifischen Aufruf. Lies den Admin-Schlüssel mit
`exec` aus der Maschine aus und gib einer UI-App mit `uses` Zugriff auf das Backend:

```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` enthält die URLs des Backends: `http` (die Convex-Client-URL) und
`site` (HTTP-Aktionen).

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

## Deine App weiß bereits, wer sie betrachtet

Eine von Beeuni gehostete App wird von jemandem geöffnet, der sich bei Beeuni **bereits angemeldet** hat. Das
Apps-Gate authentifiziert die Person, bevor dein erstes Byte ausgeliefert wird. Deine App benötigt daher
keine eigene Anmeldung – kein zweites Passwort, keinen Zustimmungsbildschirm und keine Weiterleitung.

Im Browser:

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

Der Browser ruft `/_beeuni/api/v1/*` über den eigenen Origin deiner App auf, niemals
`https://api.beeuni.ai/v1` direkt: Die API beantwortet keine CORS-Preflight-Anfrage von
einem App-Origin, daher schlägt ein direkter Aufruf im Browser fehl. Das Gate leitet jeden
Aufruf als der angemeldete Betrachter mit dessen App-bezogenem Token an die Beeuni-API weiter. Regeln:

- Es benötigt `viewer_token_scope: 'api'`. Andernfalls antwortet es mit
  `403 viewer_api_disabled`.
- Es funktioniert nur für einen Betrachter, den das Gate angemeldet hat (Beeuni-Login oder Zugriffslink). Ohne einen solchen antwortet es mit `401 no_viewer_identity`.
- Es beantwortet eine Anfrage nur vom eigenen Origin der App. Eine Cross-Site-Anfrage beantwortet es mit `403 cross_site_request`.
- Es leitet `/v1/*` weiter, mit Ausnahme von `/v1/oauth/*` und MCP-Endpunkten, die mit `404` antworten. Das Cookie deiner App und ihr eigener `Authorization`-Header erreichen die API nie, und die API kann kein Cookie für deine App setzen.

Auf dem Server deiner App signiert das Gate die Identität in jede Anfrage:

```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` überprüft mit `BEEUNI_APP_VIEWER_SECRET` einen HMAC über den Header,
den Beeuni beim Deployment in deine App einschleust. Ein gefälschter Header wird niemals akzeptiert: Das Gate löscht jede vom Client gelieferte Kopie vor der Weiterleitung, und die Signatur wird mit einem pro App abgeleiteten Geheimnis erstellt.

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

### Wie viele Informationen deine App erhält

Eine Einstellung in der Zugriffsrichtlinie der App – **Settings → Access** in Beeuni,
`beeuni apps access <app> --viewer off|identity|api` oder `viewer_token_scope`
in `PATCH /projects/:id/apps/:appId/access`:

| Bereich | Deine App erhält |
|---|---|
| `identity` (Standard) | Die ID, E-Mail-Adresse, den Namen, das Bild und die Kontorolle des Betrachters sowie seine Beeuni-Gruppen (IDs und Namen, nur in diesem Konto der App) und ein `profile email`-Token. Ausreichend, um jeder Person ihre eigenen Daten zu zeigen und den Zugriff nach Gruppe zu steuern. |
| `api` | Das Obige sowie ein Token, das **als** diese Person auf der Beeuni-API agiert – begrenzt durch ihre eigene Rolle. |
| `off` | Nichts. |

Das Token ist niemals die Beeuni-Sitzung des Benutzers: Es ist eine Stunde gültig, enthält nur diese Bereiche, und jedes von einer App ausgestellte Token verfällt, wenn die App gelöscht oder ihre Zugriffsrichtlinie geändert wird.

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

### Das Viewer-Token verwenden

- **Lies es auf deinem Server für jede Anfrage aus.** Das Gate legt bei jeder weitergeleiteten Anfrage ein gültiges Token in `x-beeuni-app-viewer-token` ab. Sende es als `Authorization: Bearer` an die Beeuni-API – `createAppViewerBeeuni(request)` erledigt genau das. Erstelle einen Client pro Anfrage; speichere das Token nicht.
- **Nach einer Änderung der Zugriffsrichtlinie funktionieren alte Tokens nicht mehr.** Die nächste Anfrage durch das Gate enthält ein neues Token. Im Browser behebt `beeuniAppViewerToken()` das automatisch: Wenn die API das Token ablehnt, liest das SDK `/_beeuni/viewer` erneut aus und wiederholt den Aufruf einmal.
- **Die Rolle des Betrachters ist die Obergrenze.** Das Token kann genau das tun, was der Betrachter in Beeuni tun kann, niemals mehr. In einem Projekt mit Agent-Berechtigungen benötigt ein `member` eine Freigabe für den Agenten, den deine App startet: Ohne eine solche antwortet `POST /projects/:id/sessions` mit `403 no_agent_access`. Gewähre deinen Betrachtern den Agenten, nicht eine umfassendere Rolle.
- **Wer einen Betrachter hat.** Eine Person, die die App mit ihrem Beeuni-Login oder mit einem Zugriffslink öffnet, der sie als diese Person anmeldet, hat einen. Eine `password`-App hat keinen: Das Passwort beweist ein gemeinsames Geheimnis, nicht die Identität einer Person. Eine `public`-App hat nur für einen Besucher einen, der über einen Zugriffslink gekommen ist; die einfache URL bleibt anonym.
- **Es ist eine Zugangsdaten.** Beeuni lässt Request-Header aus dem Zugriffsprotokoll der App weg (`beeuni apps logs`). Protokolliere den Header nicht in deinem eigenen Code.

Eine App, die unter ihrer **eigenen Domain** (nicht `*.apps.beeuni.ai`) bereitgestellt wird, hat kein Gate davor – verwende dort stattdessen [Sign in with Beeuni](/docs/de/sdk/sign-in).

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

## Ein Mitglied, in jeder Laufzeitumgebung

Beeuni bestätigt dieselbe Person an drei Stellen: Der Browser fragt das Gate (`/_beeuni/viewer`), der Server der App liest den signierten Header, und eine App, die Anmeldetokens überprüft (ein Convex-Backend oder dein eigener Server), erhält ein von Beeuni signiertes Token. `readBeeuniMember` wandelt jede dieser Formen in ein einziges `BeeuniMember` um, und `requireBeeuniMember` entscheidet, wer fortfahren darf. Kein Framework und keine Abhängigkeit: Derselbe Code läuft im Browser, in Node, Bun, einem Worker und einer Convex-Funktion.

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

Rufe in React nicht `useConvexAuth()` auf: Unter einem einfachen `ConvexProvider` löst es einen Fehler aus (es benötigt `ConvexProviderWithAuth`, und `beeuniBinding` wird mit diesem Provider nicht überprüft). Lies das angemeldete Mitglied mit einer Abfrage aus:

```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` gibt für einen anonymen Aufrufer `null` zurück. Verwende `requireBeeuniMember` nur dort, wo die Funktion einen Fehler auslösen muss.

| Feld | Wert |
|---|---|
| `userId` | Beeuni-Benutzer-ID. Stabil; verwende sie als Schlüssel für Zeilen. |
| `email`, `name`, `picture` | Aus dem Profil des Mitglieds. `null`, wenn unbekannt. |
| `groups`, `groupIds` | Die Beeuni-Gruppen des Mitglieds in diesem Konto, nach Name und ID. |
| `role` | Kontorolle: `owner`, `admin` oder `member`. |
| `accountId`, `projectId` | Wo die App lebt. |

`requireBeeuniMember(claims, { groups, roles })` gibt das Mitglied zurück oder löst `BeeuniMemberError` mit `code` `unauthenticated` aus (niemand oder ein Token, das eine Prüfung nicht bestanden hat) oder `forbidden` (außerhalb der Gruppen oder Rollen). Jede Liste bedeutet „beliebiges Element davon“; eine Gruppe stimmt nach Name oder ID überein; eine leere Liste lässt niemanden zu.

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

### Anmeldetokens

Ein Aussteller pro Projekt signiert die Tokens jeder App: `iss` =
`<API origin>/v1/projects/<projectId>`, `aud` = die ID der App, für die das Token bestimmt ist, 15 Minuten gültig. Sein Schlüsselsatz ist öffentlich unter `<issuer>/jwks.json` und wird durch `<issuer>/.well-known/openid-configuration` benannt. Jede App enthält die Werte, die ihre Tokens überprüfen: `app.auth.issuer`, `app.auth.audience`, `app.auth.jwks_uri`. Ein Token enthält `sub`, `email`, `name`, `picture`, `groups`, `group_ids`, `role`, `account_id` und `project_id`; das Token eines Agenten enthält `kind: 'agent'` und keine Gruppen oder Rolle.

| Funktion | Zweck |
|---|---|
| `beeuniToken({ audience? })` | Ein Token-Abrufer (in der Form, die Echtzeit-Clients als Auth-Callback erwarten) für die App selbst (Standard) oder eine von ihr verwendete App. Liest `/_beeuni/token` auf der eigenen Origin der App. Wird bis kurz vor Ablauf zwischengespeichert; `{ forceRefreshToken: true }` fragt erneut ab. Liefert `null` ohne angemeldeten Betrachter und `null` plus eine Konsolenwarnung für eine App, die sie nicht verwendet (`403 app_not_linked`). Wirft nie einen Fehler. |
| `beeuniBinding(slug, { origin? })` | `{ url, token }` für eine App, die diese App verwendet: `url` = `<origin>/_beeuni/apps/<slug>` (HTTP und WebSocket, gleiche Origin, kein CORS), `token` = `beeuniToken({ audience: slug })`. |
| `verifyBeeuniToken(bearer, { issuer?, audience?, jwks? })` | Prüft die ES256-Signatur, den Ablauf (60 s Zeittoleranz), den Aussteller und die Zielgruppe. Standardwerte: `BEEUNI_AUTH_ISSUER`, `BEEUNI_AUTH_AUDIENCE`, `BEEUNI_AUTH_JWKS`. Beeuni injiziert alle drei in die Laufzeit jeder Server-App (Aussteller: das Projekt; Zielgruppe: die eigene ID der App; Schlüsselsatz: eine `data:`-URI), sodass `verifyBeeuniToken(bearer)` dort ohne Optionen funktioniert. Setze niemals `BEEUNI_AUTH_*` in `env` einer Server-App: Das Präfix `BEEUNI_` ist reserviert und `beeuni validate` lehnt es ab. Der Schlüsselsatz ist ein Objekt, JSON-Text, eine `data:`-URI oder eine HTTPS-URL (`auth.jwks_uri`, einmal pro Prozess abgerufen). Jeder Fehler wirft `BeeuniMemberError`, auch ein fehlender Schlüsselsatz oder eine fehlende Zielgruppe: Ein Projektschlüssel signiert die Tokens aller Apps, daher hält `aud` das Token einer anderen App fern. `audience: false` akzeptiert ein Token für jede App des Projekts. |
| `apps.token(appId)` | Ein Token aus der API (`POST …/apps/:id/token`), das den Aufrufer benennt. Eine Agentensitzung erhält eines, das ihren Agenten benennt. |

Ein Mitglied, das du aus dem Projekt oder einer Gruppe entfernst, verliert den Zugriff innerhalb einer Token-Lebensdauer; die App-Zugriffskontrolle weist es beim nächsten Laden der Seite ab. Beeuni-Gruppen erfordern den Enterprise-Tarif; Rollen funktionieren in jedem Tarif.

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

## Zugriff verwalten

```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` meldet `password_configured`, niemals das Passwort oder dessen
Hash. Setze ein Passwort mit `{ mode: 'password', password }`. Jede Aktualisierung
erhöht `revision` und widerruft damit bestehende App-Cookies.

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

## Typen

Alle folgenden Typen werden aus `@beeuni/sdk` exportiert.

| Typ | Inhalt |
|---|---|
| `App` | Identität, `capabilities`, `labels`, `scale`, `services` (`AppServiceSummary[]`), `machines` (`total`, `running`, `stopped`), `volumes` (`count`, `size_gb`), `config` (`AppReleaseConfig` des Live-Releases oder `null`), `uses`, `used_by`, `auth`, `url`, `access_mode`, `access_revision`, `desired_state`, `active_deployment_id`, `hosting_type` (des aktiven Releases: `static`, `sandbox` oder `null` vor dem ersten Deployment), `machine` (die Größe der bereitstellenden Gruppe im Live-Release), `idle_timeout_seconds`, `always_on`, `retained_deployments`, `warnings` (nur beim Erstellen und Aktualisieren; immer `[]`), `last_request_at`, `viewer_can_access` |
| `AppCapability` | `'deployments' \| 'rollback' \| 'preview' \| 'sleep' \| 'static' \| 'member_tokens' \| 'machines' \| 'scale' \| 'exec' \| 'volumes' \| 'snapshots'` |
| `AppReleaseConfig` · `AppReleaseConfigInput` | Die aufgelöste Release-Konfiguration (`processes`, `services`, `mounts`, `vm`, `deploy`) · die verfasste Form (`processes`, `http_service`, `services`, `mounts`, `vm`, `deploy` und die Kurzformen `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`, wenn das Host-Label zu lang wäre) |
| `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` (die App-ID), `jwks_uri`: die öffentlichen Werte zur Überprüfung der Anmeldetokens der App |
| `AppToken` | `token`, `expires_at` |
| `BeeuniTokenOptions` · `BeeuniTokenFetcher` · `BeeuniBinding` · `VerifyBeeuniTokenOptions` | Die obigen Hilfsfunktionen für Anmeldetokens |
| `AppDeployment` | `version`, `status`, `source_kind`, `hosting_type` (`static`: aus dem Speicher bereitgestellt, keine Laufzeit; `sandbox`: Maschinen), `hosting_provider`, `runtime_spec`, `build_spec`, `error_code`, `attempt_count`, `created_by`, `actor_type`, `source_session_id`, `config` (die aufgelöste Konfiguration des Releases), `strategy`, `release_command_status` (`running`, `succeeded`, `failed` oder `null`) |
| `AppRollback` | Der Wert, mit dem `apps.rollback` aufgelöst wird: `App` plus das eingereihte Release in `deployment` |
| `AppDeploymentDetail` | Ein `deployment` plus dessen `events`: Lebenszyklusereignisse und das Build-Log (`build_log`-Zeilen und ein `log_truncated`-Ereignis, wenn der Build mehr als 5.000 Zeilen ausgegeben hat) |
| `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` | Eine Log-Zeile und eine Seite plus `next_cursor` |

`apps.list()` und `useProjectApps` geben nur die Apps zurück, die der Aufrufer gemäß der Zugriffsrichtlinie der jeweiligen App öffnen darf (privat: der Ersteller; eingeschränkt: die aufgeführten Mitglieder und Gruppen; Projekt, Passwort und öffentlich: das Projektteam). Ein Projektmanager sieht jede App, sodass eine private App verwaltbar bleibt, wenn ihr Ersteller das Projekt verlässt. `viewer_can_access` ist daher bei jeder aufgeführten App `true`. Ein `get` einer App, die der Aufrufer nicht öffnen darf, antwortet mit `404`, nicht mit `403`. Behandle `undefined` als unbekannt, nicht als verweigert.

`AppAccessMode` ist eine ressourcenspezifische Sichtbarkeitseinstellung zusätzlich zum Rollenmodell, keine Rolle. `restricted` benennt Benutzer und Gruppen — dieselben Prinzipaltypen, die das Rollenmodell verwendet. Siehe
[Konten & Zugriff](/docs/de/accounts#per-feature-access-settings).

`DockerfileAppSource` und `OciImageAppSource` erfordern `command`. `port` ist der Port des primären Dienstes: Lass ihn nur bei einem reinen Worker-Release weg (einem `config` mit `processes` und ohne Dienst). `StaticAppSource` und `BundleAppSource` nehmen keines von beiden entgegen.

## React-Hooks

`@beeuni/sdk/react` exportiert sieben Hooks für Apps.

### useProjectApps(projectId)

Die Apps, die der Aufrufer öffnen darf, plus ihre Lebenszyklus-Mutationen. Jede Mutation invalidiert bei Erfolg den Bestand.

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

Der Hook gibt die Abfragefelder plus `create`, `update`, `start`, `stop` und
`remove` zurück.

### useAppDeployments(projectId, appId)

Der unveränderliche Deployment-Verlauf, der alle 5 s erneut abgerufen wird, damit ein laufender Build von selbst fortschreitet.

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

Beide Mutationen invalidieren die Deployment-Liste und den App-Bestand.

### useAppDeployment(projectId, appId, deploymentId)

Ein Deployment und seine Ereignisse, einschließlich Build-Log (`AppDeploymentDetail`). Der Hook fragt alle 2 s ab, bis das Deployment `ready`, `failed` oder `cancelled` ist. Übergib `null` als `deploymentId`, damit er inaktiv bleibt.

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

Ein Build behält seine ersten 2.500 und seine letzten 2.500 Zeilen. Zeilen werden in
Batches geschrieben, spätestens 1 s nach ihrer Ausgabe. Bei einem fehlgeschlagenen oder abgebrochenen Deployment
wird das Build-Log 14 Tage lang aufbewahrt.

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

Die Zugriffsrichtlinie und eine kurzlebige Zugriffssitzung. Beide Teile sind separate
Abfragen und jeweils optional.

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

| Option | Standard | `false` verwenden, wenn |
|---|---|---|
| `policy` | `true` | Die Oberfläche nur eine Vorschau der App zeigt. `GET …/access` ist ein administrativer Lesezugriff und antwortet mit `403` für einen Aufrufer ohne Projektmanager-Berechtigungen. |
| `session` | `true` | Der Aufrufer die App nicht öffnen darf. `app.viewer_can_access` übergeben (für eine aufgelistete App immer `true`). |

Ein Raster mit Apps, das beide Optionen auf `true` belässt, löst pro App einen Lesezugriff auf die Richtlinie und die Erstellung einer
Sitzung aus. Beide ergeben für ein Mitglied, das diese App nicht öffnen darf,
jeweils `403`.

### useAppMachines(projectId, appId)

Die Maschinen einer App, alle 5 s abgefragt, sowie `start`, `stop`, `restart`,
`destroy` (jeweils mit einem `machineId`) und `scale` (mit
`{ [group]: count }`). Jede Mutation aktualisiert die Maschinen und das App-
Inventar. `appMachinesKey(projectId, appId)` ist der Abfrageschlüssel.

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

### useAppVolumes(projectId, appId)

Die Volumes einer App sowie `snapshot` (`volumeId`), `restore`
(`{ volumeId, snapshotId }`), `fork` (`{ volumeId, name, snapshotId? }`),
`extend` (`{ volumeId, sizeGb }`) und `destroy` (`{ volumeId, name }`). Jede
Mutation aktualisiert die Volumes. `appVolumesKey(projectId, appId)` ist der Abfrage-
schlüssel.

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

### useAppVolumeSnapshots(projectId, appId, volumeId)

Die Snapshots eines Volumes. `null` als `volumeId` übergeben, damit die Abfrage inaktiv bleibt.

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

## Fehler

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

Weitere Antworten, die Sie behandeln sollten:

- `409` für einen doppelten Slug, `402` mit `app_quota_exceeded`, wenn das Konto
  sein App-Limit erreicht hat, und `400` mit `app_machine_out_of_range` für eine Spezifikation außerhalb der zulässigen Grenzen.
- `400 app_not_found` (`slugs`) für einen unbekannten Slug in `uses`.
- `409 app_capability_unsupported` (`capability`) für einen Aufruf, den die App nicht
  anbietet.
- Ein Deployment: `400 app_config_invalid` (`issues`), `400 app_volumes_unsupported`.
  Ein Rollback: `409 release_not_ready`.
- Maschinen und 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`.
- Volumes: `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()` antwortet mit `Cache-Control: no-store`.
