|
| 1 | +<!-- |
| 2 | + - SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors |
| 3 | + - SPDX-License-Identifier: AGPL-3.0-or-later |
| 4 | +--> |
| 5 | + |
| 6 | +# The ExApp manifest (`<external-app>`) reference |
| 7 | + |
| 8 | +A detailed runbook (a spoke of [`../../AGENTS.md`](../../AGENTS.md)). Read this when writing an ExApp's |
| 9 | +`info.xml`, preparing a `--json-info` payload, or debugging why a route, env var, or mount did not behave as |
| 10 | +expected. The runtime side (env vars AppAPI injects, init/heartbeat, auth headers) is in the hub's |
| 11 | +"Runtime and the ExApp contract" section. |
| 12 | + |
| 13 | +Parsing lives in `ExAppService::getAppInfo()` (`lib/Service/ExAppService.php`); route validation in |
| 14 | +`lib/Service/ExAppRouteHelper.php`. Invalid route definitions abort registration with a descriptive error. |
| 15 | + |
| 16 | +## The element tree |
| 17 | + |
| 18 | +An ExApp's `info.xml` is a normal Nextcloud app manifest plus an `<external-app>` section: |
| 19 | + |
| 20 | +```xml |
| 21 | +<external-app> |
| 22 | + <docker-install> |
| 23 | + <registry>ghcr.io</registry> <!-- default: docker.io --> |
| 24 | + <image>nextcloud/my-exapp</image> <!-- default: the app id --> |
| 25 | + <image-tag>latest</image-tag> <!-- default: latest --> |
| 26 | + </docker-install> |
| 27 | + |
| 28 | + <routes> |
| 29 | + <route> |
| 30 | + <url>^/api/.*</url> <!-- required; regex on the request path --> |
| 31 | + <verb>GET,POST</verb> <!-- required; comma list --> |
| 32 | + <access_level>USER</access_level> <!-- required; PUBLIC | USER | ADMIN (or 0|1|2) --> |
| 33 | + <bruteforce_protection>[401,429]</bruteforce_protection> <!-- optional; JSON int array --> |
| 34 | + <headers_to_exclude>["Cookie"]</headers_to_exclude> <!-- optional; JSON string array --> |
| 35 | + </route> |
| 36 | + </routes> |
| 37 | + |
| 38 | + <environment-variables> |
| 39 | + <variable> |
| 40 | + <name>MY_SETTING</name> <!-- key; required --> |
| 41 | + <display-name>My setting</display-name> |
| 42 | + <description>Shown in the UI</description> |
| 43 | + <default>some-value</default> <!-- becomes the value unless overridden --> |
| 44 | + </variable> |
| 45 | + </environment-variables> |
| 46 | + |
| 47 | + <k8s-service-roles> <!-- optional; Kubernetes multi-Deployment apps --> |
| 48 | + <role> |
| 49 | + <name>api</name> <!-- role id / Deployment suffix --> |
| 50 | + <display-name>API</display-name> <!-- optional; defaults to name --> |
| 51 | + <env>SERVICE_ROLE=api</env> <!-- extra env line for this role's container --> |
| 52 | + <expose>true</expose> <!-- true = gets a Kubernetes Service --> |
| 53 | + </role> |
| 54 | + </k8s-service-roles> |
| 55 | +</external-app> |
| 56 | +``` |
| 57 | + |
| 58 | +With `occ app_api:app:register --json-info`, the same keys are given as JSON (`docker-install`, `routes`, |
| 59 | +`k8s-service-roles` may sit at the JSON root). Note the naming split: elements are hyphenated |
| 60 | +(`docker-install`, `image-tag`, `display-name`), but route fields are underscored (`access_level`, |
| 61 | +`bruteforce_protection`, `headers_to_exclude`). |
| 62 | + |
| 63 | +Things the manifest does **not** declare: |
| 64 | + |
| 65 | +- **Port and secret**: AppAPI assigns a free `APP_PORT` and generates `APP_SECRET` at registration. |
| 66 | +- **API scopes**: removed from AppAPI; ExApps no longer declare scopes anywhere (`--force-scopes` is a |
| 67 | + deprecated no-op). Do not add a scopes element. |
| 68 | +- **Mounts**: there is no `<mounts>` element (see below). |
| 69 | + |
| 70 | +## Routes: access levels and enforcement |
| 71 | + |
| 72 | +Every HTTP surface the ExApp exposes must be covered by a `<route>`; unmatched requests are rejected. |
| 73 | + |
| 74 | +| Field | Meaning | |
| 75 | +|---|---| |
| 76 | +| `url` | Case-insensitive regex matched against the request path (e.g. `^/api/.*`) | |
| 77 | +| `verb` | Comma-separated HTTP methods the route accepts | |
| 78 | +| `access_level` | `PUBLIC` (0) anyone, `USER` (1) any logged-in user, `ADMIN` (2) admins only | |
| 79 | +| `bruteforce_protection` | JSON array of response status codes that count as a bruteforce attempt (e.g. `[401,429]`) | |
| 80 | +| `headers_to_exclude` | JSON array of request header names stripped before forwarding | |
| 81 | + |
| 82 | +Enforcement happens in two places, depending on the path a request takes: |
| 83 | + |
| 84 | +- **HaRP path** (browser to `/exapps/...`): AppAPI hands HaRP the route table (url + access_level + |
| 85 | + bruteforce_protection) via the ExApp metadata endpoint, and HaRP resolves the caller's level per request |
| 86 | + through the user-info endpoint (no/disabled user = PUBLIC, admin = ADMIN, else USER), enforcing the |
| 87 | + comparison itself. `verb` and `headers_to_exclude` are not part of the HaRP checks. |
| 88 | +- **PHP proxy path** (`/index.php/apps/app_api/proxy/...`): `ExAppProxyController` matches `url` (regex) and |
| 89 | + `verb`, enforces the access level, strips `headers_to_exclude`, and applies bruteforce throttling on the |
| 90 | + listed status codes. |
| 91 | + |
| 92 | +Authoring notes: an empty element (`<headers_to_exclude></headers_to_exclude>`) is fine and means "none", but |
| 93 | +nested sub-elements (`<bruteforce_protection><status>401</status></bruteforce_protection>`) are rejected; use |
| 94 | +the JSON-in-text form shown above. |
| 95 | + |
| 96 | +## Environment variables: a declared allow-list |
| 97 | + |
| 98 | +`<environment-variables>` is an **allow-list** with defaults, not free-form input: |
| 99 | + |
| 100 | +- Each declared `<variable>` starts with `value = default`. |
| 101 | +- `occ app_api:app:register --env NAME=VALUE` overrides **only declared names**; undeclared `--env` values are |
| 102 | + **silently dropped**. If the manifest has no `<environment-variables>` block at all, every `--env` is |
| 103 | + ignored. |
| 104 | +- Variables whose final value is an empty string are not passed to the container at all. |
| 105 | +- The surviving set is stored and replayed on `app:update` (no need to repeat `--env`). |
| 106 | + |
| 107 | +So "my `--env` did nothing" almost always means the variable is not declared in the manifest. |
| 108 | + |
| 109 | +## Mounts: CLI-only, Docker-only |
| 110 | + |
| 111 | +There is **no** mounts element in the manifest. `occ app_api:app:register --mount SRC:DST[:ro|rw]` |
| 112 | +(repeatable, default `rw`) is the only source of extra mounts, and every given mount is applied as a bind |
| 113 | +mount on Docker daemons. (The `--mount` help text mentions a manifest declaration; no such gate exists in the |
| 114 | +code today.) On **Kubernetes**, mounts are recorded with the deploy options but **not** mounted into pods; |
| 115 | +persistent data goes through the PVC/`APP_PERSISTENT_STORAGE` instead. Mounts are replayed on `app:update`. |
| 116 | + |
| 117 | +## Kubernetes service roles |
| 118 | + |
| 119 | +For multi-process ExApps on Kubernetes, each `<role>` becomes its **own Deployment** (same image, plus the |
| 120 | +role's `<env>` line). Only roles with `<expose>true</expose>` get a Kubernetes Service, and the **first** |
| 121 | +exposed role is the app's single entry point for HaRP routing; other roles are internal-only. An ExApp without |
| 122 | +roles gets one default Deployment. On Docker daemons the roles element is ignored. |
| 123 | + |
| 124 | +## Minimal working example (JSON form) |
| 125 | + |
| 126 | +The shape used by the project's own Kubernetes tests, as an `--json-info` payload: |
| 127 | + |
| 128 | +```json |
| 129 | +{ |
| 130 | + "id": "app-skeleton-python", |
| 131 | + "name": "App Skeleton Python", |
| 132 | + "version": "1.0.0", |
| 133 | + "docker-install": { |
| 134 | + "registry": "ghcr.io", |
| 135 | + "image": "nextcloud/app-skeleton-python", |
| 136 | + "image-tag": "latest" |
| 137 | + }, |
| 138 | + "k8s-service-roles": [ |
| 139 | + {"name": "api", "env": "SERVICE_ROLE=api", "expose": true}, |
| 140 | + {"name": "worker", "env": "SERVICE_ROLE=worker", "expose": false} |
| 141 | + ] |
| 142 | +} |
| 143 | +``` |
| 144 | + |
| 145 | +A complete XML reference manifest lives in the `nextcloud/test-deploy` repository (the app the admin UI's |
| 146 | +**Test deploy** button installs). For building the ExApp itself, see **nc_py_api** |
| 147 | +(https://github.com/cloud-py-api/nc_py_api). |
0 commit comments