Make each peer file card a flex column filling its grid cell (flex flex-col h-full) and pin the body row (filename + Play/Download) with mt-auto, so cards with a media preview and cards without line their footers up across the row. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
413 lines
15 KiB
Markdown
413 lines
15 KiB
Markdown
# Archipelago App Developer Guide
|
|
|
|
Build and package containerized apps for Archipelago.
|
|
|
|
## Overview
|
|
|
|
Apps run as rootless Podman containers on user nodes. You describe an app in `apps/<app-id>/manifest.yml`; the backend validates that manifest, compiles it into rootless container/runtime behavior, and the release pipeline generates catalog surfaces from the same manifest-owned metadata.
|
|
|
|
Archipelago's app contract is deliberately manifest-first. A developer should be able to describe images or local builds, ports, volumes, generated files, dependencies, health/readiness, data ownership, networking, secrets, and supported bridge integrations in the app manifest without asking for a custom OS image or app-specific backend patch. When a real app needs a capability that is not represented yet, the preferred path is to add a reusable manifest/orchestrator primitive that other apps can use too.
|
|
|
|
The historical marketplace-publish design is not the active local developer contract for `1.8-alpha`. For this release, local manifests are the source of truth and catalog JSON is generated from them.
|
|
|
|
## App Manifest
|
|
|
|
Every app needs a manifest at `apps/<app-id>/manifest.yml`. The root key is `app`; runtime, catalog, and integration fields live below that key.
|
|
|
|
### Template Manifest
|
|
|
|
```yaml
|
|
# apps/my-app/manifest.yml
|
|
app:
|
|
id: my-app # Unique, lowercase kebab-case
|
|
name: My App
|
|
version: 1.0.0 # Semantic versioning
|
|
description: My App does one thing well.
|
|
|
|
container:
|
|
image: docker.io/myorg/my-app:1.0.0
|
|
pull_policy: if-not-present
|
|
network: archy-net
|
|
entrypoint: ["sh", "-lc"]
|
|
custom_args:
|
|
- /app/start.sh
|
|
derived_env:
|
|
- key: PUBLIC_URL
|
|
template: https://{{HOST_MDNS}}:8180
|
|
secret_env:
|
|
- key: APP_PASSWORD
|
|
secret_file: my-app-password
|
|
|
|
dependencies:
|
|
- storage: 1Gi
|
|
|
|
resources:
|
|
cpu_limit: 2
|
|
memory_limit: 512Mi
|
|
|
|
security:
|
|
capabilities: []
|
|
readonly_root: true
|
|
no_new_privileges: true
|
|
network_policy: isolated
|
|
|
|
ports:
|
|
- host: 8180
|
|
container: 8080
|
|
protocol: tcp
|
|
|
|
volumes:
|
|
- type: bind
|
|
source: /var/lib/archipelago/my-app
|
|
target: /data
|
|
options: [rw]
|
|
|
|
environment:
|
|
- APP_MODE=production
|
|
|
|
health_check:
|
|
type: http
|
|
endpoint: http://localhost:8080
|
|
path: /health
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
|
|
files:
|
|
- path: /var/lib/archipelago/my-app/config.yml
|
|
content: |
|
|
bind: 0.0.0.0:8080
|
|
overwrite: false
|
|
|
|
metadata:
|
|
icon: /assets/img/app-icons/my-app.svg
|
|
category: tools
|
|
tier: optional
|
|
repo: https://github.com/myorg/my-app
|
|
launch:
|
|
open_in_new_tab: false
|
|
```
|
|
|
|
### Required Fields
|
|
|
|
| Field | Description |
|
|
|-------|-------------|
|
|
| `app.id` | Unique identifier, lowercase, kebab-case only |
|
|
| `app.name` | Human-readable name |
|
|
| `app.version` | Version string containing at least one digit; semantic versions are preferred |
|
|
| `container.image` or `container.build` | Exactly one image source must be present |
|
|
| `security.readonly_root` | Should remain `true` for normal apps |
|
|
| `security.no_new_privileges` | Should remain `true` for normal apps |
|
|
|
|
### Current Manifest Fields
|
|
|
|
| Field | Purpose |
|
|
|-------|---------|
|
|
| `app.id`, `app.name`, `app.version`, `app.description` | App identity and release metadata |
|
|
| `app.container.image` | Registry image to pull |
|
|
| `app.container.build` | Local build definition with `context`, `dockerfile`, `tag`, and optional `build_args` |
|
|
| `app.container.pull_policy` | Pull behavior, usually `if-not-present` |
|
|
| `app.container.network` | Podman network setting such as `archy-net` or `pasta`; dangerous namespace-sharing modes are rejected |
|
|
| `app.container.entrypoint` / `custom_args` | Entrypoint and command override |
|
|
| `app.container.derived_env` | Environment values rendered from allowed host facts such as `HOST_IP`, `HOST_MDNS`, and `DISK_GB` |
|
|
| `app.container.secret_env` | Environment values read from `/var/lib/archipelago/secrets/<secret_file>` |
|
|
| `app.container.data_uid` | UID:GID ownership repair for app data directories |
|
|
| `app.dependencies` | Storage requirements and app dependencies |
|
|
| `app.resources` | CPU, memory, and disk limits |
|
|
| `app.security` | Capabilities, read-only root, no-new-privileges, network policy, optional AppArmor profile |
|
|
| `app.ports` | Host-to-container port mappings |
|
|
| `app.volumes` | `bind`, `volume`, or `tmpfs` mounts |
|
|
| `app.files` | Generated files under declared bind-mounted host paths |
|
|
| `app.environment` | Static `KEY=value` environment entries |
|
|
| `app.health_check` | HTTP or TCP health check settings |
|
|
| `app.devices` | Explicit device paths |
|
|
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints |
|
|
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
|
|
|
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
|
|
|
|
Use `metadata.launch.open_in_new_tab: true` when the app UI is known to reject iframe embedding with headers such as `X-Frame-Options` or restrictive CSP. The frontend app-session metadata is generated from this flag during release work.
|
|
|
|
### Launch Interfaces
|
|
|
|
If an app exposes a user-facing web UI, declare its primary launch surface in
|
|
`interfaces.main`. Runtime package listings prefer this interface over inferred
|
|
port mappings, which matters for apps that expose non-UI service ports or use a
|
|
companion wait/proxy UI.
|
|
|
|
```yaml
|
|
interfaces:
|
|
main:
|
|
name: Web UI
|
|
description: Primary app interface
|
|
type: ui
|
|
port: 8180
|
|
protocol: http
|
|
path: /
|
|
```
|
|
|
|
For simple HTTP apps without `interfaces.main`, Archipelago can still infer the
|
|
launch URL from the first declared TCP host port when the app has an HTTP health
|
|
check. TCP-only service ports, such as Bitcoin RPC/P2P, are not treated as UI
|
|
launch URLs.
|
|
|
|
Interface keys must use lowercase ASCII letters, digits, hyphens, or
|
|
underscores. Supported interface types are `ui`, `api`, and `metrics`; only
|
|
`type: ui` is treated as a launchable app surface. Supported protocols are
|
|
`http` and `https`, and `path` must start with `/`.
|
|
|
|
### Nostr Signer Bridge (NIP-07)
|
|
|
|
Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign
|
|
events without managing their own keys. Archipelago injects a **NIP-07 provider**
|
|
(`window.nostr` with `getPublicKey()` / `signEvent()` / `nip04` / `nip44`) that bridges
|
|
to the host. Your app code uses standard NIP-07 — no Archipelago-specific API.
|
|
|
|
**How injection works.** After install, the host copies `nostr-provider.js` into the
|
|
app container and patches the app's web server so every page loads it and the app is
|
|
iframe-embeddable. This is **best-effort** and depends on your server config exposing
|
|
the right hooks. For an **nginx-served SPA** (the supported reference shape, e.g.
|
|
IndeeHub) your `nginx.conf` must satisfy this contract:
|
|
|
|
1. **Be iframe-embeddable.** Do not send a hard `X-Frame-Options: DENY`. The host
|
|
strips a `SAMEORIGIN`/`DENY` `X-Frame-Options` header line if present; restrictive
|
|
CSP `frame-ancestors` will still block embedding.
|
|
2. **Keep an exact-match `location = /sw.js {` block.** The provider's no-cache
|
|
`location = /nostr-provider.js` block is inserted immediately before it.
|
|
3. **Keep an SPA fallback line `try_files $uri $uri/ /index.html;`.** A
|
|
`sub_filter` that injects `<script src="/nostr-provider.js"></script>` before
|
|
`</head>` is inserted right after it. (nginx must have `ngx_http_sub_module` —
|
|
stock `nginx:alpine` does.)
|
|
4. **If you proxy an API that does NIP-98 URL verification**, expose
|
|
`proxy_set_header X-Forwarded-Prefix /api;`; the host rewrites it to honor the
|
|
outer reverse proxy's prefix.
|
|
|
|
The patch is **idempotent** (it checks for an existing `nostr-provider` reference
|
|
before editing) and re-runs on reinstall. If you rename or remove any of the anchor
|
|
strings above, injection silently no-ops and `window.nostr` will be undefined in your
|
|
app — so guard those lines in your config (see the contract comment block at the top of
|
|
IndeeHub's `nginx.conf` for a template).
|
|
|
|
> Non-nginx servers (Next.js `node server.js`, etc.) are not auto-patched today. Either
|
|
> serve via nginx, or ship `nostr-provider.js` yourself and reference it in your HTML;
|
|
> the canonical script lives at `/opt/archipelago/web-ui/nostr-provider.js` on the node.
|
|
|
|
Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):
|
|
|
|
```yaml
|
|
metadata:
|
|
launch:
|
|
open_in_new_tab: false # default; set true only if the app cannot be iframed
|
|
```
|
|
|
|
## Security Requirements
|
|
|
|
These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged.
|
|
|
|
### Mandatory
|
|
|
|
1. **No `:latest` tag** — Pin a specific version: `myapp:1.0.0`
|
|
2. **Read-only root filesystem** — `security.readonly_root: true` (use volumes for writable data)
|
|
3. **No privilege escalation** — `security.no_new_privileges: true`
|
|
4. **Minimal capabilities** — Drop all caps, only add required ones
|
|
5. **No host network unless explicitly approved** — keep `security.network_policy` isolated or bridge
|
|
|
|
### Allowed Capabilities
|
|
|
|
The parser currently accepts this allow-list. Keep capability requests minimal; some accepted capabilities still require release review before a public package should depend on them.
|
|
|
|
| Capability | When Needed |
|
|
|-----------|-------------|
|
|
| `CHOWN` | App needs to change file ownership |
|
|
| `DAC_OVERRIDE` | App needs to bypass file permissions |
|
|
| `FOWNER` | App needs ownership-related file operations |
|
|
| `NET_ADMIN` | Network administration; requires extra scrutiny |
|
|
| `NET_BIND_SERVICE` | App binds to ports below 1024 |
|
|
| `NET_RAW` | Raw network sockets; requires extra scrutiny |
|
|
| `SETUID`, `SETGID` | App manages user switching |
|
|
| `SYS_ADMIN` | Broad administrative capability; avoid for normal apps |
|
|
|
|
### Forbidden
|
|
|
|
- Namespace-sharing network modes such as `container:<name>` or `ns:<path>`
|
|
- Mounting system paths: `/`, `/etc`, `/var`, `/usr`, `/proc`, `/sys`
|
|
- `SYS_PTRACE`, privileged containers, Docker socket mounts, or rootful execution
|
|
- Hardcoded secrets in environment variables or images
|
|
|
|
## Container Best Practices
|
|
|
|
### Volumes
|
|
|
|
```yaml
|
|
volumes:
|
|
- type: bind
|
|
source: /var/lib/archipelago/my-app
|
|
target: /data
|
|
options: [rw]
|
|
```
|
|
|
|
Data is stored at `/var/lib/archipelago/{app-id}/` on the host.
|
|
|
|
Generated files must live under a declared bind-mounted host path:
|
|
|
|
```yaml
|
|
files:
|
|
- path: /var/lib/archipelago/my-app/config.yml
|
|
content: |
|
|
bind: 0.0.0.0:8080
|
|
overwrite: false
|
|
```
|
|
|
|
Use `overwrite: false` for first-run defaults that users or the app may later modify. Use `overwrite: true` only for generated files the platform must own.
|
|
|
|
### Health Checks
|
|
|
|
Define a health check endpoint in your container:
|
|
|
|
```dockerfile
|
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
|
CMD curl -f http://localhost:8080/health || exit 1
|
|
```
|
|
|
|
### Logging
|
|
|
|
- Log to stdout/stderr (Podman captures container logs)
|
|
- Never log secrets, passwords, or keys
|
|
- Use structured logging (JSON) for machine parsing
|
|
|
|
### Networking
|
|
|
|
Apps get their own network namespace. To connect to other Archipelago apps:
|
|
|
|
```yaml
|
|
# If your app needs to talk to Bitcoin
|
|
dependencies:
|
|
- bitcoin-knots
|
|
|
|
container:
|
|
network: archy-net
|
|
derived_env:
|
|
- key: BITCOIN_RPC_HOST
|
|
template: bitcoin-knots
|
|
- key: BITCOIN_RPC_PORT
|
|
template: "8332"
|
|
```
|
|
|
|
The `archy-net` Podman network provides DNS resolution between containers. Use `derived_env` for host facts like `HOST_MDNS` instead of hardcoding node-specific URLs.
|
|
|
|
## Catalog Generation
|
|
|
|
Catalog JSON is generated from manifests during release work. Do not manually edit generated fields in `app-catalog/catalog.json` or `neode-ui/public/catalog.json` when the same value belongs in the manifest.
|
|
|
|
Manifest-owned catalog fields currently include:
|
|
|
|
- app title from `app.name`;
|
|
- version from `app.version`;
|
|
- description from `app.description`;
|
|
- Docker image from `app.container.image`;
|
|
- category from `app.category` or `app.metadata.category`;
|
|
- tier from `app.metadata.tier`;
|
|
- icon from `app.metadata.icon`;
|
|
- repo URL from `app.metadata.repo`, `repoUrl`, or `source`.
|
|
|
|
### 1. Build and Push Your Image
|
|
|
|
```bash
|
|
podman build -t docker.io/myorg/my-app:1.0.0 .
|
|
podman push docker.io/myorg/my-app:1.0.0
|
|
```
|
|
|
|
### 2. Generate Catalogs
|
|
|
|
```bash
|
|
python3 scripts/generate-app-catalog.py
|
|
```
|
|
|
|
### 3. Verify Drift
|
|
|
|
```bash
|
|
python3 scripts/check-app-catalog-drift.py --release --strict
|
|
```
|
|
|
|
Before release, the canonical catalog and UI public catalog should match:
|
|
|
|
```bash
|
|
cmp -s app-catalog/catalog.json neode-ui/public/catalog.json
|
|
```
|
|
|
|
## Testing Your App
|
|
|
|
### Local Testing
|
|
|
|
```bash
|
|
# Run your container locally
|
|
podman run -d --name my-app \
|
|
-p 8180:8080 \
|
|
--read-only \
|
|
--security-opt no-new-privileges \
|
|
--user 1000:1000 \
|
|
docker.io/myorg/my-app:1.0.0
|
|
|
|
# Verify it works
|
|
curl http://localhost:8180/health
|
|
|
|
# Check logs
|
|
podman logs my-app
|
|
```
|
|
|
|
### On an Archipelago Node
|
|
|
|
1. Install via the marketplace UI or RPC:
|
|
```bash
|
|
curl -b cookies.txt -X POST http://192.168.1.228/rpc/v1 \
|
|
-d '{"method":"package.install","params":{"id":"my-app","dockerImage":"docker.io/myorg/my-app:1.0.0"}}'
|
|
```
|
|
2. Verify the container is running:
|
|
```bash
|
|
curl -b cookies.txt -X POST http://192.168.1.228/rpc/v1 \
|
|
-d '{"method":"container-list"}'
|
|
```
|
|
3. Check the UI at `http://192.168.1.228/app/my-app/`
|
|
|
|
### Validate Manifest
|
|
|
|
```bash
|
|
cargo test --manifest-path core/Cargo.toml -p archipelago-container
|
|
python3 scripts/check-app-catalog-drift.py --release --strict
|
|
```
|
|
|
|
## Updating Your App
|
|
|
|
1. Build and push the new version: `docker.io/myorg/my-app:1.1.0`.
|
|
2. Update `app.version` and `app.container.image` or `app.container.build.tag`.
|
|
3. Run catalog generation and drift checks.
|
|
4. Validate install/start/stop/restart/uninstall/reinstall behavior before shipping.
|
|
|
|
The broader app update policy for `1.8-alpha` is still being finalized. Until that policy is locked, app manifests should be explicit and pinned so update detection compares concrete image/tag metadata rather than mutable tags.
|
|
|
|
## App Icon
|
|
|
|
- Provide a URL to your app icon (PNG, WebP, or SVG)
|
|
- Recommended size: 256x256 pixels
|
|
- Square aspect ratio
|
|
- If no icon URL, a generic placeholder is shown in the marketplace
|
|
|
|
## Release Validation Expectations
|
|
|
|
Every supported app must satisfy the lifecycle contract:
|
|
|
|
- install
|
|
- launch
|
|
- stop
|
|
- start
|
|
- restart
|
|
- uninstall while preserving data
|
|
- reinstall with preserved data
|
|
- report truthful health/status
|
|
- survive backend restart
|
|
- survive host reboot
|
|
|
|
For apps with special dependencies, launch must explain dependency wait states instead of showing a dead iframe. Examples include Bitcoin sync/IBD, Lightning wallet readiness, Nostr signer bridge injection, Tailscale login/auth, and app-specific setup screens.
|
|
|
|
Runtime changes should be validated with focused tests first, then the release lifecycle harness on the validation host when host access is intentionally resumed.
|