Headless · Node 22 service · v0.14.0

Headless App

Folder intent

Project Structure

listam-headless (v0.14.0) is the always-on owned peer: a long-lived Node 22 service for a device the user controls (Raspberry Pi, mini PC, NAS, a Mac that stays on) — a durable personal peer, not a cloud service. It runs the same @listam/backend engine as mobile and desktop and is driven entirely by JSON lines on stdin/stdout.

Path Role Why it exists
headless.mjs Entry point / CLI Commands setup, run, status, install, uninstall; owns the stdin op loop and signal handling. Also the listam-headless bin.
src/config.mjs Config builder/loader Validates role, base key, bootstrap list, and storage quota; persists headless-config.json.
src/service.mjs Participant service Boots the full backend and dispatches the participant op set with wedge-avoiding timeouts.
src/blind.mjs Blind-storage helper Corestore + Hyperswarm only: pins cores by public key and replicates ciphertext without ever holding the encryption key.
src/control.mjs Owner-control server HyperDHT server for pairing, signed command envelopes, device registry, and the audit ring.
src/install.mjs Service installer Renders run.sh and a systemd user unit, enables linger, falls back to cron, and wires the FIFO control surface.
src/status.mjs / src/quota.mjs Monitoring Writes a redacted headless-status.json snapshot every ~5 s; scans storage against the configured quota.
test/ Acceptance matrix Multi-process scenarios on a private DHT testnet: matrix, wedge regression, blind C2 boundary, owner control, restarts, export/import.
scripts/build-dist.mjs Distribution Rewrites file: monorepo deps to registry ranges and builds the dist tarball.
Trust model

Roles

The role is chosen at setup time (--role) and stored in the config. It decides which code path run boots and which capabilities the node can ever exercise.

participant

A full trusted peer. Runs @listam/backend with the encrypted Autobase, holds the list encryption key in its secret store, and supports the whole op set: invite, join, item mutations, export/import, roster queries, and owner-gated member removal. Its identity (base key, joined-ness, items) survives restarts.

src/service.mjs

blind-storage

An untrusted storage helper. Configured with core public keys only, it pins and replicates blocks as opaque ciphertext through Corestore + Hyperswarm — there is no Autobase, no view, and no code path that accepts an encryption key. Its status reports encryptionKey: "never-held", and remote owner-control restricts it to pin-style commands.

src/blind.mjs
C2 boundary, enforced by test: test/blind-c2.test.mjs replicates a real base into a blind node and then asserts that no plaintext item text appears in any stored block, that blocks don't parse as readable operations, and that no headless-secrets.json was ever created on the blind instance.
Self-asserted peer label. A node names itself from config — setup --name or the LISTAM_INSTANCE_NAME env (clamped to ≤64 chars, matching @listam/domain MAX_LABEL_NAME). Once the roster reveals the node's own writer key, the service writes a synced peer-label item (@listam/domain/labels buildPeerLabelItem via RPC_UPDATE), so every app shows a legible name like "Raspberry Pi" instead of a key fingerprint. Label items live in a reserved bucket and are skipped by every list projection (isLabelItem).
Encrypted backup, export & import. The participant inherits the shared backend's password-encrypted backup surface (Argon2id + XChaCha20): RPC_EXPORT_DATA (24) dumps all data, RPC_EXPORT_SEED (25) exports the instance seed (full identity), and RPC_IMPORT (26) merges an encrypted envelope back by stable id. The crypto lives in lib/backup-crypto.mjs, lib/backup-payload.mjs, and lib/backup.mjs — the same code path mobile and desktop expose from Settings.
Voice assistant (opt-in). The voice pipeline can be wired into the service but is gated off by default (LISTAM_VOICE_ENABLED=1 + a configured whisper model). The host runs whisper.cpp STT (lib/stt/whisper-cpp-subprocess.mjs) over an audio bridge on port 9994, with a per-locale default whisper prompt, a configurable STT locale/prompt (LISTAM_VOICE_LOCALE / LISTAM_VOICE_PROMPT), and per-intent exec-confidence floors. The leaf captures audio; the host transcribes and applies the parsed command through normal RPCs.
Behavior

Runtime Flows

1

Boot

node headless.mjs run --storage … loads headless-config.json (a CLI --bootstrap can override the stored DHT bootstrap for test harnesses) and branches on the role: participant service or blind helper.

headless.mjs
2

Storage lease

Both roles take a storage lease with a 30 s heartbeat TTL and a refuse-destructive policy: a second instance on the same directory fails fast with "lease held", and after a kill -9 the next start waits out the TTL instead of corrupting state.

src/service.mjs
3

Ready and steady state

Once up, the process prints {"event":"ready","role":…,"controlPublicKey":…}, starts the owner-control server, refreshes headless-status.json every ~5 s, and checks the storage quota every 30 s while Hyperswarm replicates in the background.

src/status.mjs
4

Shutdown

SIGINT, SIGTERM, stdin EOF, or a shutdown op all exit cleanly in a few seconds, releasing the lease. headless status exits 0 while the snapshot is fresh and 1 once it goes stale (>60 s).

headless.mjs

First-run and join sequence

  1. setup --storage <dir> --role participant writes the config (re-running refuses without --force); secrets land in headless-secrets.json with mode 0600.
  2. run boots the backend and prints the ready event with the node's control public key.
  3. Either mint an invite for GUI peers ({"op":"invite"} → z32 key) or join an existing base: {"op":"join","invite":…} with a 180 s budget for the BlindPairing handshake.
  4. The joined base is persisted, so a restart comes back with joined: true and the same identity (covered by test/join-restart.test.mjs).
  5. The owner pairs trusted clients (desktop/mobile) via control-pair for remote administration.

Interesting implementation details

Wedge avoidance by op timeouts.

Every stdin op races a timeout — 20 s normally, 180 s for join. If the backend stalls because peers vanished mid-append (the upstream Autobase wedge), the op answers ok: false promptly instead of hanging the service; the regression is pinned by test/wedge.test.mjs.

Blind replication without decryption.

pin derives the swarm discovery key from the core's public key, calls core.download({ start: 0, end: -1 }), and stores whatever arrives. Encrypted blocks never pass through Autobase, so there is nothing to decrypt with.

Redacted status surface.

The status file and logs only ever contain BLAKE2b fingerprints of keys, never raw key material; stdout is reserved for protocol JSON and all diagnostics go to stderr through the @listam/logging redactor (a no-raw-console lint rule enforces this).

Export/import round-trips by stable id.

Exports carry version, timestamp, base fingerprint, and items; import upserts by the item's stable id, so done flags and edits survive and re-importing the same file creates no duplicates.

Operator surface

Control Interface (stdin JSON)

A headless peer has no UI — you drive it from a shell two ways. CLI subcommands (setup / run / status / install / uninstall) handle the lifecycle; JSON ops handle everything live — one JSON object per line on stdin, one reply per request on stdout, correlated by id. Unknown ops or missing fields answer ok: false without killing the service. Under systemd that stdin is the installer's control.fifo and the replies land in the journal — see below.

Fastest check — role, base, data, peers

No op required. The service rewrites a status file every few seconds; read it (or run the status subcommand) to see the role, which base it is on / replicating, whether it joined, the live peer count, item count and quota.

# over SSH on the peer
cat ~/listam-data/headless-status.json
# …or the CLI (exits 1 when the status is stale = not running)
node headless.mjs status --storage ~/listam-data

{
  "role": "participant",         # participant = full writer · blind-storage = ciphertext mirror
  "mode": "secure-store",
  "baseId": "fnv1a32:42fc0b1b",  # the project base this peer is on / replicating
  "joined": true,                # joined another base vs. owns its own
  "peerCount": 1,                # live replication connections right now
  "itemCount": 30,               # items in the materialised list
  "inviteActive": false,
  "quota": { "usedBytes": 4462636, "maxBytes": 1073741824, "exceeded": false },
  "startedAt": 1782399966987, "updatedAt": 1782416867419
}

A blind-storage peer reports pins[] instead — the cores it mirrors, each a fingerprint + length — and states "encryptionKey":"never-held": it holds ciphertext only.

Live ops — FIFO in, journal out

The peer reads JSON ops from stdin, which the installer wires to <storage>/control.fifo (held open read-write so it never hits EOF — an EOF means shutdown). Replies print to stdout, which systemd routes to the journal. So you echo an op into the FIFO and read the reply from the journal, matched by id.

# terminal A — watch replies
journalctl --user -u listam-headless -f

# terminal B — send ops (one JSON object per line)
echo '{"id":1,"op":"status"}'            > ~/listam-data/control.fifo
echo '{"id":2,"op":"add","text":"milk"}' > ~/listam-data/control.fifo
echo '{"id":3,"op":"dump"}'              > ~/listam-data/control.fifo

# → appears in terminal A:
# {"id":2,"ok":true}
# {"id":3,"ok":true,"items":[…],"peerCount":1,"joined":true,"roster":[…]}

Not under systemd? Run it in the foreground and ops/replies are just stdin/stdout: node headless.mjs run --storage ~/listam-data, then type the JSON lines.

Recipes

# What role / base / data is this peer on?
cat ~/listam-data/headless-status.json

# Full item list + the owner-signed writer roster (who can write)
echo '{"id":1,"op":"dump"}'    > ~/listam-data/control.fifo
echo '{"id":2,"op":"members"}' > ~/listam-data/control.fifo

# Add / tick / edit / delete an item   (itemId comes from a dump)
echo '{"id":3,"op":"add","text":"buy bread"}'                       > ~/listam-data/control.fifo
echo '{"id":4,"op":"done","itemId":"abc123","isDone":true}'         > ~/listam-data/control.fifo
echo '{"id":5,"op":"edit","itemId":"abc123","text":"buy 2 loaves"}' > ~/listam-data/control.fifo
echo '{"id":6,"op":"delete","itemId":"abc123"}'                     > ~/listam-data/control.fifo

# Mint an invite for another device to join THIS base   (reply: {"inviteKey":"…z32…"})
echo '{"id":7,"op":"invite"}' > ~/listam-data/control.fifo

# Join another peer's whole base (REPLACES this peer's base; 180s budget)
echo '{"id":8,"op":"join","invite":"PASTE_Z32"}' > ~/listam-data/control.fifo

# Share / join a SINGLE list (additive — the rest of the project stays private)
echo '{"id":9,"op":"share-list","listId":"default"}'      > ~/listam-data/control.fifo
echo '{"id":10,"op":"join-list","invite":"PASTE_Z32"}'    > ~/listam-data/control.fifo

# Back up / restore (plain JSON dump, id-stable upsert on import)
echo '{"id":11,"op":"export","path":"/home/cassandrina/listam-export.json"}' > ~/listam-data/control.fifo
echo '{"id":12,"op":"import","path":"/home/cassandrina/listam-export.json"}' > ~/listam-data/control.fifo

# Pre-join encrypted auto-backups
echo '{"id":13,"op":"set-backup-password","password":"hunter2"}' > ~/listam-data/control.fifo
echo '{"id":14,"op":"list-backups"}'                             > ~/listam-data/control.fifo
echo '{"id":15,"op":"restore-backup","file":"NAME","password":"hunter2"}' > ~/listam-data/control.fifo

# Graceful stop   (or: systemctl --user stop listam-headless)
echo '{"id":16,"op":"shutdown"}' > ~/listam-data/control.fifo

CLI subcommands

CommandWhat it does
setup --storage DIR --role participant|blind-storage [--base-key HEX] [--bootstrap host:port,…] [--max-storage-bytes N] [--name STR] [--force]Write the config. The role is chosen here and is fixed for the storage dir.
run --storage DIR [--bootstrap …]The long-lived peer. Reads JSON ops on stdin, one reply per line. Normally launched by the systemd unit, not by hand.
status --storage DIRPrint the last status snapshot (role, baseId, joined, peers, items, quota). Exits 1 when stale = not running.
install --storage DIR [--role …] [--base-key HEX] [--invite Z32]Linux only: create control.fifo, a systemd --user unit (listam-headless.service), enable linger, and start it. --invite joins a base on first start.
uninstall --storage DIRDisable + remove the unit. Storage is left intact.

Every op

Aliases: print-inviteinvite, dump-listdump, add-itemadd, edit-itemedit, mark-donedone, delete-itemdelete.

Op Role What it does
status / dump both Snapshot: role, base fingerprint, joined, peer count, item count, quota — plus full items/roster on dump.
invite / join participant Mint a z32 BlindPairing invite, or consume one to join a base (180 s budget; the joined base persists). join is a whole-project join — it REPLACES this peer's base.
share-list / join-list participant Promote one list ({listId}) into its own shared base and return its co-edit invite, or additively {invite}-join one shared list — the rest of the project stays private (no base replacement).
add / edit / done / delete participant Item mutations by itemId. Unwritable bases refuse fast ("mutation refused") rather than queueing silently.
export / import / sync participant JSON export (optionally to a 0600 file via {path}), id-stable {path|data} import (upsert), explicit sync request.
set-backup-password / list-backups / restore-backup participant Manage the encrypted pre-join auto-backups: set the {password} (required before they run), list them, and {file, password}-restore one (decrypt + LWW merge).
members / remove-member participant Owner-signed roster (C3) and member removal, which triggers the C1 re-key epoch.
pin / peek blind-storage Add a core public key to the replication set; return a raw block as hex — always ciphertext.
provision-leaf participant (local radio only) Initialize a nearby ESP32 leaf over BLE: {op:'provision-leaf', ssid, psk} (or {wifi:[…]}) builds a provisioning payload from the hub's leaf-bridge control key + auto-detected LAN address(es) + the operator's WiFi creds and writes it to the leaf over @abandonware/noble; the leaf reboots and mirrors. Degrades to {ok:false, reason:'ble-unavailable'} on radio-less hosts and is deliberately absent from the remote owner-control channel.
control-info / control-pair / control-devices / control-revoke / control-audit both (local operator only) Owner-control administration: inspect the control key, mint pairing codes, list/revoke devices, read the audit trail.
shutdown both Graceful exit; stdin EOF and signals behave the same.
Leaf onboarding

Leaf Provisioning (BLE)

The provision-leaf op replaces hand-editing cfg.toml and USB-flashing an ESP32 leaf: an operator standing next to the device pushes its WiFi creds and the hub's connection details into it over Bluetooth Low Energy, and the leaf reboots straight into mirroring.

Lead: trust is anchored on physical BLE proximity (v1, cleartext), not a key exchange. A version byte in the payload is reserved for a future PIN / AEAD upgrade. Because proximity is the only credential, provisioning is a local-radio operator action and is deliberately NOT exposed over the remote owner-control channel.

Zero-dependency wire contract

The new @listam/provisioning package (v0.7.0, main only — not yet on npm) defines the GATT service / characteristic UUIDs, the advertised name prefix (listam-leaf), a framed BEGIN/CHUNK/COMMIT payload split to the negotiated MTU, and a CRC16/CCITT-FALSE checksum verified by the firmware on COMMIT. It imports no BLE stack itself — the transport is injected.

packages/provisioning/index.mjs

Injected transport

A provisionLeaf orchestrator drives the framed write against a per-runtime transport. On headless that transport is @abandonware/noble, loaded as an OPTIONAL / lazy dependency (a native addon that may not build on every host); desktop uses Web Bluetooth and mobile uses react-native-ble-plx against the same core contract.

packages/provisioning/transport/noble.mjs

What the leaf learns

The payload carries the hub's leaf-bridge control key, the auto-detected LAN address(es), the operator's WiFi network(s), and optional audio-bridge / LED hints. A central app can also fetch this from a participant directly: RPC_LEAF_PROVISION_INFO (27) replies with {type:'leaf-provision-info', controlKey, hubAddr, audioAddr} to write into the leaf.

src/provision-ble.mjs

Graceful degradation

On a host with no BLE radio (or where the native noble addon failed to build), the op never throws: it answers {ok:false, reason:'ble-unavailable'} with a hint to install the optional dependency. Both the success path and the ble-unavailable fallback are pinned by test/provision-ble.test.mjs.

test/provision-ble.test.mjs
Remote administration

Owner Control

Pairing

control-pair mints a short-lived one-time code (≤15 min expiry). A client (the desktop app's Peers pane, for example) dials the node's persistent control key over an encrypted HyperDHT session, proves possession of the code, and lands in headless-devices.json with the capabilities the code granted.

src/control.mjs

Envelope authentication

Every remote command is a signed envelope checked by @listam/owner-control: the device must be registered and not revoked, the Ed25519 signature must verify, the timestamp must be fresh, the per-device sequence number must strictly increase (replay protection), and the capability grant must cover the command (H1).

src/control.mjs

Remote command set

status and diagnostics work against both roles; invite, export, and import only against a participant; pin-style topics only against blind storage; shutdown and device key rotate (H3) round it out. Denials are capability errors, not silence.

headless.mjs

Audit and revocation

Pairings, commands, rejections, rotations, and revocations land in a 100-event audit ring readable via control-audit and mirrored to the redacted stderr log. control-revoke is operator-shell only — a paired device cannot revoke its peers remotely.

src/control.mjs
Persistence

Storage Layout

Everything lives under the --storage directory; nothing is written elsewhere. Secret-bearing files are created with mode 0600.

File Role Contents
headless-config.json both Role, bootstrap list, storage quota (default 1 GiB), blind pins, optional leaf-bridge port.
headless-secrets.json participant only Autobase/encryption key material via the @listam/secrets file store — this file never exists on a blind node.
headless-control-keys.json / headless-devices.json both Owner-control server seed and the paired-device registry (capabilities, sequence counters).
headless-status.json both Live snapshot rewritten every ~5 s; key material appears only as fingerprints. Read by headless status and remote diagnostics.
headless/ namespace / blind-store/ participant / blind The Corestore data: encrypted Autobase cores for participants, pinned ciphertext cores for blind nodes.
run.sh, control.fifo, service.log installed service Generated runner, the named pipe that feeds stdin under systemd, and the cron-fallback log.
Always-on deployment

Service Installer

One-command install

node headless.mjs install --storage ~/listam (Linux: Raspberry Pi, the Geekom VMs) renders run.sh — which runs setup if no config exists and keeps control.fifo open as stdin — plus a systemd user unit with Restart=always and a 10 s backoff sized to outlast the 30 s storage-lease TTL. The service is active immediately after install.

src/install.mjs

Surviving reboots and logouts

The installer enables loginctl linger so the user manager outlives the SSH session, and handles XDG_RUNTIME_DIR quirks on Tailscale SSH. Where no user bus or linger exists, it falls back to crontab: an @reboot entry plus a 5-minute staleness guard, each line tagged for idempotent re-install and clean uninstall.

src/install.mjs

Join during install

install --invite <z32> waits for the service to report ready, writes the join op into the FIFO, and polls status until joined: true (200 s budget on top of the 180 s join timeout) — a Pi can be enrolled into a household base in a single command.

src/install.mjs

Operating it

Echo ops into control.fifo and read replies in the journal; headless status --storage … is the scriptable health check (exit 0 fresh, 1 stale). uninstall removes the unit and cron entries but always preserves the storage directory. Target runtime is Node 22+ (on the Pi: ~/node22).

headless.mjs
Dependency roles

Libraries

@listam/backendThe shared replication/mutation engine, booted through the Node platform adapter (namespace headless).
@listam/client + @listam/protocolCommand dispatch and the numeric RPC ABI shared with mobile and desktop.
@listam/domainId-keyed list reduction — the same convergence rules on every surface.
@listam/owner-controlPairing-code parsing, envelope signing/verification, capability grants, rotation.
@listam/secretsFile secret store backing the participant's persistent identity.
@listam/loggingRedacting stderr logger; stdout stays protocol-clean.
Corestore / HyperswarmBlock storage and peer replication — the entire substrate a blind node needs.
HyperDHTTransport for the owner-control channel and the private testnets in CI.
hypercore-crypto + b4aKey derivation, discovery keys, signatures, and buffer plumbing.
package.json
Source map

File Index

Verification

Testing

The suite is the project's acceptance harness: real child processes on a private hyperdht testnet (3 bootstrap nodes), no in-process mocks. Manual passes follow the Headless Checklist.

Unit

config.test.mjs (parsing and defaults), install.test.mjs (rendered unit/run.sh/cron content), quota.test.mjs (scan and callback behavior).

Acceptance matrix

matrix.test.mjs runs the CORE tier by default (~4 min: join and convergence, duplicate names kept distinct by id (M1), single-use invite rotation (H3), owner gate and signed roster (C3)). LISTAM_MATRIX_FULL=1 adds the ~30 min FULL tier: steady-state mesh convergence, member removal with the C1 re-key epoch, 3-way kill/rejoin, and rejoin reconciliation.

Security regressions

blind-c2.test.mjs (ciphertext boundary), owner-control.test.mjs (pairing, expired codes, replayed/unsigned envelopes rejected, capability scope, revocation, rotation), wedge.test.mjs (mutations fail fast under peer loss), restart.test.mjs / join-restart.test.mjs (identity and joined base persist).

How to run

npm test for the suite, npm run ci for lint + tests (the release gate). Scenario files in test/helpers/ run as child processes so every instance is a real OS process with its own storage root.

Interaction: headless is the always-on hub of the cross-device scenarios: it stays online while phones close and reopen, accepts desktop and mobile joins, mirrors to ESP32 leaf peers via the optional leaf bridge (LISTAM_LEAF_BRIDGE_PORT), and is the target the desktop app administers over owner control. The tools/cross-device harness drives the Pi and the Geekom VMs for the real-network rows.