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.
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
| Command | What 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 DIR | Print 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 DIR | Disable + remove the unit. Storage is left intact. |
Every op
Aliases: print-invite→invite, dump-list→dump, add-item→add, edit-item→edit, mark-done→done, delete-item→delete.
| 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
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.