Service apps

Last updated 2026-09-21

A service app ships a binary that the box runs. The box supervises one process per (user, app), under the app's own uid, inside resource limits, with no network listener of its own: it speaks HTTP over a unix socket, and the box proxies /apps/<id>/svc/* to it with the caller's identity already verified.

This is where an app's data and background work belong. The panel or the companion app is the face.

Declare it

{
  "id": "dev.jan.notes",
  "name": "Notes",
  "version": "0.2.0",
  "entry": "index.html",
  "permissions": ["user:profile", "service:call", "files:write"],
  "server": {
    "bin": "svc",
    "args": [],
    "healthPath": "/healthz",
    "limits": { "memory": "256M", "cpu": 50, "pids": 64 }
  }
}
  • bin is a bundle-relative path to a 64-bit arm64 ELF. Both plum-dev validate and the store reject anything else.
  • healthPath defaults to /healthz. The box marks the service ready when it answers with a status below 500.
  • limits are optional. Defaults: 256 MiB memory, 50 % of one core, 64 pids. The box clamps what you declare into 32 MiB–1 GiB, 5 %–200 %, 8–256 pids. (cpu: 100 is one full core.)

Build it

CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags='-s -w' -o ../svc .

plum-dev init <name> --template server-go writes exactly that as build.sh, along with a working server/main.go. Run plum-dev validate before pushing; it parses the ELF header and tells you if you built for the wrong architecture.

The runtime contract

The box starts your binary with these environment variables:

Variable Meaning
PLUM_APP_SOCKET the unix socket to listen on — this is what /apps/<id>/svc/* reaches
PLUM_CTL_SOCKET the control socket back into the box
PLUM_APP_DATA_DIR your writable, persistent directory (also the working directory and HOME)
PLUM_APP_ID, PLUM_USER_ID, PLUM_APP_VERSION the execution context

The bundle directory is read-only. PLUM_APP_DATA_DIR survives updates. Your process runs as the app's own uid, so it cannot read the user's files directly — everything user-facing goes through the control socket.

A minimal service:

package main

import (
    "log"
    "net"
    "net/http"
    "os"

    "github.com/plum-networks/plum-sdk/plumsvc"
)

func main() {
    log.SetFlags(0) // the box timestamps every line

    sock := plumsvc.ServeSocket()
    if sock == "" {
        log.Fatal("PLUM_APP_SOCKET not set: this binary is started by the Plum supervisor")
    }
    _ = os.Remove(sock) // a stale socket from an unclean stop
    ln, err := net.Listen("unix", sock)
    if err != nil {
        log.Fatalf("listen %s: %v", sock, err)
    }
    defer ln.Close()

    ctl, err := plumsvc.New() // wraps PLUM_CTL_SOCKET
    if err != nil {
        log.Fatalf("control socket: %v", err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(200) })
    mux.HandleFunc("/whoami", func(w http.ResponseWriter, r *http.Request) {
        c := plumsvc.CallerOf(r) // X-Plum-* headers, injected by the box
        if !c.Has("user:profile") {
            http.Error(w, "need user:profile", http.StatusForbidden)
            return
        }
        p, err := ctl.User(r.Context())
        if err != nil {
            http.Error(w, err.Error(), http.StatusBadGateway)
            return
        }
        _, _ = w.Write([]byte(c.UserID + " " + p.DisplayName))
    })

    log.Printf("listening on %s", sock)
    if err := (&http.Server{Handler: mux}).Serve(ln); err != nil {
        log.Fatalf("serve: %v", err)
    }
}

go get github.com/plum-networks/plum-sdk/plumsvc — the module is stdlib-only.

Who is calling: the injected headers

The box strips every inbound X-Plum-*, X-Forwarded-*, Forwarded, X-Real-Ip and Cookie header, then sets its own. They cannot be forged.

Header Value
X-Plum-User-Id the authenticated user
X-Plum-Username their username
X-Plum-App-Id your app id
X-Plum-Perms JSON array of your app's effective permissions, sorted
X-Plum-Entitlements comma-separated active SKUs (always present, often empty)
X-Plum-Entitlements-Stale 1 only when the receipts are older than 48 hours

plumsvc.CallerOf(r) parses all of these into a Caller{UserID, Username, AppID, Perms, Mount} with a Has(perm) helper.

The control socket

PLUM_CTL_SOCKET is a unix socket owned by your uid, mode 0600 — ownership is the authentication, there is no token. It serves exactly seven routes:

Route Permission What
POST /files/publish files:write move a finished file out of your data dir into the user's storage ({"root":"files"\|"downloads","src","dest"}); a rename, so gigabytes cost nothing
GET /files/list?root=&path= files:read list the user's files ({"entries":[{name,path,is_dir,size,modified}]})
GET /files/read?root=&path= files:read stream one file's bytes
GET /user user:profile {"id","username","display_name","locale","timezone"}
POST /notify — raise a notification for the user ({"kind","title","body"}; title ≤ 200, body ≤ 2000)
POST /events — publish an app event ({"payload": …}); the box tags it apps:<your id> and every subscriber to /api/events sees it
GET /entitlement — {"skus":[{sku,kind,expires_at,active}],"refreshed_at","stale"}

Errors come back as {"error": "<message>"}. A box that does not implement a route answers 501, which plumsvc turns into ErrNotImplemented.

With plumsvc:

ctl.Publish(plumsvc.Downloads, "staging/report.pdf", "report.pdf")     // files:write
entries, _ := ctl.Files(ctx, plumsvc.Files, "Documents")               // files:read
rc, _ := ctl.Read(ctx, plumsvc.Files, "Documents/report.pdf")          // files:read
ctl.Notify(ctx, "done", "Export finished", "report.pdf is in Downloads")
ctl.PublishEvent(ctx, map[string]any{"type": "progress", "pct": 42})
skus, _ := ctl.Entitlements(ctx)

Never move big files through the browser: write them into PLUM_APP_DATA_DIR and Publish them.

Being called

/apps/<id>/svc/<path> reaches your socket with the path after /svc intact and every HTTP method allowed. Three kinds of caller are accepted:

  1. A browser session — the panel calling plum.service.fetch('/list').
  2. A personal access token — Authorization: Bearer plum_pat_…, which must carry the exact scope service:call:<your app id>. read, write and admin do not imply it.
  3. Basic auth — Authorization: Basic base64(<username>:plum_pat_…), for tools that only speak Basic. The username must be the token's owner.

Failures from the proxy, before your code runs:

Status Body Meaning
401 {"error":"unauthorized"} no credentials (with WWW-Authenticate: Bearer …, Basic …)
403 {"error":"token lacks service:call:<id>"} wrong scope
403 {"error":"app lacks service:call permission"} the manifest does not declare it
404 {"error":"app not installed"} no such app for this user
503 {"error":"app backend unavailable"} your process is not up

Watching it run

plum-dev status dev.jan.notes     # state, restarts, ready, limits, memory_current, oom_kills
plum-dev logs dev.jan.notes -f    # stdout/stderr, live
plum-dev restart dev.jan.notes

status reports state as starting, ready, crashed, give_up, start_timeout or stopped, and reports the limits actually enforced (with a reason when they are not — an older box, or PLUMBOX_APP_CGROUPS=0). Logs are a per-process ring buffer; GET /api/apps/{id}/logs returns a snapshot and ?follow=1 streams it as SSE. A token needs the apps:dev scope for logs.

Local development

go build -tags dev -o ./svc-dev ./server && ./svc-dev     # listens on TCP
plum-dev serve --service http://127.0.0.1:8080

plum-dev serve proxies /apps/<id>/svc/* to your local process and injects the same identity headers (with X-Plum-User-Id: dev-user), so the panel and the backend can be developed together with no box. The server-go template ships the //go:build dev file that makes this possible. When you want the real sandbox — real uid, real cgroups, real control socket — use the emulator or push to your own box.