UmbrelApps: community app store whatsnext

Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HarmenandClaude Opus 5.5 committed 2026-10-09 10:40:25 +02:00
commit 85d85a1295
63 files changed
+13987

No files matched your search

+67
View File
@@ -0,0 +1,67 @@
# ═══════════════════════════════════════════════════════════════════════════════
# De image voor de app whatsnext-evolu-relay.
#
# Eén image voor drie containers. De compose start hem drie keer: als `relay`
# met het standaardcommando (`node src/index.js`, als gebruiker node), als
# `agent` met `python3 /app/agent.py` en als `server` met nginx. Tot 0.5.6 zat
# alleen de relay in deze image en draaiden de agent en de pagina als *.template
# op python:3-alpine en nginx:alpine; sinds 0.6.0 zit alles hier. Beslist door de
# gebruiker op 08-09-2026, na dezelfde stap bij Electrum Gate: één image scheelt
# een tweede recept en een tweede pin. Zie het plan Eigenimage, PLAN.md §8.
#
# Dit bouwt niet de relay van Evolu na: het installeert `@evolu/nodejs` uit npm
# en start ons eigen `src/index.js`, dat alleen de twee terugroepfuncties voor de
# toegangscontrole toevoegt.
#
# Waarom `slim` en niet `alpine`: `better-sqlite3` zit onder `@evolu/nodejs` en
# heeft kant-en-klare binaries voor glibc, niet voor musl. Op alpine zou hij bij
# elke bouw opnieuw gecompileerd moeten worden, met een bouwketen erbij in de
# image. Dat is ook de reden dat nginx en python3 hier uit apt komen en niet
# andersom node in een nginx-image gezet is.
#
# De bouwcontext is de REPO-ROOT, niet deze map: icon.png blijft in de app-map
# staan (het manifest wijst ernaar voor de tegel in de winkel) en wordt van daar
# gekopieerd, zodat er niet twee exemplaren zijn. build.sh geeft die context mee.
# ═══════════════════════════════════════════════════════════════════════════════
FROM node:24-slim
# nginx en python3 uit Debian. Geen aanbevolen pakketten: die halen bij nginx
# onder meer een set modules binnen die de pagina niet gebruikt. De agent heeft
# alleen de standaardbibliotheek nodig.
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx python3 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Eerst alleen het manifest, zodat een wijziging in `src/` de installatielaag niet
# ongeldig maakt.
COPY tools/evolu-relay/package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY tools/evolu-relay/src ./src
COPY tools/evolu-relay/agent.py ./agent.py
# De relay maakt zelf `data/` aan bij de start, maar dan als de gebruiker die op
# dat moment draait. Vooraf aanmaken met de juiste eigenaar voorkomt dat een
# gemounte map van de host als root wordt aangeraakt. Alleen `data/` en `src/` zijn
# van node; agent.py en de rest blijven van root, want die worden alleen gelezen.
RUN mkdir -p /app/data && chown -R node:node /app/data /app/src
# De pagina. Debian zet nginx' eigen voorbeeldpagina onder /var/www/html; wij
# gebruiken het pad dat de nginx-image ook gebruikt, zodat nginx.conf gelijk kan
# blijven aan die van Electrum Gate.
COPY tools/evolu-relay/nginx.conf /etc/nginx/nginx.conf
COPY tools/evolu-relay/index.html /usr/share/nginx/html/index.html
COPY whatsnext-evolu-relay/icon.png /usr/share/nginx/html/icon.png
# Geen USER hier, en dat is met opzet: nginx moet als root beginnen om poort 80 te
# nemen, en de agent schrijft in de gedeelde map. De relay hoort wél als node te
# draaien, zoals hij tot 0.5.6 deed, en dat regelt de compose met `user: node`.
ENV NODE_ENV=production
ENV PYTHONUNBUFFERED=1
EXPOSE 4000 80
CMD ["node", "src/index.js"]
+438
View File
@@ -0,0 +1,438 @@
# ═══════════════════════════════════════════════════════════════════════════════
# De agent van Evolu Relay.
#
# Hij bedient de statuspagina en doet verder niets: lezen wat het relay-proces
# heeft opgeschreven, en opdrachten van de pagina in de postbus leggen.
#
# Waarom de agent niet zelf beslist wie er binnen mag: dat beleid hoort bij het
# proces dat de verbindingen aanneemt, en dat is de relay. Twee processen die in
# dezelfde allowlist schrijven is een wedloop die je een keer per jaar treft en
# dan niet kunt reproduceren. De agent schrijft daarom uitsluitend command.json,
# en het relay-proces past hem toe en ruimt hem op.
#
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als /app/agent.py.
# Tot 0.5.6 was het een *.template in de app-map en haalde umbreld het bij elke
# start door envsubst, en daarom staat er geen dollarteken in en komen alle
# instellingen uit de omgeving. Dat laatste is zo gebleven: de compose zet ze in
# de omgeving neer, en dat is ook de nette weg. tests/test_relay_agent.py houdt
# het dollarteken buiten de deur, voor het geval dit ooit weer een template wordt.
# ═══════════════════════════════════════════════════════════════════════════════
import json
import os
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
STATE_DIR = Path(os.environ.get("RELAY_STATE_DIR", "/var/lib/relay"))
OWNERS_FILE = STATE_DIR / "owners.json"
COMMAND_FILE = STATE_DIR / "command.json"
DATABASE_FILE = STATE_DIR / "evolu-relay.db"
# De labels die de gebruiker aan een eigenaar-id hangt. Een eigen bestand, en dat
# is de kern van deze keuze: owners.json is van het relay-proces en labels.json is
# van de agent, dus er is per bestand precies één schrijver. Dat is dezelfde
# afspraak die de postbus hierboven oplevert, en de reden staat bovenaan dit
# bestand.
#
# Wat het bovendien oplevert: een label is meteen opgeslagen en niet pas als de
# relay de postbus leegmaakt, en labelen blijft werken als de relay omgevallen is.
# Dat mag, want de relay hoeft dit niet te weten: een label zegt niets over wie er
# binnen mag.
LABELS_FILE = STATE_DIR / "labels.json"
RELAY_HOST = os.environ.get("RELAY_HOST", "")
RELAY_PORT = int(os.environ.get("RELAY_PORT", "4000"))
PUBLIC_PORT = int(os.environ.get("RELAY_PUBLIC_PORT", "3852"))
API_PORT = int(os.environ.get("RELAY_API_PORT", "8000"))
PROBE_INTERVAL = int(os.environ.get("RELAY_PROBE_INTERVAL", "15"))
# De versie uit het manifest, voor de kop van de pagina. Tot 0.5.6 vulde umbreld
# die rechtstreeks in de pagina in; nu de pagina in de image zit, loopt het via de
# status. Leeg betekent dat de compose hem niet doorgeeft, en dan toont de pagina
# geen versie in plaats van een verzonnen.
APP_VERSION = os.environ.get("RELAY_APP_VERSION", "")
# Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
# hier te stranden en niet daar.
ALLOWED_ACTIONS = ("block", "allow", "forget")
MAX_BODY_BYTES = 4096
MAX_OWNER_ID_LENGTH = 256
# Een label is een herkenpunt en geen aantekenveld: het staat op de pagina naast
# een id en moet daar op één regel passen.
MAX_LABEL_LENGTH = 48
# Een bovengrens op het aantal labels. De pagina zit achter de inlog van umbrelOS,
# dus dit is geen verdediging tegen een aanvaller maar tegen een lus die per
# ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft.
MAX_LABELS = 200
# Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig:
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
# labelen is lezen-wijzigen-schrijven. Dit slot maakt daar één handeling van.
labels_lock = threading.Lock()
# Door de achtergrondlus bijgewerkt, door de webserver gelezen. Een dict wordt in
# zijn geheel vervangen en nooit ter plekke aangepast, zodat een lezer altijd een
# samenhangend beeld heeft zonder slot.
probe = {"reachable": None, "checked": None}
def relay_reachable():
"""Kan de relay een TCP-verbinding aannemen.
Bewust niet meer dan dat. De relay is een WebSocket-server en antwoordt niet
op een gewoon verzoek; een handdruk nabouwen om de pagina groen te krijgen is
meer code dan het waard is. Wat dit wel uitsluit is de meest voorkomende
storing: het proces is omgevallen.
"""
if not RELAY_HOST:
return None
try:
with socket.create_connection((RELAY_HOST, RELAY_PORT), timeout=3):
return True
except OSError:
return False
def probe_loop():
global probe
while True:
# In zijn geheel vervangen en niet ter plekke aanpassen: een lezer ziet
# dan altijd een samenhangend beeld, zonder dat er een slot nodig is.
probe = {
"reachable": relay_reachable(),
"checked": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
}
time.sleep(PROBE_INTERVAL)
def read_owners():
"""De allowlist zoals het relay-proces hem heeft achtergelaten."""
try:
with OWNERS_FILE.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except FileNotFoundError:
# Nog nooit geschreven. Dat is de normale toestand vlak na een
# installatie: het relay-proces schrijft pas bij de eerste wijziging.
return {"state": None, "problem": "nog-niet-aangemaakt"}
except (OSError, ValueError):
return {"state": None, "problem": "onleesbaar"}
if not isinstance(data, dict):
return {"state": None, "problem": "onleesbaar"}
return {"state": data, "problem": None}
def read_labels():
"""De labels, of een lege verzameling.
Bewust vergevingsgezind, en dat is het omgekeerde van hoe owners.json gelezen
wordt. Daar hangt aan een half begrepen bestand de vraag wie er binnen mag, en
dan is weigeren het antwoord. Hier gaat het om een naam naast een id: is het
onleesbaar, dan is het ergste gevolg dat je de rauwe ids ziet.
"""
try:
with LABELS_FILE.open("r", encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
schoon = {}
for owner_id, label in data.items():
if not isinstance(owner_id, str) or not isinstance(label, str):
continue
if not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
continue
label = label.strip()
if label:
schoon[owner_id] = label[:MAX_LABEL_LENGTH]
return schoon
def write_labels(labels):
"""Schrijft de labels. Eerst een tijdelijk bestand en dan hernoemen.
Hernoemen binnen dezelfde map is atomair, dus een onderbroken schrijfactie
laat geen half bestand achter. Dezelfde constructie als de postbus.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
temporary = LABELS_FILE.with_suffix(".json.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(labels, handle, indent=2, sort_keys=True)
handle.write("\n")
temporary.replace(LABELS_FILE)
def database_facts():
try:
stat = DATABASE_FILE.stat()
except OSError:
return {"bytes": None, "modified": None}
return {
"bytes": stat.st_size,
"modified": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
}
def met_label(entries, labels):
"""Hangt het label van de gebruiker aan elke regel.
Een kopie en niet ter plekke: wat hier binnenkomt komt uit het bestand van het
relay-proces, en daar horen wij niets aan toe te voegen.
"""
resultaat = []
for entry in entries:
regel = dict(entry)
regel["label"] = labels.get(entry.get("id"))
resultaat.append(regel)
return resultaat
def build_status():
owners = read_owners()
state = owners["state"] or {}
labels = read_labels()
return {
"version": APP_VERSION or None,
"relay": {
"reachable": probe["reachable"],
"checked": probe["checked"],
"publicPort": PUBLIC_PORT,
},
"owners": {
"problem": owners["problem"],
"allowed": met_label(
[
entry
for entry in state.get("owners", [])
if isinstance(entry, dict) and entry.get("allowed") is True
],
labels,
),
"blocked": met_label(
[
entry
for entry in state.get("owners", [])
if isinstance(entry, dict) and entry.get("allowed") is False
],
labels,
),
"rejected": met_label(
[entry for entry in state.get("rejected", []) if isinstance(entry, dict)],
labels,
),
},
"database": database_facts(),
# Ligt er nog een opdracht, dan heeft het relay-proces hem nog niet
# opgepakt. De pagina kan dat tonen in plaats van te doen alsof er niets
# gebeurd is.
"pendingCommand": COMMAND_FILE.exists(),
}
def valid_command(payload):
"""Geeft de opdracht terug, of een foutmelding.
Streng aan deze kant, want dit is de enige plek waar iets van buiten in de
postbus belandt.
"""
if not isinstance(payload, dict):
return None, "geen object"
action = payload.get("action")
if action not in ALLOWED_ACTIONS:
return None, "onbekende actie"
owner_id = payload.get("ownerId")
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
return None, "ontbrekende of te lange ownerId"
return {"action": action, "ownerId": owner_id}, None
def valid_label(payload):
"""Geeft (ownerId, label) terug, of een foutmelding.
Een leeg label is geen fout maar de manier om er een weg te halen: dan hoeft er
geen tweede opdracht te bestaan voor iets dat de gebruiker als hetzelfde veld
ziet.
"""
if not isinstance(payload, dict):
return None, None, "geen object"
owner_id = payload.get("ownerId")
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
return None, None, "ontbrekende of te lange ownerId"
label = payload.get("label")
if label is None:
label = ""
if not isinstance(label, str):
return None, None, "label moet tekst zijn"
# Regeleindes eruit: dit is één regel naast een id, en een label met een
# nieuwe regel erin zou de lijst uit elkaar trekken.
label = " ".join(label.split()).strip()
if len(label) > MAX_LABEL_LENGTH:
return None, None, "label is te lang"
return owner_id, label, None
def apply_label(owner_id, label):
"""Zet of haalt een label weg. Geeft een foutmelding terug, of None.
Onder het slot, want dit is lezen-wijzigen-schrijven en de agent bedient
meerdere verzoeken tegelijk.
"""
with labels_lock:
labels = read_labels()
if label:
if owner_id not in labels and len(labels) >= MAX_LABELS:
return "er zijn al te veel labels"
labels[owner_id] = label
else:
if owner_id not in labels:
# Niets te doen, en dat is geen fout: de pagina stuurt een leeg
# label als je het veld leegmaakt, ook als er nog niets stond.
return None
del labels[owner_id]
try:
write_labels(labels)
except OSError as error:
return "label kon niet worden weggeschreven: " + str(error)
return None
def write_command(command):
"""Legt de opdracht in de postbus.
Eerst een tijdelijk bestand en dan hernoemen: het relay-proces kijkt op zijn
eigen moment en mag geen half bestand aantreffen.
"""
STATE_DIR.mkdir(parents=True, exist_ok=True)
temporary = COMMAND_FILE.with_suffix(".json.tmp")
with temporary.open("w", encoding="utf-8") as handle:
json.dump(command, handle)
handle.write("\n")
temporary.replace(COMMAND_FILE)
class Handler(BaseHTTPRequestHandler):
# De standaardregel van BaseHTTPRequestHandler noemt de naam van de server en
# de Python-versie. Dat hoeft niemand te weten.
server_version = "evolu-relay-agent"
sys_version = ""
def log_message(self, format, *args):
# Geen toegangslog. Elke regel zou het adres van de bezoeker bevatten en
# de pagina zit achter de inlog van umbrelOS; er valt niets te zien wat
# het bewaren waard is.
return
def _send(self, status, payload):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path.rstrip("/") in ("/api/status", "/status"):
self._send(200, build_status())
return
self._send(404, {"error": "onbekend pad"})
def _read_payload(self):
"""Het verzoek als object, of None als er al een fout verstuurd is."""
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
self._send(400, {"error": "lengte ontbreekt"})
return None
if length <= 0 or length > MAX_BODY_BYTES:
self._send(400, {"error": "lege of te grote opdracht"})
return None
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, ValueError):
self._send(400, {"error": "onleesbare opdracht"})
return None
def do_POST(self):
pad = self.path.rstrip("/")
# Een label gaat NIET via de postbus, en dat is de enige uitzondering op
# die regel. De reden dat opdrachten er wel door gaan, is dat het
# relay-proces beslist wie er binnen mag en dat twee schrijvers in die
# allowlist een wedloop zou zijn. Een label zegt niets over toegang, staat
# in een eigen bestand met de agent als enige schrijver, en is meteen
# opgeslagen in plaats van na de volgende ronde van de relay.
if pad in ("/api/label", "/label"):
payload = self._read_payload()
if payload is None:
return
owner_id, label, problem = valid_label(payload)
if problem is not None:
self._send(400, {"error": problem})
return
problem = apply_label(owner_id, label)
if problem is not None:
self._send(500, {"error": problem})
return
self._send(200, {"ownerId": owner_id, "label": label})
return
if pad not in ("/api/command", "/command"):
self._send(404, {"error": "onbekend pad"})
return
payload = self._read_payload()
if payload is None:
return
command, problem = valid_command(payload)
if command is None:
self._send(400, {"error": problem})
return
# Eén opdracht tegelijk. Ligt er nog een, dan zou schrijven hem stil
# overschrijven en verdwijnt de vorige zonder dat iemand het merkt.
if COMMAND_FILE.exists():
self._send(409, {"error": "vorige opdracht is nog niet verwerkt"})
return
try:
write_command(command)
except OSError as error:
self._send(500, {"error": "opdracht kon niet worden weggeschreven: " + str(error)})
return
self._send(202, {"accepted": command})
def main():
threading.Thread(target=probe_loop, daemon=True).start()
ThreadingHTTPServer(("0.0.0.0", API_PORT), Handler).serve_forever()
if __name__ == "__main__":
main()
+126
View File
@@ -0,0 +1,126 @@
#!/bin/sh
# ═══════════════════════════════════════════════════════════════════════════════
# Bouwt de image voor de app whatsnext-evolu-relay.
#
# Eén image voor de drie containers van de app: de relay (ons `src/index.js` om
# `@evolu/nodejs` heen), de agent (`agent.py`) en nginx met de pagina. De relay
# zelf komt uit npm en wordt niet nagebouwd of aangepast. Tot 0.5.6 zat alleen de
# relay in deze image; sinds 0.6.0 alles, zie de Dockerfile.
#
# Dit verving op 28-08-2026 het vorige recept, dat de repo van Trezor kloonde en
# hun Dockerfile bouwde. Dat pakket had een Postgres en een quota-manager nodig en
# kon als zelf-gehoste relay in de kern niet werken; zie het plan Umbrelapp,
# OPEN.md punt 6.
#
# Waarom dit NIET in de app-map staat: umbreld kopieert de hele app-map naar het
# apparaat, en bij een update wordt alleen een whitelist ververst waar een
# Dockerfile niet in zit. Bouwen tijdens het starten van de app zou de installatie
# bovendien minuten laten hangen. Het recept hoort dus in de repo en het resultaat
# in een register; de app verwijst alleen naar de tag.
#
# Draaien: sh tools/evolu-relay/build.sh
# Vereist: docker op de machine waar je bouwt. Op de Umbrel is dat met sudo.
#
# Multi-arch: PLATFORMS="linux/amd64,linux/arm64" sh tools/evolu-relay/build.sh
# Vraagt buildx met QEMU en duwt meteen. Nog nooit geprobeerd; zie het masterplan
# Publicatie-Relay. Zonder PLATFORMS bouwt dit één architectuur, die van de
# machine waarop je staat.
# ═══════════════════════════════════════════════════════════════════════════════
set -eu
# ── De pin ────────────────────────────────────────────────────────────────────
# De versies van `@evolu/common` en `@evolu/nodejs` staan exact in package.json en
# zijn dáár de pin. Hier staat alleen het etiket op de uitkomst.
#
# Dit is het etiket op de IMAGE. Verhoog het als de image verandert, dus als er
# iets in deze map of in icon.png wijzigt, en zet dan dezelfde waarde achter
# `image:` in de compose (drie keer: relay, agent en server delen hem).
#
# `version` in umbrel-app.yml is een ander nummer en mag hierop vooruitlopen: dat
# moet bij élke wijziging aan de app omhoog, ook als alleen de compose verandert,
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
# Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je
# VERSION, dan ook het manifest.
VERSION="0.7.1"
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
# hem onbereikbaar en de installatie faalt met "pull access denied". Dat is op
# 25-08-2026 op het apparaat vastgesteld. Zie Umbrel-appstore-spec.md.
IMAGE="sc.kamenier-hamer.nl/sysop/evolu-relay:${VERSION}"
RECEPT="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
# De bouwcontext is de repo-root, want icon.png komt uit de app-map. Zie de kop
# van de Dockerfile.
REPO="$(CDPATH= cd -- "${RECEPT}/../.." && pwd)"
# ── Controles vooraf ──────────────────────────────────────────────────────────
# Beter hier hard falen dan een image bouwen die iets anders bevat dan je denkt.
for bestand in Dockerfile package.json src/index.js agent.py nginx.conf index.html; do
if [ ! -f "${RECEPT}/${bestand}" ]; then
echo "FOUT: ${bestand} ontbreekt in ${RECEPT}" >&2
exit 1
fi
done
if [ ! -f "${REPO}/whatsnext-evolu-relay/icon.png" ]; then
echo "FOUT: whatsnext-evolu-relay/icon.png ontbreekt in ${REPO}" >&2
exit 1
fi
if [ ! -f "${RECEPT}/package-lock.json" ]; then
echo "LET OP: er is geen package-lock.json, dus npm kiest zelf de onderliggende"
echo " afhankelijkheden. De twee pakketten van Evolu staan exact vast in"
echo " package.json, maar wat daaronder hangt kan meebewegen. Draai eenmalig"
echo " 'npm install' in deze map, commit het lock-bestand, en zet dan in de"
echo " Dockerfile 'npm install' om naar 'npm ci'."
echo
fi
# Een accolade-variabele hoort hier nergens meer in: deze bestanden gaan niet
# meer door envsubst, dus wat er staat is wat er draait. Staat er tóch een, dan
# is dat een restant van de template-tijd en zou hij letterlijk in de pagina of
# de configuratie terechtkomen.
if grep -l '\${' "${RECEPT}/nginx.conf" "${RECEPT}/index.html" "${RECEPT}/agent.py" 2>/dev/null; then
echo "FOUT: de bestanden hierboven bevatten nog een \${...}; die wordt niet meer ingevuld." >&2
exit 1
fi
# ── Bouwen ────────────────────────────────────────────────────────────────────
PLATFORMS="${PLATFORMS:-}"
if [ -n "$PLATFORMS" ]; then
echo "Bouwen als ${IMAGE} voor ${PLATFORMS}, en meteen duwen"
docker buildx build --platform "$PLATFORMS" --tag "$IMAGE" --push \
--file "${RECEPT}/Dockerfile" "$REPO"
echo
echo "Geduwd. De index-digest:"
docker buildx imagetools inspect "$IMAGE" | head -3
else
echo "Bouwen als ${IMAGE}"
docker build --tag "$IMAGE" --file "${RECEPT}/Dockerfile" "$REPO"
echo
echo "Klaar:"
docker image inspect --format '{{.RepoTags}} {{.Id}}' "$IMAGE"
echo
echo "Bouwen is niet genoeg: de app verwijst naar het register, want umbreld kan"
echo "niet bij een image die alleen lokaal staat. Nog te doen:"
echo
echo " docker login sc.kamenier-hamer.nl"
echo " docker push ${IMAGE}"
fi
echo
echo "Zet daarna de digest uit de push-uitvoer achter de tag in"
echo "whatsnext-evolu-relay/docker-compose.yml (drie keer: relay, agent en server),"
echo "en zet \`version\` in het manifest op ${VERSION}. Zonder die verhoging rolt"
echo "umbrelOS het niet uit."
echo
echo "Controleer dat het anoniem te halen is, want umbreld krijgt geen"
echo "inloggegevens mee. Log uit in dezelfde context waarin je inlogde, anders"
echo "meet je je eigen sessie:"
echo
echo " docker logout sc.kamenier-hamer.nl && docker pull ${IMAGE}"
File diff suppressed because it is too large. Load diff
+65
View File
@@ -0,0 +1,65 @@
# nginx-configuratie voor Evolu Relay.
#
# Dit bestand serveert alleen de statuspagina. De relay zelf zit er niet achter:
# die publiceert zijn eigen poort in docker-compose.yml, waar Zoraxy met TLS
# naartoe wijst. Dat is de omkering ten opzichte van het pakket van 25-08-2026,
# waar de relay achter de app-proxy stond en de inlog dus uit moest; zie het plan
# Umbrelapp, PLAN.md §4h.
#
# Dit bestand zit in de image (tools/evolu-relay/Dockerfile) als
# /etc/nginx/nginx.conf. Tot 0.5.6 was het een *.template in de app-map die
# umbreld bij elke start door envsubst haalde, en daarom mocht er toen geen
# nginx-variabele met een dollarteken in staan. Die beperking is weg. Dat de
# access_log nog steeds uit staat is een keuze en geen restant: de pagina hangt
# achter de app_proxy en wat die opvraagt is niet interessant.
# www-data en niet nginx: deze nginx komt uit Debian (de image is node:24-slim
# met nginx uit apt), en Debian heeft geen gebruiker nginx. Bij Electrum Gate
# staat hier wél nginx, want die image is de officiële nginx-image.
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log off;
sendfile on;
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# De API van de agent: de status lezen en een opdracht in de postbus
# leggen. Dit is het enige pad waarlangs iets van buiten de instellingen
# van de app raakt.
#
# Waarom dat hier te verantwoorden is: dit pad hangt achter de app_proxy
# van umbrelOS, die er zijn eigen inlog voor zet. De relay staat op een
# andere poort en is hierlangs niet te bereiken. En het ergste wat een
# geslaagde aanroep doet is de leerstand omzetten of een eigenaar
# blokkeren, en dat is precies waar de pagina voor is.
#
# Geen proxy_set_header: de agent heeft ze niet nodig.
#
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
# eerst, en op het apparaat gaf het een 404 bij ongeveer elke tweede
# aanroep (28-08-2026): Electrum Gate heeft óók een service 'agent' op
# poort 8000, beide apps zitten op hetzelfde Docker-netwerk, en de korte
# naam kan dus naar de verkeerde container wijzen. De vorm
# <app-id>_<service>_1 is uniek per app. Dezelfde les stond al in de
# compose van Electrum Gate, over de servicenaam 'server'.
location /api/ {
proxy_pass http://whatsnext-evolu-relay_agent_1:8000;
# Een opdracht is een handvol bytes. De agent kapt zelf ook af, op
# 4k; dit is de eerste zeef en niet de enige.
client_max_body_size 4k;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "whatsnext-evolu-relay",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "De relay van Evolu met een eigen eigenaars-allowlist eromheen.",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
}
}
+173
View File
@@ -0,0 +1,173 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De relay van Evolu, met onze eigen toegangscontrole eromheen.
//
// Wat dit programma NIET is: een eigen relay. `createRelay` uit `@evolu/nodejs`
// doet al het werk; het enige dat wij toevoegen zijn de twee terugroepfuncties
// die het bedoelde uitbreidpunt vormen. Trezor doet in `createEvoluRelay.ts`
// precies hetzelfde, alleen kijken hun functies in een Postgres.
//
// De opstartvolgorde en de `runMain`-aanroep zijn overgenomen uit
// `apps/relay/src/index.ts` van Evolu zelf. Wijk daar niet van af zonder reden:
// dat bestand is de referentie voor hoe deze relay hoort te starten.
//
// Zie het plan Umbrelapp, PLAN.md §4g, en Referenties/Upstream-evolu-relay.md §8.
// ═══════════════════════════════════════════════════════════════════════════════
import { createConsole, createConsoleFormatter } from '@evolu/common';
import { installPolyfills } from '@evolu/common/polyfills';
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
import { mkdirSync } from 'node:fs';
import { applyCommand, decideOwner } from './policy.js';
import { readState, takeCommand, writeState } from './store.js';
installPolyfills();
// Zoals bovenstrooms: de database komt in een map `data` naast het programma,
// zodat het volume van de container een voorspelbaar pad heeft. Onze allowlist
// komt in diezelfde map te staan, naast de database en niet erin.
mkdirSync('data', { recursive: true });
process.chdir('data');
const dataDir = process.cwd();
const port = Number.parseInt(process.env.RELAY_PORT ?? '4000', 10);
// Per schrijfactie, niet per eigenaar. Dat is hoe `isOwnerWithinQuota` bij Evolu
// bedoeld is: de relay geeft door hoeveel bytes déze actie nodig heeft. Trezor
// telt er zelf een totaal bij op uit hun eigen tabel; wij hebben die tabel niet en
// doen dat dus niet.
//
// De standaard is dezelfde 1 MB als de gepubliceerde image, en dat is een bewuste
// keuze en geen overname: een enkele labelwijziging is klein, dus een schrijfactie
// die hierboven uitkomt is eerder een fout of misbruik dan normaal gebruik.
const maxWriteBytes = Number.parseInt(process.env.RELAY_MAX_WRITE_BYTES ?? '1048576', 10);
const console = createConsole({
formatter: createConsoleFormatter()({ timestampFormat: 'relative' }),
});
// ── De staat ──────────────────────────────────────────────────────────────────
// In het geheugen gezaghebbend, op schijf bewaard. `isOwnerAllowed` zit in het
// verbindingspad, dus daar wordt niet naar schijf geschreven; dat doet de
// achtergrondlus hieronder.
const start = readState(dataDir, new Date().toISOString());
let state = start.state;
let dirty = start.status === 'new';
if (start.status === 'corrupt') {
console.log(
'[warn] owners.json was unreadable and has been moved aside. Everyone is on the waiting list ' +
'again; allow the owners you recognise on the status page.',
);
}
const markDirty = () => {
dirty = true;
};
const flush = () => {
if (!dirty) return;
try {
writeState(dataDir, state);
dirty = false;
} catch (error) {
// Niet fataal: de relay blijft werken met de staat in het geheugen. Wel
// melden, want na een herstart is de allowlist dan verouderd.
console.log(`[warn] could not write owners.json: ${String(error)}`);
}
};
// ── Opdrachten van de statuspagina ────────────────────────────────────────────
// De pagina schrijft een bestand, dit proces past het toe. Geen Docker-socket en
// geen tweede container die in dezelfde staat schrijft; dezelfde afspraak als bij
// Electrum Gate.
const pollCommands = () => {
const command = takeCommand(dataDir);
if (command === null) return;
const result = applyCommand(state, command, new Date().toISOString());
if (result.error !== null) {
console.log(`[warn] command refused: ${result.error}`);
return;
}
if (result.changed) {
state = result.state;
markDirty();
console.log(`[info] command applied: ${command.action}`);
}
};
setInterval(() => {
pollCommands();
flush();
}, 2000).unref();
const stopCleanly = () => {
flush();
process.exit(0);
};
process.on('SIGTERM', stopCleanly);
process.on('SIGINT', stopCleanly);
// ── De relay ──────────────────────────────────────────────────────────────────
// Eén regel per eigenaar per keer dat dit proces draait, en niet per verbinding.
// Zonder deze rem staat het log vol zodra een cliënt opnieuw verbindt; zónder
// logging kostte het op 28-08-2026 juist een half uur om te zien dat een tweede
// eigenaar aan de deur stond te kloppen. De bovengrens erop is er omdat een
// weigering een id bevat dat de ander zelf verzint.
const MAX_ANNOUNCED = 100;
const announced = new Set();
const announceOnce = (key, message) => {
if (announced.has(key)) return;
if (announced.size < MAX_ANNOUNCED) announced.add(key);
console.log(message);
};
await runMain({ ...createRelayDeps(), console })(
createRelay({
port,
isOwnerAllowed: (ownerId) => {
let decision;
try {
decision = decideOwner(state, ownerId, new Date().toISOString());
} catch (error) {
// Bij twijfel weigeren. Een fout in het beleid mag geen open deur worden.
console.log(`[warn] policy failed, refusing owner: ${String(error)}`);
return false;
}
state = decision.state;
if (decision.changed) markDirty();
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
if (decision.allowed) {
announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
} else {
announceOnce(
`no:${short}`,
`[info] owner ${short} is on the waiting list (${decision.reason}); allow it on the ` +
'status page if it is yours',
);
}
return decision.allowed;
},
isOwnerWithinQuota: (ownerId, requiredBytes) => {
if (requiredBytes <= maxWriteBytes) return true;
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
announceOnce(
`quota:${short}`,
`[warn] refused a write of ${requiredBytes} bytes from ${short}; the limit is ` +
`${maxWriteBytes}. Raise RELAY_MAX_WRITE_BYTES if this is normal for your app.`,
);
return false;
},
}),
);
+239
View File
@@ -0,0 +1,239 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
// onbekende eigenaar.
//
// **Er is sinds 0.7.0 (09-09-2026) precies één weg naar binnen: de wachtlijst.**
// Een onbekende eigenaar wordt geweigerd en onthouden; op de statuspagina staat
// hij met zijn `OwnerId` erbij en daar laat je hem toe. De cliënt verbindt
// vanzelf opnieuw en is dan binnen.
//
// Tot 0.6.0 was er daarnaast een getimede leerstand die de eerstvolgende
// onbekende eigenaars toeliet. Die is eruit op voorstel van de gebruiker, en de
// reden is dat het dubbelop was: allebei de wegen vragen iemand die bij de app
// kan, en het venster is de zwakste van de twee omdat het iederéén toelaat die
// er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek
// wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je
// eigen `OwnerId` nergens kon aflezen, en die reden verviel toen de weigerlijst
// dat id ging tonen.
//
// De velden `learning` en `learningUntil` worden nog wél gelézen, en dat is met
// opzet: `STATE_VERSION` verhogen zou `normalizeState` een bestaand bestand
// laten afwijzen, en dan schuift `store.js` de allowlist van een werkende
// installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
// Ze worden gelezen, genegeerd en niet teruggeschreven.
//
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
// er binnenkomt, en zulke logica hoort te testen te zijn zonder relay en zonder
// Docker.
//
// De relay van Evolu roept `isOwnerAllowed(ownerId)` aan. Wij beantwoorden die
// vraag hier; `src/index.js` doet niets anders dan lezen, doorgeven en opslaan.
// ═══════════════════════════════════════════════════════════════════════════════
export const STATE_VERSION = 1;
// Hoeveel wachtenden we onthouden om op de pagina te tonen. Er zit een grens op
// omdat een onbekende die blijft proberen anders het bestand vol schrijft: elke
// poging is een eigenaar-id dat hij zelf verzint.
//
// Het veld heet op schijf nog `rejected` en dat blijft zo. Op de pagina heet het
// sinds 0.7.0 de wachtlijst, want dat is wat het is: kandidaten die op een
// beslissing wachten. De naam in het bestand hernoemen zou een migratie zijn, en
// die is het niet waard voor een woord dat niemand ziet.
export const MAX_REJECTED = 20;
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
// buiten en belandt in een bestand. Daarom een bovengrens en een typecontrole, en
// verder geen aannames over de vorm.
export const MAX_OWNER_ID_LENGTH = 256;
export const createEmptyState = () => ({
version: STATE_VERSION,
owners: [],
rejected: [],
});
const isUsableOwnerId = (value) =>
typeof value === 'string' && value.length > 0 && value.length <= MAX_OWNER_ID_LENGTH;
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
/**
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
*
* Bewust streng: een half begrepen bestand is gevaarlijker dan geen bestand,
* want dan denkt de app dat er een allowlist is terwijl die leeg is. De
* aanroeper beslist wat er bij `null` gebeurt, en die keuze staat in `store.js`.
*/
export const normalizeState = (raw) => {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
if (raw.version !== STATE_VERSION) return null;
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
// `learning` en `learningUntil` mogen er nog in staan en worden overgeslagen.
// Een bestand van 0.6.0 of ouder heeft ze, en dat bestand hoort gewoon te
// lezen; zie de kop van dit bestand voor waarom hier geen versieverhoging
// tegenover staat.
const owners = [];
for (const owner of raw.owners) {
if (owner === null || typeof owner !== 'object') return null;
if (!isUsableOwnerId(owner.id)) return null;
if (typeof owner.allowed !== 'boolean') return null;
owners.push({
id: owner.id,
allowed: owner.allowed,
firstSeen: typeof owner.firstSeen === 'string' ? owner.firstSeen : null,
lastSeen: typeof owner.lastSeen === 'string' ? owner.lastSeen : null,
});
}
const rejected = [];
for (const entry of raw.rejected) {
if (entry === null || typeof entry !== 'object') return null;
if (!isUsableOwnerId(entry.id)) return null;
rejected.push({
id: entry.id,
firstSeen: typeof entry.firstSeen === 'string' ? entry.firstSeen : null,
lastSeen: typeof entry.lastSeen === 'string' ? entry.lastSeen : null,
attempts: Number.isInteger(entry.attempts) && entry.attempts > 0 ? entry.attempts : 1,
});
}
return { version: STATE_VERSION, owners, rejected };
};
const rememberWaiting = (rejected, ownerId, now) => {
const existing = rejected.find((entry) => entry.id === ownerId);
if (existing) {
return rejected.map((entry) =>
entry.id === ownerId ? { ...entry, lastSeen: now, attempts: entry.attempts + 1 } : entry,
);
}
// Nieuwste vooraan, en afkappen op MAX_REJECTED. De oudste valt eraf; dat is
// hier de juiste kant om te verliezen, want een aanvaller die blijft proberen
// duwt dan zijn eigen eerdere pogingen weg en niet de allowlist.
return [{ id: ownerId, firstSeen: now, lastSeen: now, attempts: 1 }, ...rejected].slice(
0,
MAX_REJECTED,
);
};
/**
* Beslist of deze eigenaar erin mag, en geeft de staat terug zoals hij daarna is.
*
* @returns {{allowed: boolean, state: object, reason: string, changed: boolean}}
*/
export const decideOwner = (state, ownerId, now) => {
if (!isUsableOwnerId(ownerId)) {
// Geen bruikbaar id: weigeren en niets onthouden. Onthouden zou hier juist
// de weg openzetten om het bestand vol te schrijven met rommel.
return { allowed: false, state, reason: 'invalid-owner-id', changed: false };
}
const known = findOwner(state, ownerId);
if (known) {
const seen = { ...known, lastSeen: now };
const owners = state.owners.map((owner) => (owner.id === ownerId ? seen : owner));
return {
allowed: known.allowed,
state: { ...state, owners },
reason: known.allowed ? 'known-allowed' : 'known-blocked',
changed: known.lastSeen !== now,
};
}
// Onbekend: weigeren en op de wachtlijst zetten. Dat is sinds 0.7.0 de enige
// uitkomst voor een onbekende, en daarmee ook de enige weg naar binnen: op de
// statuspagina staat hij met zijn `OwnerId` erbij en daar laat je hem toe.
return {
allowed: false,
state: { ...state, rejected: rememberWaiting(state.rejected, ownerId, now) },
reason: 'waiting',
changed: true,
};
};
/**
* Voert een opdracht van de statuspagina uit.
*
* De pagina schrijft een opdrachtbestand en dit proces past hem toe; er is geen
* Docker-socket en geen tweede container die in dezelfde staat schrijft. Dat is
* dezelfde afspraak als bij Electrum Gate.
*
* @returns {{state: object, changed: boolean, error: string|null}}
*/
export const applyCommand = (state, command, now) => {
const unchanged = (error) => ({ state, changed: false, error });
if (command === null || typeof command !== 'object') return unchanged('malformed-command');
switch (command.action) {
case 'block': {
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
const owners = state.owners.map((owner) =>
owner.id === command.ownerId ? { ...owner, allowed: false, lastSeen: owner.lastSeen } : owner,
);
return { state: { ...state, owners }, changed: true, error: null };
}
case 'allow': {
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
const known = findOwner(state, command.ownerId);
// Ook een eigenaar die alleen als geweigerde poging bekend is, mag hiermee
// alsnog toegelaten worden. Dat is de knop naast zo'n regel op de pagina.
//
// De geschiedenis van die pogingen gaat mee. Dat leek een detail tot het
// op 28-08-2026 misging: de pagina meldde daarna "first seen" op het moment
// van de klik, terwijl die eigenaar al twintig minuten aan de deur stond.
// Juist bij een eigenaar die je alsnog toelaat wil je kunnen terugzien
// sinds wanneer hij er is.
const attempted = state.rejected.find((entry) => entry.id === command.ownerId);
const owners = known
? state.owners.map((owner) =>
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
)
: [
...state.owners,
{
id: command.ownerId,
allowed: true,
firstSeen: attempted && attempted.firstSeen ? attempted.firstSeen : now,
lastSeen: attempted ? attempted.lastSeen : null,
},
];
return {
state: {
...state,
owners,
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
},
changed: true,
error: null,
};
}
case 'forget': {
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
const inOwners = Boolean(findOwner(state, command.ownerId));
const inRejected = state.rejected.some((entry) => entry.id === command.ownerId);
if (!inOwners && !inRejected) return unchanged('unknown-owner');
return {
state: {
...state,
owners: state.owners.filter((owner) => owner.id !== command.ownerId),
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
},
changed: true,
error: null,
};
}
default:
return unchanged('unknown-action');
}
};
+107
View File
@@ -0,0 +1,107 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De staat op schijf: de allowlist en het opdrachtbestand van de statuspagina.
//
// Dit is de enige plek met bestanden erin. Het beleid zelf staat in `policy.js`
// en weet hier niets van.
//
// Waar het staat: onder `${APP_DATA_DIR}/data/`, náást de database van de relay
// en niet erin. De relay bezit zijn eigen SQLite en daar schrijven wij niet in.
// ═══════════════════════════════════════════════════════════════════════════════
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { createEmptyState, normalizeState } from './policy.js';
export const stateFileName = 'owners.json';
export const commandFileName = 'command.json';
/**
* Leest de allowlist.
*
* Drie uitkomsten, en de derde is de interessante:
* - `new`: er is nog niets, dus een verse staat met de leerstand aan;
* - `ok`: gelezen en begrepen;
* - `corrupt`: er stond iets wat we niet begrijpen. Dan schuiven we het bestand
* opzij en beginnen we met de leerstand **uit**.
*
* Die laatste keuze is bewust en volgt de regel die deze store al kent van
* Electrum Gate: bij twijfel weigert de app. Een onleesbaar bestand betekent dat
* we niet weten wie er toegelaten was; met de leerstand aan zou de eerstvolgende
* die verbindt stilzwijgend de nieuwe eigenaar worden, en dat hoeft je eigen
* apparaat niet te zijn. Dan liever een app die niemand toelaat en dat op de
* pagina toont.
*/
export const readState = (dataDir, now) => {
const file = join(dataDir, stateFileName);
if (!existsSync(file)) return { state: createEmptyState(), status: 'new' };
let parsed = null;
try {
parsed = JSON.parse(readFileSync(file, 'utf8'));
} catch {
parsed = null;
}
const state = parsed === null ? null : normalizeState(parsed);
if (state !== null) return { state, status: 'ok' };
// Opzij schuiven en niet weggooien: als dit ooit gebeurt, wil je kunnen zien
// wat erin stond.
const aside = `${file}.corrupt-${now.replace(/[:.]/g, '-')}`;
try {
renameSync(file, aside);
} catch {
// Lukt het opzij schuiven niet, dan gaan we alsnog dicht. De melding hieronder
// is dan het enige dat de gebruiker heeft, en dat is nog steeds beter dan
// stilzwijgend iemand toelaten.
}
return { state: { ...createEmptyState(), learning: false }, status: 'corrupt' };
};
/**
* Schrijft de allowlist. Eerst naar een tijdelijk bestand en dan hernoemen, zodat
* een onderbroken schrijfactie geen half bestand achterlaat: hernoemen binnen
* dezelfde map is atomair.
*/
export const writeState = (dataDir, state) => {
mkdirSync(dataDir, { recursive: true });
const file = join(dataDir, stateFileName);
const temporary = `${file}.tmp`;
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
renameSync(temporary, file);
};
/**
* Haalt een opdracht van de statuspagina op en verwijdert het bestand meteen.
*
* Verwijderen vóór het uitvoeren is met opzet: een opdracht die het proces laat
* struikelen mag niet bij elke ronde opnieuw worden geprobeerd.
*/
export const takeCommand = (dataDir) => {
const file = join(dataDir, commandFileName);
if (!existsSync(file)) return null;
let raw = null;
try {
raw = readFileSync(file, 'utf8');
} catch {
raw = null;
}
try {
unlinkSync(file);
} catch {
// Niets aan te doen; de volgende ronde probeert het opnieuw.
}
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
};
/** De map waarin de staat hoort, afgeleid van het pad van de relay-database. */
export const dataDirectoryFor = (relayDatabaseFile) => dirname(relayDatabaseFile);