archy/docs/app-developer-guide.md
archipelago aa9e0f02b7 fix(cloud): pin peer file-card filename + action buttons to the bottom (#11)
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>
2026-06-16 09:27:29 -04:00

15 KiB

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

# 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.

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

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 filesystemsecurity.readonly_root: true (use volumes for writable data)
  3. No privilege escalationsecurity.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

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:

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:

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:

# 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

podman build -t docker.io/myorg/my-app:1.0.0 .
podman push docker.io/myorg/my-app:1.0.0

2. Generate Catalogs

python3 scripts/generate-app-catalog.py

3. Verify Drift

python3 scripts/check-app-catalog-drift.py --release --strict

Before release, the canonical catalog and UI public catalog should match:

cmp -s app-catalog/catalog.json neode-ui/public/catalog.json

Testing Your App

Local Testing

# 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:
    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:
    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

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.