Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
571 changes: 571 additions & 0 deletions AGENTS.md

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

# CLAUDE.md

Guidance for AI agents working in this repo lives in [`AGENTS.md`](AGENTS.md), the tool-agnostic convention.
This file exists only so Claude Code, which reads `CLAUDE.md`, picks that guidance up automatically through the
import below. Keep the real content in `AGENTS.md`; do not duplicate it here.

@AGENTS.md
58 changes: 58 additions & 0 deletions docs/appapi/aio.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

# ExApps on Nextcloud AIO

A short runbook (a spoke of [`../../AGENTS.md`](../../AGENTS.md)). Read this when the Nextcloud instance is
**AIO (all-in-one)**. AIO manages the deploy daemon for you: most of the manual setup in the `AGENTS.md`
Quickstart does not apply, and fighting the managed daemon is the main failure mode.

## What AIO automates

AppAPI detects AIO via the `THIS_IS_AIO` env var and auto-registers the deploy daemon during app
install/upgrade (`lib/DeployActions/AIODockerActions.php`, `lib/Migration/DataInitializationStep.php`):

- With AIO's **HaRP container** enabled (`HARP_ENABLED` + `HP_SHARED_KEY` set by AIO), AppAPI registers
**`harp_aio`** (`nextcloud-aio-harp:8780`, network `nextcloud-aio`, HaRP direct-connect mode) and makes it
the default. Exists on NC33+.
- With the legacy **Docker Socket Proxy** container, AppAPI registers **`docker_aio`**
(`nextcloud-aio-docker-socket-proxy:2375`). Deprecated since NC34, removal targeted for NC35.
- If both are enabled, `harp_aio` wins the default. Registration is idempotent and AppAPI never removes a
daemon it registered: after a DSP-to-HaRP migration, `docker_aio` stays behind; clean it up with
`occ app_api:daemon:unregister docker_aio` once no ExApps use it.
- AIO's own web server container routes `/exapps/` to HaRP internally, so the `AGENTS.md` Quickstart Step 4
proxy rule is only needed on a reverse proxy **in front of** AIO, if you run one.

## What the admin does

1. In the AIO interface, enable the **HaRP community container** (or add `harp` to the
`AIO_COMMUNITY_CONTAINERS` env var of the mastercontainer), then restart the AIO containers so the env
reaches the Nextcloud container.
2. That is all for the daemon: check **Settings --> Administration --> AppAPI** shows the `AIO HaRP` daemon,
and use **Check connection** / **Test deploy**.
3. Install ExApps normally (UI store, or `occ app_api:app:register <appid> --wait-finish`; on AIO run occ as
`docker exec -u www-data nextcloud-aio-nextcloud php occ ...`).

## What NOT to do on AIO

- Do **not** run your own HaRP container or hand-register another HaRP daemon; `harp_aio` is AIO-managed
(a second daemon competes with it, and re-registering the same name is a no-op).
- Do **not** edit `harp_aio`'s host or shared key by hand; AIO owns `HP_SHARED_KEY` and a mismatch breaks
Nextcloud-to-HaRP auth.
- Do not re-default the deprecated `docker_aio`; migrate to HaRP and unregister it.

## Troubleshooting

- **"AppAPI default deploy daemon is not set"** on AIO: the HaRP community container is not enabled or not
running, so `HARP_ENABLED`/`HP_SHARED_KEY` never reached the Nextcloud container and auto-registration
skipped itself. Enable the container, restart AIO, and re-check.
- **HaRP enabled but no `harp_aio` daemon**: the Nextcloud container was not recreated after enabling HaRP,
so the env vars are missing inside it. Restart/recreate the AIO containers; the (idempotent) registration
re-runs on the next app_api install/upgrade repair step.
- **ExApp installs fine but its page is blank / WebSocket fails, only behind your own proxy**: the reverse
proxy in front of AIO does not forward `/exapps/` (with WebSocket upgrade). The internal checks pass anyway;
add the rule from `AGENTS.md` Quickstart Step 4 pointing at AIO.

For everything else (lifecycle, troubleshooting, version notes) see [`../../AGENTS.md`](../../AGENTS.md).
147 changes: 147 additions & 0 deletions docs/appapi/exapp-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->

# The ExApp manifest (`<external-app>`) reference

A detailed runbook (a spoke of [`../../AGENTS.md`](../../AGENTS.md)). Read this when writing an ExApp's
`info.xml`, preparing a `--json-info` payload, or debugging why a route, env var, or mount did not behave as
expected. The runtime side (env vars AppAPI injects, init/heartbeat, auth headers) is in the hub's
"Runtime and the ExApp contract" section.

Parsing lives in `ExAppService::getAppInfo()` (`lib/Service/ExAppService.php`); route validation in
`lib/Service/ExAppRouteHelper.php`. Invalid route definitions abort registration with a descriptive error.

## The element tree

An ExApp's `info.xml` is a normal Nextcloud app manifest plus an `<external-app>` section:

```xml
<external-app>
<docker-install>
<registry>ghcr.io</registry> <!-- default: docker.io -->
<image>nextcloud/my-exapp</image> <!-- default: the app id -->
<image-tag>latest</image-tag> <!-- default: latest -->
</docker-install>

<routes>
<route>
<url>^/api/.*</url> <!-- required; regex on the request path -->
<verb>GET,POST</verb> <!-- required; comma list -->
<access_level>USER</access_level> <!-- required; PUBLIC | USER | ADMIN (or 0|1|2) -->
<bruteforce_protection>[401,429]</bruteforce_protection> <!-- optional; JSON int array -->
<headers_to_exclude>["Cookie"]</headers_to_exclude> <!-- optional; JSON string array -->
</route>
</routes>

<environment-variables>
<variable>
<name>MY_SETTING</name> <!-- key; required -->
<display-name>My setting</display-name>
<description>Shown in the UI</description>
<default>some-value</default> <!-- becomes the value unless overridden -->
</variable>
</environment-variables>

<k8s-service-roles> <!-- optional; Kubernetes multi-Deployment apps -->
<role>
<name>api</name> <!-- role id / Deployment suffix -->
<display-name>API</display-name> <!-- optional; defaults to name -->
<env>SERVICE_ROLE=api</env> <!-- extra env line for this role's container -->
<expose>true</expose> <!-- true = gets a Kubernetes Service -->
</role>
</k8s-service-roles>
</external-app>
```

With `occ app_api:app:register --json-info`, the same keys are given as JSON (`docker-install`, `routes`,
`k8s-service-roles` may sit at the JSON root). Note the naming split: elements are hyphenated
(`docker-install`, `image-tag`, `display-name`), but route fields are underscored (`access_level`,
`bruteforce_protection`, `headers_to_exclude`).

Things the manifest does **not** declare:

- **Port and secret**: AppAPI assigns a free `APP_PORT` and generates `APP_SECRET` at registration.
- **API scopes**: removed from AppAPI; ExApps no longer declare scopes anywhere (`--force-scopes` is a
deprecated no-op). Do not add a scopes element.
- **Mounts**: there is no `<mounts>` element (see below).

## Routes: access levels and enforcement

Every HTTP surface the ExApp exposes must be covered by a `<route>`; unmatched requests are rejected.

| Field | Meaning |
|---|---|
| `url` | Case-insensitive regex matched against the request path (e.g. `^/api/.*`) |
| `verb` | Comma-separated HTTP methods the route accepts |
| `access_level` | `PUBLIC` (0) anyone, `USER` (1) any logged-in user, `ADMIN` (2) admins only |
| `bruteforce_protection` | JSON array of response status codes that count as a bruteforce attempt (e.g. `[401,429]`) |
| `headers_to_exclude` | JSON array of request header names stripped before forwarding |

Enforcement happens in two places, depending on the path a request takes:

- **HaRP path** (browser to `/exapps/...`): AppAPI hands HaRP the route table (url + access_level +
bruteforce_protection) via the ExApp metadata endpoint, and HaRP resolves the caller's level per request
through the user-info endpoint (no/disabled user = PUBLIC, admin = ADMIN, else USER), enforcing the
comparison itself. `verb` and `headers_to_exclude` are not part of the HaRP checks.
- **PHP proxy path** (`/index.php/apps/app_api/proxy/...`): `ExAppProxyController` matches `url` (regex) and
`verb`, enforces the access level, strips `headers_to_exclude`, and applies bruteforce throttling on the
listed status codes.

Authoring notes: an empty element (`<headers_to_exclude></headers_to_exclude>`) is fine and means "none", but
nested sub-elements (`<bruteforce_protection><status>401</status></bruteforce_protection>`) are rejected; use
the JSON-in-text form shown above.

## Environment variables: a declared allow-list

`<environment-variables>` is an **allow-list** with defaults, not free-form input:

- Each declared `<variable>` starts with `value = default`.
- `occ app_api:app:register --env NAME=VALUE` overrides **only declared names**; undeclared `--env` values are
**silently dropped**. If the manifest has no `<environment-variables>` block at all, every `--env` is
ignored.
- Variables whose final value is an empty string are not passed to the container at all.
- The surviving set is stored and replayed on `app:update` (no need to repeat `--env`).

So "my `--env` did nothing" almost always means the variable is not declared in the manifest.

## Mounts: CLI-only, Docker-only

There is **no** mounts element in the manifest. `occ app_api:app:register --mount SRC:DST[:ro|rw]`
(repeatable, default `rw`) is the only source of extra mounts, and every given mount is applied as a bind
mount on Docker daemons. (The `--mount` help text mentions a manifest declaration; no such gate exists in the
code today.) On **Kubernetes**, mounts are recorded with the deploy options but **not** mounted into pods;
persistent data goes through the PVC/`APP_PERSISTENT_STORAGE` instead. Mounts are replayed on `app:update`.

## Kubernetes service roles

For multi-process ExApps on Kubernetes, each `<role>` becomes its **own Deployment** (same image, plus the
role's `<env>` line). Only roles with `<expose>true</expose>` get a Kubernetes Service, and the **first**
exposed role is the app's single entry point for HaRP routing; other roles are internal-only. An ExApp without
roles gets one default Deployment. On Docker daemons the roles element is ignored.

## Minimal working example (JSON form)

The shape used by the project's own Kubernetes tests, as an `--json-info` payload:

```json
{
"id": "app-skeleton-python",
"name": "App Skeleton Python",
"version": "1.0.0",
"docker-install": {
"registry": "ghcr.io",
"image": "nextcloud/app-skeleton-python",
"image-tag": "latest"
},
"k8s-service-roles": [
{"name": "api", "env": "SERVICE_ROLE=api", "expose": true},
{"name": "worker", "env": "SERVICE_ROLE=worker", "expose": false}
]
}
```

A complete XML reference manifest lives in the `nextcloud/test-deploy` repository (the app the admin UI's
**Test deploy** button installs). For building the ExApp itself, see **nc_py_api**
(https://github.com/cloud-py-api/nc_py_api).
Loading
Loading