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

+54
View File
@@ -0,0 +1,54 @@
# ═══════════════════════════════════════════════════════════════════════════════
# De image voor de app whatsnext-electrum-gate.
#
# Eén image voor twee containers. De compose start hem twee keer: als `server`
# met het standaardcommando (entrypoint.sh, dat nginx wordt) en als `agent` met
# `python3 /app/agent.py`. Twee images was netter gescheiden geweest, maar het
# verdubbelt het bouw- en pinwerk, en nginx plus python3 in één alpine is klein.
# Beslist bij de start van het plan Eigenimage, OPEN.md punt 2.
#
# Tot 0.0.29 stonden deze bestanden als *.template in de app-map en draaiden ze
# op python:3-alpine en nginx:alpine. Wat dat kostte staat in het plan
# Eigenimage, PLAN.md §3; het kortste antwoord: het command-blok van de compose
# was ruim honderd regels shell in een YAML-string met verdubbelde dollartekens,
# en dat is nu een gewoon script.
#
# De bouwcontext is de REPO-ROOT, niet deze map. Dat is omdat icon.png in de
# app-map blijft staan (het manifest wijst ernaar voor de tegel in de winkel) en
# de pagina hem óók nodig heeft; kopiëren uit de app-map voorkomt een tweede
# exemplaar dat stil achterloopt. build.sh geeft die context mee.
# ═══════════════════════════════════════════════════════════════════════════════
# nginx en niet python als basis: de officiële nginx-image heeft de stream-module
# ingebouwd, en op alpine's eigen nginx-pakket is dat een los pakket. De app draaide
# al op nginx:alpine, dus dit houdt nginx precies zoals hij was. De tag is de
# stable-reeks (even tweede cijfer); zie HomeGit/Docs/Referenties/UmbrelApps/Images-pinnen.md.
FROM nginx:1.30-alpine
# De agent gebruikt alleen de standaardbibliotheek, inclusief ssl; dat zit in het
# python3-pakket van alpine.
RUN apk add --no-cache python3
COPY tools/electrum-gate/nginx.conf /etc/nginx/nginx.conf
# Met .in erachter, want dit is nog niet de configuratie die nginx leest: het
# backend-adres komt uit de omgeving en entrypoint.sh vult het bij het starten in.
COPY tools/electrum-gate/stream.conf /etc/nginx/stream.conf.in
COPY tools/electrum-gate/index.html /usr/share/nginx/html/index.html
COPY whatsnext-electrum-gate/icon.png /usr/share/nginx/html/icon.png
COPY tools/electrum-gate/agent.py /app/agent.py
COPY tools/electrum-gate/entrypoint.sh /entrypoint.sh
# De uitvoerbit hier zetten en niet vertrouwen op wat git meegeeft: de repo wordt
# ook op Windows bewerkt en daar bestaat die bit niet.
RUN chmod 0755 /entrypoint.sh
ENV PYTHONUNBUFFERED=1
EXPOSE 80 50022
# De nginx-image heeft een eigen entrypoint dat scripts uit /docker-entrypoint.d
# draait. Die doen hier niets nuttigs (envsubst op een templates-map die leeg is)
# en één ervan zou, als hij ooit wél iets deed, precies de fout maken waar dit
# project al een dag aan kwijt was. Weg ermee: het commando is het commando.
ENTRYPOINT []
CMD ["/entrypoint.sh"]
File diff suppressed because it is too large. Load diff
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# ═══════════════════════════════════════════════════════════════════════════════
# Bouwt de image voor de app whatsnext-electrum-gate.
#
# Eén image voor de twee containers van de app: nginx met de pagina en het
# stream-blok, en python3 met de agent. Wat erin gaat staat in de Dockerfile
# hiernaast; de bestanden zelf staan in deze map, behalve icon.png, dat in de
# app-map blijft omdat het manifest ernaar wijst.
#
# 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/electrum-gate/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/electrum-gate/build.sh
# Dat vraagt buildx met QEMU en duwt meteen naar het register, want een
# multi-arch-manifest bestaat niet lokaal. Nog nooit geprobeerd; zie het plan
# Eigenimage, PLAN.md §4. Zonder PLATFORMS bouwt dit één architectuur, die van
# de machine waarop je staat, en dat is voor eigen gebruik op een amd64-Umbrel
# genoeg.
# ═══════════════════════════════════════════════════════════════════════════════
set -eu
# ── De pin ────────────────────────────────────────────────────────────────────
# 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 (twee keer: 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 de eigen image raakt vrijwel elke wijziging de image, dus ze lopen
# meestal gelijk; de regel blijft: verhoog je VERSION, dan ook het manifest.
VERSION="0.1.0"
# 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 bij Evolu Relay op het apparaat vastgesteld.
IMAGE="sc.kamenier-hamer.nl/sysop/electrum-gate:${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 entrypoint.sh agent.py nginx.conf stream.conf index.html; do
if [ ! -f "${RECEPT}/${bestand}" ]; then
echo "FOUT: ${bestand} ontbreekt in ${RECEPT}" >&2
exit 1
fi
done
if [ ! -f "${REPO}/whatsnext-electrum-gate/icon.png" ]; then
echo "FOUT: whatsnext-electrum-gate/icon.png ontbreekt in ${REPO}" >&2
exit 1
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}/stream.conf" "${RECEPT}/index.html" 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-electrum-gate/docker-compose.yml (twee keer, agent en server), en"
echo "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}"
+141
View File
@@ -0,0 +1,141 @@
#!/bin/sh
# ═══════════════════════════════════════════════════════════════════════════════
# Het startscript van de server-container van Electrum Gate.
#
# Tot 0.0.29 stond dit als inline command-blok in docker-compose.yml, omdat de
# compose in de update-whitelist van umbreld staat en een los script niet. Dat
# betekende shell in een YAML-string, met elk dollarteken verdubbeld omdat
# umbreld anders elke ${...} leegmaakt. Sinds de eigen image is het een gewoon
# bestand; wat het doet is niet veranderd.
#
# Drie dingen die hier gebeuren en waarom ze hier gebeuren en niet in de agent:
#
# 1. de TLS-poort aan- of uitzetten naar de toestand van cert.conf. nginx weigert
# te starten met een 'listen ssl' zonder certificaat, en de pagina waarop je
# dat certificaat kiest moet juist altijd omhoog komen;
# 2. de open verbindingen tellen. Dat kan alleen in deze netwerk-namespace;
# 3. nginx herladen als de agent een vlag neerzet. De agent kan dat niet zelf,
# want dat zou de Docker-socket vragen en die is er bewust uit.
# ═══════════════════════════════════════════════════════════════════════════════
set -eu
STATE=/var/lib/gate
mkdir -p "$STATE" "$STATE/tls"
# ── Het stream-blok invullen ──────────────────────────────────────────────────
# Het backend-adres komt uit de omgeving, dezelfde twee variabelen die de agent
# krijgt. Tot 0.0.29 vulde umbreld ze in via stream.conf.template; nu de
# configuratie in de image zit, gebeurt dat hier. nginx kan in een proxy_pass van
# het stream-blok zelf geen omgevingsvariabele lezen.
#
# Twee plaatshouders zonder dollarteken, zodat het bestand zelf nooit door een
# envsubst-stap beschadigd kan worden, ook niet als iemand het ooit weer als
# template in de app-map zet.
if [ -n "${GATE_ELECTRUM_HOST:-}" ] && [ -n "${GATE_ELECTRUM_PORT:-}" ]; then
sed -e "s|__GATE_ELECTRUM_HOST__|${GATE_ELECTRUM_HOST}|g" \
-e "s|__GATE_ELECTRUM_PORT__|${GATE_ELECTRUM_PORT}|g" \
/etc/nginx/stream.conf.in > /etc/nginx/stream.conf
else
# Zonder backend is er niets om naar door te sturen. De pagina komt wel omhoog
# en de agent meldt daar dat het adres ontbreekt; TLS blijft uit.
rm -f /etc/nginx/stream.conf
echo "GATE_ELECTRUM_HOST of GATE_ELECTRUM_PORT ontbreekt; TLS blijft uit, de pagina werkt."
fi
# ── De TLS-poort aan of uit ───────────────────────────────────────────────────
# Hier zat tot 0.0.3 een lus die wachtte tot de agent een certificaat gekozen had,
# en dat was fout: nginx startte dan niet, dus de pagina kwam niet omhoog, en de
# pagina is juist waar je dat certificaat kiest.
#
# Nu start nginx altijd. Het stream-blok komt erbij zodra cert.conf er is, en
# verdwijnt weer als de agent zijn keuze intrekt. nginx.conf haalt de map tls/ op
# met een jokerteken; die matcht dan niets en dat is geen fout.
#
# Deze functie eindigt altijd geslaagd, en dat is met opzet: het script draait
# onder 'set -e', dus een mislukte cp zou de aanroeper afbreken. In de lus
# hieronder is die aanroeper de wachtlus, en die mag om geen enkele reden stoppen.
sync_tls() {
if [ -f "$STATE/cert.conf" ] && [ -f /etc/nginx/stream.conf ]; then
# Kopiëren en niet linken: nginx opent dit pad als de gebruiker nginx, en een
# symlink is hier nodeloos fragiel.
if cp /etc/nginx/stream.conf "$STATE/tls/stream.conf"; then
echo "Certificaat aanwezig, TLS luistert op 50022."
else
echo "stream.conf kon niet worden weggezet; TLS blijft uit."
fi
else
rm -f "$STATE/tls/stream.conf"
echo "Nog geen certificaat gekozen; TLS staat uit, de pagina werkt."
fi
}
sync_tls
# ── De verbindingsteller ──────────────────────────────────────────────────────
# nginx stream schrijft zijn logregel pas bij het sluiten van een sessie, en een
# wallet houdt zijn verbinding uren open; zonder deze teller lijkt een actieve
# wallet dus afwezig. Dat was de vraag van de gebruiker op 20-08-2026.
#
# /proc/net/tcp geldt per netwerk-namespace, dus dit ziet alleen de sockets van
# deze container, en daarom staat de teller hier en niet in de agent: die zit in
# een andere namespace en kan er niet bij. Kolom 2 is het lokale adres met de
# poort in hex, kolom 4 de toestand. C366 is 50022 en 01 is ESTABLISHED, dus de
# luisterende socket (0A) en de verbindingen naar de backend en naar de pagina
# vallen er buiten.
#
# Geteld wordt alleen het aantal. Geen adres en geen bronpoort, net als in het
# log_format in stream.conf.
count_sessions() {
cat /proc/net/tcp /proc/net/tcp6 2>/dev/null \
| awk '$4 == "01" && $2 ~ /:C366$/ { n++ } END { print n+0 }'
}
# Meteen een 0 neerzetten, zodat de pagina "geen verbindingen" kan tonen in
# plaats van "onbekend" in het minuutje voor de eerste ronde.
count_sessions > "$STATE/sessions"
# ── De herlaadlus ─────────────────────────────────────────────────────────────
# Draait op de achtergrond; nginx wordt hieronder met exec het hoofdproces.
# Andersom gaat twee keer mis: de shell blijft dan PID 1 en geeft signalen niet
# door, en als nginx omvalt eindigt het script met exitcode 0, waardoor Docker een
# geslaagde afsluiting ziet en 'restart: on-failure' niet ingrijpt.
reload_nginx() {
# Een certificaat dat nginx niet aanneemt laat de reload falen. Dat mag niet
# met 'set -e' meegaan, want dan verdwijnt de lus en wordt geen enkele latere
# wijziging meer opgepikt, zonder dat er iets te zien is.
if ! nginx -s reload; then
echo "Herladen mislukt; nginx houdt de vorige configuratie."
fi
}
(
while true; do
sleep 10
# Eerst schrijven, dan mv: de agent leest dit bestand op zijn eigen moment en
# mag geen half bestand zien.
count_sessions > "$STATE/sessions.tmp"
mv "$STATE/sessions.tmp" "$STATE/sessions"
if [ -f "$STATE/reload" ]; then
echo "Certificaat gewijzigd, nginx herladen ..."
# Eerst wegzetten, dan herladen. Andersom zou een herlading die mislukt de
# vlag toch opruimen, en dan probeert hij het nooit meer.
mv "$STATE/reload" "$STATE/reload.done"
# Voor de herlading, niet erna: een reload leest de configuratie opnieuw,
# dus het stream-blok moet er dan al liggen.
sync_tls
reload_nginx
elif [ -f "$STATE/cert.conf" ] && [ ! -f "$STATE/tls/stream.conf" ]; then
# De vangnetregel. De agent zet de vlag bij elke wijziging, dus normaal komt
# hier niets langs; wel als de vlag verloren gaat of als cert.conf van een
# vorige installatie al klaarlag. Zonder deze tak zou TLS dan uit blijven
# staan tot de volgende wijziging.
echo "Certificaat gevonden zonder herlaadvlag, TLS aanzetten ..."
sync_tls
reload_nginx
fi
done
) &
exec nginx -g 'daemon off;'
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
# nginx-configuratie voor Electrum Gate.
#
# Dit bestand zit in de image (tools/electrum-gate/Dockerfile) als
# /etc/nginx/nginx.conf. Tot 0.0.29 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 voor http nog steeds uit staat is een keuze en geen restant: de
# pagina hangt achter de app_proxy en wat die opvraagt is niet interessant.
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
# ── De web-UI ────────────────────────────────────────────────────────────────
# Wordt niet rechtstreeks gepubliceerd: umbrelOS zet er zijn eigen app_proxy
# voor, en die verwijst naar poort 80 van deze container.
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;
# status.json wordt door de agent in de gedeelde map geschreven, niet in
# de webroot. Geen cache: de pagina vraagt hem juist op om te zien hoe
# oud de gegevens zijn.
location = /status.json {
alias /var/lib/gate/status.json;
add_header Cache-Control "no-store";
}
# De API van de agent. Het enige pad waarlangs iets van buiten de
# instellingen van de app raakt, en dat is de certificaatkeuze.
#
# Waarom dit te verantwoorden is op een app waarvan de kleinheid het punt
# is: het pad hangt achter de app_proxy van umbrelOS, die er zijn eigen
# inlog voor zet, de TLS-poort staat er los van, en de agent accepteert
# alleen een certificaat-id die hij zelf in de gemounte mappen gevonden
# heeft. Het ergste wat een geslaagde aanroep doet is een ander, ook
# bestaand, certificaat kiezen.
#
# Geen proxy_set_header hier: de agent heeft ze niet nodig.
#
# De volledige containernaam en niet de servicenaam 'agent'. Dat stond er
# tot 0.0.23 wel, en het werkte zolang deze app de enige was met een
# service van die naam. Sinds Evolu Relay er is, is dat niet meer waar:
# beide apps zitten op hetzelfde Docker-netwerk, allebei met een 'agent'
# op poort 8000, en Docker verdeelt de naam dan over de twee. Op
# 28-08-2026 gemeten op het apparaat: van tien verzoeken kwamen er vijf
# bij de verkeerde app uit. De vorm <app-id>_<service>_1 is uniek per app;
# het is dezelfde les die in de compose al stond over de naam 'server'.
location /api/ {
proxy_pass http://whatsnext-electrum-gate_agent_1:8000;
client_max_body_size 1k;
}
# Het uploaden van een certificaat, en het enige pad met een grotere
# limiet: een sleutel plus keten haalt de 1k van hierboven ruim. Een
# exacte match, dus deze regel wint van /api/ hierboven en de grotere
# limiet geldt nergens anders. De agent kapt zelf ook af, op 96k; deze
# limiet is de eerste zeef en niet de enige.
location = /api/certificate/upload {
proxy_pass http://whatsnext-electrum-gate_agent_1:8000;
client_max_body_size 96k;
}
}
}
# ── De TLS-terminatie ────────────────────────────────────────────────────────
# Die staat niet hier maar in stream.conf, en dat is geen ordening maar de
# reparatie van een klem. nginx weigert te starten als een 'listen ... ssl' geen
# certificaat heeft; stond het stream-blok hier, dan kwam met dat blok ook de
# pagina hierboven niet omhoog zolang de agent geen certificaat gekozen had. En
# de pagina is juist de plek waar die keuze gemaakt wordt.
#
# entrypoint.sh legt stream.conf in de map hieronder zodra cert.conf bestaat, en
# haalt het weg als dat niet zo is. Een include met een jokerteken die nergens op
# uitkomt is voor nginx geen fout, dus zonder certificaat start deze server
# zonder poort 50022 en met een pagina die vertelt waarom.
include /var/lib/gate/tls/*.conf;
+65
View File
@@ -0,0 +1,65 @@
# De TLS-terminatie van Electrum Gate, als los bestand, en dat is de hele truc.
#
# nginx weigert te starten als een 'listen ... ssl' geen certificaat heeft, en
# welk certificaat dat wordt weet alleen de agent. Zolang dit blok in nginx.conf
# stond, kwam de server dus niet omhoog voordat er een keuze lag, en daarmee ook
# de web-UI niet: precies de pagina waarop die keuze gemaakt wordt. Een gebruiker
# zonder Zoraxy, of met twee kandidaten, zag daardoor helemaal niets en had geen
# enkele weg vooruit. Zo aangetroffen in 0.0.3.
#
# Nu staat het blok hier, en entrypoint.sh zet dit bestand pas in
# /var/lib/gate/tls/ zodra de agent cert.conf geschreven heeft. nginx.conf haalt
# die map op met een jokerteken, en een jokerteken dat niets matcht is voor nginx
# geen fout. Geen certificaat betekent dus: geen stream-blok, geen poort 50022,
# maar wel een werkende pagina die vertelt wat eraan mankeert.
#
# Dit bestand zit in de image als /etc/nginx/stream.conf.in. De twee plaatshouders
# __GATE_ELECTRUM_HOST__ en __GATE_ELECTRUM_PORT__ vult entrypoint.sh bij het
# starten in uit de omgeving; nginx kan in een proxy_pass van het stream-blok zelf
# geen omgevingsvariabele lezen. Tot 0.0.29 was dit een *.template in de app-map
# en deed umbreld die invulling, en daarom mocht er toen geen nginx-variabele met
# een dollarteken in staan. Die beperking is weg: het log_format staat nu gewoon
# hier en niet meer in een los bestand dat het command-blok wegschreef.
stream {
# Let op wat er NIET in staat: geen client-adres en geen bronpoort. Dat is
# precies het soort gegeven dat deze app van het netwerk af houdt, en om te
# zien dat het werkt is het niet nodig.
log_format gate '$time_iso8601 $status $bytes_received $bytes_sent $session_time';
access_log /var/lib/gate/stream.log gate;
server {
# 50022 en niet 50002; de reden staat bij de ports-regel in
# docker-compose.yml. Wijzig je dit, wijzig dan ook de hex-poort in de
# verbindingsteller van entrypoint.sh.
listen 50022 ssl;
# ssl_certificate en ssl_certificate_key, geschreven door de agent op
# grond van wat er in de certificaatmappen staat en wat de gebruiker op
# het dashboard gekozen heeft. Daarom staat er hier geen domeinnaam meer
# in dit bestand. Dat dit bestand bestaat betekent dat cert.conf er is:
# entrypoint.sh zet het er pas dan neer.
#
# nginx wil de VOLLEDIGE keten in ssl_certificate, dus het bestand van
# Zoraxy kan er ongewijzigd in. Splitsen in een servercertificaat en een
# CA-deel, zoals de oude stunnel-opzet deed, is hier juist fout.
include /var/lib/gate/cert.conf;
ssl_protocols TLSv1.2 TLSv1.3;
# Een Electrum-wallet houdt een langlopende verbinding open die het
# grootste deel van de tijd stil is. De standaardwaarde van
# proxy_timeout is tien minuten, en die verbreekt zo'n verbinding dus
# zonder aanleiding; de wallet merkt dat pas als hij iets wil en meldt
# dan dat er geen data is. stunnel hanteerde hier twaalf uur, en dat
# verschil is bij de overstap naar nginx over het hoofd gezien.
proxy_timeout 12h;
proxy_connect_timeout 10s;
# Houdt de verbinding naar de backend levend en laat een verbroken
# verbinding sneller opvallen dan pas bij het volgende verzoek.
proxy_socket_keepalive on;
proxy_pass __GATE_ELECTRUM_HOST__:__GATE_ELECTRUM_PORT__;
}
}
+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);
+4
View File
@@ -0,0 +1,4 @@
# Kopieer naar `.env` op de Umbrel en vul het token in uit Gitea:
# repo → Settings → Actions → Runners → Create new runner.
# Het token is eenmalig en wordt bij de eerste start verbruikt.
GITEA_RUNNER_REGISTRATION_TOKEN=
+40
View File
@@ -0,0 +1,40 @@
# ═══════════════════════════════════════════════════════════════════════════════
# Gitea Actions-runner voor de Umbrel: bouwt en duwt de image van Atreides
# Assets als daar een tag gezet wordt. De workflow zelf staat in die repo
# (`.gitea/workflows/release.yml`); hier staat alleen wát hem uitvoert.
#
# GEEN UMBREL-APP, en daarom staat dit onder `tools/` en niet in een app-map.
# umbrelOS weet hier niets van: dit is gereedschap op het apparaat, net als de
# bouwscripts van de andere twee apps.
#
# DE DOCKER-SOCKET ZIT ER WÉL IN, en dat is geen vergissing tegen de regel bij
# Electrum Gate ("geen docker-socket"). Die regel gaat over een app die door
# anderen geïnstalleerd wordt; dit is een bouwmachine en zonder de socket bouwt
# hij niets. Besef wat het betekent en besef het één keer goed: een runner aan
# die socket is in de praktijk root op dit apparaat, en hij voert uit wat er in
# de repo staat. Laat hem dus geen andermans repo's bouwen.
# ═══════════════════════════════════════════════════════════════════════════════
services:
runner:
# Gepind op wat er bij de eerste registratie draaide (18-09-2026). Een runner
# die stilzwijgend meeschuift met upstream is precies het soort verrassing dat
# een release-dag kost.
image: gitea/act_runner:0.6.1
container_name: gitea-act-runner
restart: unless-stopped
environment:
GITEA_INSTANCE_URL: https://sc.kamenier-hamer.nl
# Het registratietoken komt uit Gitea (repo → Settings → Actions → Runners)
# en staat in `.env` ernaast, nooit in de repo. Na de eerste start is het
# verbruikt: de registratie zelf ligt dan in `data/.runner`.
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN:?zet hem in .env}
GITEA_RUNNER_NAME: umbrel
# HET LABEL LINKS MOET GELIJK ZIJN AAN `runs-on:` IN DE WORKFLOW. Staat daar
# een label dat niemand draagt, dan wacht die workflow zonder te zeggen
# waarop. Rechts staat de image waarin een job draait; die moet de docker-CLI
# én buildx hebben, want de workflow bouwt en duwt.
GITEA_RUNNER_LABELS: ubuntu-latest:docker://catthehacker/ubuntu:act-latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data:/data
+31
View File
@@ -0,0 +1,31 @@
# De bronbestanden van de app-iconen
Hier hoort het werkbestand waaruit `icon.png` van een app komt: de gelaagde versie waarin je nog
kunt bewerken. Eén bestand per app, met de app-id als naam.
**Op dit moment staat er niets.** Beide iconen zijn buiten deze repo gemaakt. Op 28-08-2026 begon de
gebruiker aan een 3D-icoon voor Evolu Relay in Paint.NET; dat was op 30-08-2026 nog niet mooi genoeg
en is door hem weggehaald. Komt er een volgende poging, dan is dít de map.
## Waarom hier en niet in de app-map
**umbreld kopieert bij een installatie de héle app-map naar het apparaat**, met `rsync --archive`
naar `~/umbrel/app-data/<app-id>/`. Alles wat daar staat belandt dus op de Umbrel én in de back-up.
Voor een gelaagd bewerkbestand is dat allebei zinloos: het apparaat kan er niets mee en de back-up
wordt er alleen groter van.
Dat de app-map niets mag bevatten dat umbreld niet nodig heeft, controleert
`tests/test_appstore_vorm.py` sinds 30-08-2026. Kwam je hier omdat die toets over een bestand van
jou klaagde: dit is de plek waar het hoort.
## Wat er wél in de app-map staat
Alleen het resultaat: `icon.png`. Dat bestand staat **niet** in de whitelist die umbreld bij een
update ververst, dus een nieuw icoon bereikt een bestaande installatie niet. Twee dingen volgen
daaruit, en de tweede wordt makkelijk vergeten:
- het manifest verwijst met een `icon:`-regel naar de rauwe URL in deze repo, en dát is wat het
dashboard van umbrelOS toont. Een nieuw icoon is daar dus meteen zichtbaar;
- de kop van de statuspagina laadt `icon.png` uit de gemounte app-map, en die blijft na een update
het oude plaatje tonen tot de app opnieuw geïnstalleerd wordt. Reken er niet op dat een nieuw
icoon overal in één keer doorkomt.
+27
View File
@@ -0,0 +1,27 @@
# Instellingen van de testclient. Kopieer dit bestand naar .env en vul het in;
# .env staat in .gitignore en komt dus niet in de repo.
#
# Het adres van je Umbrel hoort hier en nergens anders: dit is een publieke app
# store, en een adres in de historie krijg je er niet meer uit.
# Waar de relay luistert. Poort 3852 is wat de app publiceert.
# Voorbeelden:
# RELAY_URL=ws://umbrel.local:3852
# RELAY_URL=ws://192.168.1.20:3852
# RELAY_URL=wss://relay.voorbeeld.nl (achter een reverse proxy met TLS)
#
# ws:// of wss:// mag je weglaten; zonder schema wordt het ws://.
#
# LET OP, en dit kostte op 09-09-2026 de eerste poging: zet bij wss:// GEEN
# poort 3852 erachter. Die poort is waar de relay zelf luistert, in plat ws,
# binnen je netwerk. Je certificaat zit op de reverse proxy en die luistert op
# 443. wss://host:3852 vraagt dus om TLS op een poort waar geen TLS is, en van
# buiten is die poort bovendien dicht. Laat de poort weg.
RELAY_URL=ws://umbrel.local:3852
# Waar het register en de databases komen te staan. Relatief aan
# tools/relay-client/. Hier staan mnemonics, dus deze map is gitignored.
#DATA_DIR=data
# De poort van het bedieningsvlak in de browser.
#UI_PORT=4380
+55
View File
@@ -0,0 +1,55 @@
@echo off
rem ============================================================================
rem Start het bedieningsvlak van de testclient en opent de browser.
rem
rem Dubbelklikken werkt: het venster blijft open zolang de server draait, en
rem blijft ook open als er iets misgaat, want anders lees je de fout nooit.
rem
rem De macOS-tegenhanger is Start.command ernaast.
rem ============================================================================
setlocal
cd /d "%~dp0"
where node >nul 2>nul
if errorlevel 1 (
echo Node is niet gevonden. Deze client heeft Node 24 of hoger nodig.
echo Zie https://nodejs.org
echo.
pause
exit /b 1
)
if not exist "node_modules" (
echo Pakketten ontbreken, npm install wordt gedraaid...
call npm install
if errorlevel 1 (
echo.
echo npm install is mislukt.
pause
exit /b 1
)
echo.
)
if not exist ".env" (
echo Let op: er is geen .env, dus er is geen relay-adres.
echo Kopieer .env.sample naar .env en vul RELAY_URL in.
echo Zonder dat werkt alles behalve verbinden met de relay.
echo.
)
rem De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem
rem dan in .env als UI_PORT en niet hier: dan weet de client het ook.
set UI_ADRES=http://127.0.0.1:4380
rem De browser gaat nu open, terwijl de server nog moet starten. Dat is goed:
rem het tabblad ververst zichzelf elke drie seconden, dus een eerste poging die
rem te vroeg komt herstelt vanzelf.
start "" "%UI_ADRES%"
node src\cli.js ui
echo.
echo De client is gestopt.
pause
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# =============================================================================
# Start het bedieningsvlak van de testclient en opent de browser. Voor macOS.
#
# Dubbelklikken in Finder werkt, maar alleen als het bestand uitvoerbaar is.
# Git bewaart dat bit, dus na een verse kloon hoort het te kloppen; is dat niet
# zo, dan eenmalig: chmod +x Start.command
#
# De Windows-tegenhanger is Start.bat ernaast.
# =============================================================================
set -u
cd "$(dirname "$0")"
if ! command -v node >/dev/null 2>&1; then
echo "Node is niet gevonden. Deze client heeft Node 24 of hoger nodig."
echo "Zie https://nodejs.org"
echo
read -r -p "Enter om te sluiten."
exit 1
fi
if [ ! -d node_modules ]; then
echo "Pakketten ontbreken, npm install wordt gedraaid..."
if ! npm install; then
echo
echo "npm install is mislukt."
read -r -p "Enter om te sluiten."
exit 1
fi
echo
fi
if [ ! -f .env ]; then
echo "Let op: er is geen .env, dus er is geen relay-adres."
echo "Kopieer .env.sample naar .env en vul RELAY_URL in."
echo "Zonder dat werkt alles behalve verbinden met de relay."
echo
fi
# De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem dan
# in .env als UI_PORT en niet hier: dan weet de client het ook.
UI_ADRES="http://127.0.0.1:4380"
# De browser gaat nu open, terwijl de server nog moet starten. Dat is goed: het
# tabblad ververst zichzelf elke drie seconden, dus een eerste poging die te
# vroeg komt herstelt vanzelf.
open "$UI_ADRES" >/dev/null 2>&1 || true
node src/cli.js ui
echo
echo "De client is gestopt."
read -r -p "Enter om te sluiten."
+616
View File
@@ -0,0 +1,616 @@
{
"name": "whatsnext-relay-client",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "whatsnext-relay-client",
"version": "0.0.0",
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
},
"engines": {
"node": ">=24"
}
},
"node_modules/@evolu/common": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/@evolu/common/-/common-8.7.0.tgz",
"integrity": "sha512-52bfvAJZaOmAAmiaOdkGn4MtYbIK1xf5sSb9fjzdRRAD6XbJtRT4yqlJ5MxBacsUHDtMXjUW02XgYlbUXyJ04A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@noble/ciphers": "^2.3.0",
"@noble/hashes": "^2.3.0",
"@scure/bip39": "^2.3.0",
"@standard-schema/spec": "^1.1.0",
"kysely": "^0.29.5",
"random": "5.5.1"
},
"engines": {
"node": ">=24.0.0"
}
},
"node_modules/@evolu/nodejs": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@evolu/nodejs/-/nodejs-3.1.0.tgz",
"integrity": "sha512-xw1eYjsWNbQfJsDQ0pTe/2iYrxuzDG0cHMhObyCbHwICdJzXvEH2iVGzw4qPfxW4fikZNcUTsAHyzblW8tJkqg==",
"license": "MIT",
"dependencies": {
"better-sqlite3": "^12.11.1",
"ws": "^8.21.3"
},
"engines": {
"node": ">=24.0.0"
},
"peerDependencies": {
"@evolu/common": "^8.0.0",
"@evolu/oxlint-config": "^0.1.0",
"@evolu/typescript-config": "^0.1.0",
"oxlint": "^1.79.0",
"oxlint-tsgolint": "7.0.2001",
"typescript": ">=7.0.0",
"vite": ">=8.0.0",
"webpack": ">=5.108.4"
},
"peerDependenciesMeta": {
"@evolu/oxlint-config": {
"optional": true
},
"@evolu/typescript-config": {
"optional": true
},
"oxlint": {
"optional": true
},
"oxlint-tsgolint": {
"optional": true
},
"typescript": {
"optional": true
},
"vite": {
"optional": true
},
"webpack": {
"optional": true
}
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@scure/bip39": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.4.0.tgz",
"integrity": "sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/better-sqlite3": {
"version": "12.11.1",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/kysely": {
"version": "0.29.5",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.5.tgz",
"integrity": "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ==",
"license": "MIT",
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.96.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
"integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/random": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/random/-/random-5.5.1.tgz",
"integrity": "sha512-2TIJKf/BGjy0FGeZaan2+a/vq37afbSyscquADMFC13KBjFmg+10T/TkACpP9GMHfbEruKYbHqq0rjCA9x8gNA==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
"integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "whatsnext-relay-client",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Testcliënt voor Evolu Relay: meerdere eigenaars, elk met eigen sleutels, om het toegangsbeleid en de opslag te beproeven.",
"engines": {
"node": ">=24"
},
"scripts": {
"start": "node src/cli.js"
},
"dependencies": {
"@evolu/common": "8.7.0",
"@evolu/nodejs": "3.1.0"
}
}
+101
View File
@@ -0,0 +1,101 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De opdrachtregel lezen. Meer niet.
//
// Apart van cli.js omdat dit het enige deel van de opdrachtregel is dat te
// toetsen valt zonder iets te starten. Dezelfde scheiding als policy.js aan de
// relay-kant: pure functies aan de ene kant, alles wat schijf of netwerk raakt
// aan de andere.
// ═══════════════════════════════════════════════════════════════════════════════
export const OPDRACHTEN = {
nieuw: { argumenten: ['naam'], uitleg: 'Maakt een nieuwe eigenaar met verse sleutels' },
lijst: { argumenten: [], uitleg: 'Toont de eigenaars in het register' },
schrijf: { argumenten: ['naam', 'label'], uitleg: 'Schrijft een blob weg voor deze eigenaar' },
lees: { argumenten: ['naam'], uitleg: 'Toont de blobs van deze eigenaar' },
test: { argumenten: ['naam'], uitleg: 'Test of de relay deze eigenaar toelaat, zonder te verbinden' },
spiegel: { argumenten: ['naam'], uitleg: 'Haalt deze eigenaar op in een lege database, via de relay' },
sync: { argumenten: ['naam'], uitleg: 'Verbindt en blijft draaien tot Ctrl-C' },
vergeet: { argumenten: ['naam'], uitleg: 'Haalt een eigenaar uit het register' },
ui: { argumenten: [], uitleg: 'Start het bedieningsvlak in de browser' },
};
/**
* Leest `process.argv.slice(2)`.
*
* Geeft `{fout}` in plaats van te gooien: de aanroeper drukt dat af met de
* gebruiksaanwijzing eronder, en dat leest beter dan een stack trace.
*
* Vlaggen zijn `--naam waarde` of `--naam=waarde`. Een losse `--naam` is `true`.
*/
export const leesArgumenten = (argv) => {
const losse = [];
const vlaggen = {};
for (let i = 0; i < argv.length; i += 1) {
const stuk = argv[i];
if (!stuk.startsWith('--')) {
losse.push(stuk);
continue;
}
const kaal = stuk.slice(2);
const isGelijk = kaal.indexOf('=');
if (isGelijk > 0) {
vlaggen[kaal.slice(0, isGelijk)] = kaal.slice(isGelijk + 1);
continue;
}
const volgende = argv[i + 1];
if (volgende !== undefined && !volgende.startsWith('--')) {
vlaggen[kaal] = volgende;
i += 1;
} else {
vlaggen[kaal] = true;
}
}
const [opdracht, ...rest] = losse;
if (opdracht === undefined) return { fout: null, opdracht: null, waarden: {}, vlaggen };
const vorm = OPDRACHTEN[opdracht];
if (!vorm) return { fout: `Onbekende opdracht "${opdracht}".`, opdracht: null, waarden: {}, vlaggen };
if (rest.length < vorm.argumenten.length) {
const ontbreekt = vorm.argumenten.slice(rest.length).join(', ');
return { fout: `"${opdracht}" mist: ${ontbreekt}.`, opdracht, waarden: {}, vlaggen };
}
if (rest.length > vorm.argumenten.length) {
// Streng, en met opzet. `schrijf piet mijn label` ziet er goed uit en zou
// anders stil "mijn" als label nemen; dan sta je te kijken naar een blob die
// anders heet dan je typte.
return {
fout: `"${opdracht}" verwacht ${vorm.argumenten.length} argument(en), niet ${rest.length}. Gebruik aanhalingstekens om een label met spaties.`,
opdracht,
waarden: {},
vlaggen,
};
}
const waarden = {};
vorm.argumenten.forEach((naam, plek) => {
waarden[naam] = rest[plek];
});
return { fout: null, opdracht, waarden, vlaggen };
};
export const gebruiksaanwijzing = () => {
const regels = ['Gebruik: node src/cli.js <opdracht> [argumenten]', ''];
for (const [naam, vorm] of Object.entries(OPDRACHTEN)) {
const argumenten = vorm.argumenten.map((a) => `<${a}>`).join(' ');
regels.push(` ${`${naam} ${argumenten}`.trim().padEnd(24)}${vorm.uitleg}`);
}
regels.push('');
regels.push('Vlaggen:');
regels.push(' --bytes <n> Omvang van de blob bij `schrijf` (standaard 64)');
regels.push(' --lokaal Schrijven zonder de relay, bij `schrijf`');
regels.push(' --wacht <n> Seconden wachten op de relay, bij `spiegel` (standaard 15)');
regels.push(' --poort <n> Poort van het bedieningsvlak bij `ui`');
regels.push('');
regels.push('Het adres van de relay komt uit .env; zie .env.sample.');
return regels.join('\n');
};
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env node
// ═══════════════════════════════════════════════════════════════════════════════
// De opdrachtregel van de testcliënt.
//
// Alles wat hier gebeurt kan ook in het bedieningsvlak (`ui`), en dat is de
// bedoeling: dit is dezelfde laag, alleen zonder browser. Handig om een proef te
// scripten en om te zien wat er misgaat als de pagina niet meewerkt.
//
// De opdrachten staan in argumenten.js, het register in register.js, en het
// praten met Evolu in client.js. Hier staat alleen de lijm plus wat er op het
// scherm komt.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { gebruiksaanwijzing, leesArgumenten } from './argumenten.js';
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
import { leesConfig } from './config.js';
import { klopAan } from './probe.js';
import { openRegister } from './register.js';
const config = leesConfig();
const register = openRegister(config.dataDir);
const databasesIn = join(config.dataDir, 'db');
/** Zoekt een eigenaar op of stopt met een nette melding. */
const haalEigenaarOp = (naam) => {
const regel = register.zoek(naam);
if (!regel) {
console.error(`Geen eigenaar "${naam}". \`lijst\` toont wie er wel zijn.`);
process.exit(1);
}
return { regel, owner: eigenaarUitMnemonic(regel.mnemonic) };
};
/** Opent de opslag van een eigenaar, met of zonder relay. */
const openVoor = (regel, { metRelay }) =>
openStore({
directory: databasesIn,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: metRelay ? config.relayUrl : null,
// Een defect in een worker betekent dat deze instantie stuk is, en de
// opdracht erna wacht dan op een antwoord dat nooit komt. Afkappen is hier
// dus vriendelijker dan doordraaien: een opdrachtregel die blijft hangen
// zegt niets, een foutmelding wel.
onDefect: (defect) => {
console.error('Defect uit een worker, de opdracht wordt afgebroken:');
console.error(defect);
process.exit(1);
},
// Wat Evolu wérkelijk opent, en niet wat wij hem meegaven. De `OwnerId` in
// die URL is wat de relay ziet en dus wie er toegelaten of geweigerd wordt.
onWebSocket: (m) => {
console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`);
},
});
const eisRelay = () => {
if (config.relayUrl) return;
console.error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
process.exit(1);
};
const opdrachten = {
nieuw: async ({ naam }) => {
const owner = nieuweEigenaar();
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
console.log(`Eigenaar "${regel.naam}" aangemaakt.`);
console.log(` OwnerId ${regel.ownerId}`);
console.log('');
console.log('Dat OwnerId is wat je op de statuspagina van de app terugziet. De mnemonic staat');
console.log(`in ${register.pad} en wordt hier niet afgedrukt.`);
},
lijst: async () => {
const eigenaars = register.lijst();
if (eigenaars.length === 0) {
console.log('Nog geen eigenaars. `nieuw <naam>` maakt er een.');
return;
}
const breedte = Math.max(...eigenaars.map((e) => e.naam.length), 4);
console.log(`${'naam'.padEnd(breedte)} OwnerId`);
for (const eigenaar of eigenaars) {
console.log(`${eigenaar.naam.padEnd(breedte)} ${eigenaar.ownerId}`);
}
},
schrijf: async ({ naam, label }, vlaggen) => {
const { regel } = haalEigenaarOp(naam);
const bytes = Number.parseInt(vlaggen.bytes ?? '64', 10);
if (!Number.isInteger(bytes) || bytes < 1) {
console.error(`--bytes moet een positief geheel getal zijn, niet "${vlaggen.bytes}".`);
process.exit(1);
}
// Een herkenbaar patroon en geen willekeur: bij het teruglezen wil je kunnen
// zien dát het jouw bytes zijn, en niet alleen hoeveel het er waren.
const body = new Uint8Array(bytes);
for (let i = 0; i < bytes; i += 1) body[i] = i % 256;
// Standaard mét relay, want schrijven zonder verbinding zegt niets over de
// relay en dat is waarvoor dit gereedschap bestaat. Ontbreekt het adres, dan
// is dat een fout en geen stille lokale schrijfactie: anders denk je dat je
// iets beproefd hebt. Met `--lokaal` kies je er expliciet voor, en dat is
// precies wat de proef "toelaten en opnieuw verbinden" nodig heeft.
const lokaal = vlaggen.lokaal === true || vlaggen.lokaal === 'true';
if (!lokaal) eisRelay();
const store = await openVoor(regel, { metRelay: !lokaal });
try {
const geschreven = await store.schrijf(label, body);
console.log(`Geschreven: ${geschreven.id} (${bytes} bytes, label "${label}")`);
if (lokaal) {
console.log('Lokaal, dus niet naar de relay gestuurd.');
return;
}
console.log(`Verbonden met ${store.transportUrl}`);
// De synchronisatie loopt asynchroon. Even wachten is hier eerlijker dan
// meteen afsluiten en doen alsof het weg is. Wat er daarna op de relay
// staat, zegt de statuspagina van de app en niet deze uitdraai.
await new Promise((klaar) => {
setTimeout(klaar, 2000);
});
} finally {
await store.sluit();
}
},
lees: async ({ naam }) => {
const { regel } = haalEigenaarOp(naam);
const store = await openVoor(regel, { metRelay: false });
try {
const rijen = await store.lees();
if (rijen.length === 0) {
console.log(`"${naam}" heeft nog geen blobs.`);
return;
}
console.log(`${rijen.length} blob(s) voor "${naam}":`);
for (const rij of rijen) {
console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`);
}
} finally {
await store.sluit();
}
},
test: async ({ naam }) => {
eisRelay();
const { regel } = haalEigenaarOp(naam);
console.log(`Testen bij ${config.relayUrl} als ${regel.ownerId}`);
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
console.log(` status ${uitkomst.status ?? '-'}`);
console.log(` uitkomst ${uitkomst.uitleg}`);
if (!uitkomst.geaccepteerd && uitkomst.status === 401) {
console.log('');
console.log('Zet op de statuspagina van de app het venster voor nieuwe eigenaars open,');
console.log('of laat deze eigenaar daar alsnog toe.');
}
process.exit(uitkomst.geaccepteerd ? 0 : 1);
},
spiegel: async ({ naam }, vlaggen) => {
// De enige proef die van de cliëntkant kan bewijzen dat er werkelijk iets op
// de relay staat. Dezelfde eigenaar, een lege database ernaast: alles wat
// hier binnenkomt heeft de heenreis en de terugreis gemaakt.
//
// Waarom dit nodig is: de cliënt is local-first, dus `lees` toont je altijd
// je eigen rijen, ook als de relay ze nooit gezien heeft. Wat de relay
// bewaart is van hier niet te zien; dit is de omweg eromheen.
eisRelay();
const { regel } = haalEigenaarOp(naam);
const seconden = Number.parseInt(vlaggen.wacht ?? '15', 10);
const tijdelijk = mkdtempSync(join(tmpdir(), 'relay-client-spiegel-'));
console.log(`"${naam}" opnieuw opgehaald in een lege database.`);
console.log(` ${tijdelijk}`);
console.log(` ${config.relayUrl}`);
console.log('');
const store = await openStore({
directory: tijdelijk,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: config.relayUrl,
onWebSocket: (m) => { console.log(` socket ${m.wat}: ${m.url}${m.detail ? ` (${m.detail})` : ""}`); },
onDefect: (defect) => {
console.error('Defect uit een worker, de opdracht wordt afgebroken:');
console.error(defect);
process.exit(1);
},
});
try {
// Peilen tot het aantal een paar rondes stil blijft. Er is geen signaal
// "hier ben ik klaar": Evolu synchroniseert en blijft synchroniseren.
let vorige = -1;
let stil = 0;
for (let ronde = 0; ronde < seconden * 2; ronde += 1) {
await new Promise((klaar) => {
setTimeout(klaar, 500);
});
const nu = (await store.lees()).length;
if (nu === vorige && nu > 0) {
stil += 1;
if (stil >= 6) break;
} else {
stil = 0;
}
vorige = nu;
}
const rijen = await store.lees();
if (rijen.length === 0) {
console.log('Er kwam niets terug.');
console.log('');
console.log('Dat kan twee dingen betekenen en de cliënt kan ze niet uit elkaar houden:');
console.log('de relay heeft niets van deze eigenaar, of hij was er nog niet klaar mee.');
console.log('Probeer `--wacht 60` en kijk anders op de statuspagina van de app.');
return;
}
console.log(`${rijen.length} blob(s) terug van de relay:`);
for (const rij of rijen) {
console.log(` ${rij.id} ${String(rij.body?.length ?? 0).padStart(8)} bytes ${rij.label}`);
}
} finally {
await store.sluit();
// Eigen tijdelijke map, dus die ruimt deze opdracht zelf op. Er zit een
// database van een echte eigenaar in.
rmSync(tijdelijk, { recursive: true, force: true });
}
},
sync: async ({ naam }) => {
eisRelay();
const { regel } = haalEigenaarOp(naam);
const store = await openVoor(regel, { metRelay: true });
console.log(`"${naam}" verbonden met ${store.transportUrl}`);
console.log('Ctrl-C om te stoppen.');
const stop = async () => {
console.log('');
console.log('Afsluiten...');
await store.sluit();
process.exit(0);
};
process.on('SIGINT', () => {
void stop();
});
// Blijven draaien. Een lopende verbinding is precies wat de proef "blokkeren
// tijdens een lopende verbinding" nodig heeft; zie PLAN.md §4c.
await new Promise(() => {});
},
vergeet: async ({ naam }) => {
const weg = register.vergeet(naam);
console.log(`"${weg.naam}" is uit het register.`);
console.log('De lokale database blijft staan en op de relay verandert er niets.');
},
ui: async (_waarden, vlaggen) => {
const { startUi } = await import('./ui.js');
const poort = vlaggen.poort ? Number.parseInt(vlaggen.poort, 10) : config.uiPoort;
await startUi({ config: { ...config, uiPoort: poort }, register, databasesIn });
},
};
const { fout, opdracht, waarden, vlaggen } = leesArgumenten(process.argv.slice(2));
if (fout) {
console.error(fout);
console.error('');
console.error(gebruiksaanwijzing());
process.exit(1);
}
if (!opdracht) {
console.log(gebruiksaanwijzing());
process.exit(0);
}
try {
await opdrachten[opdracht](waarden, vlaggen);
} catch (oorzaak) {
console.error(oorzaak.message);
process.exit(1);
}
+258
View File
@@ -0,0 +1,258 @@
// ═══════════════════════════════════════════════════════════════════════════════
// De testcliënt zelf: eigenaars aanmaken en blobs door de relay duwen.
//
// Dit is de publieke kant van dit gereedschap. De opdrachtregel en straks de
// pagina praten hiermee en niet rechtstreeks met Evolu; `evolu-node.js` ernaast
// doet alleen de platformafhankelijkheden en heeft geen weet van eigenaars.
//
// Eén eigenaar is één mnemonic plus één database. Evolu leidt de bestandsnaam af
// uit `appName` en de eigenaar, dus alle eigenaars kunnen in dezelfde map staan
// zonder elkaar te raken; wat ze deelt is het proces, en dat is precies wat de
// gedeelde worker aankan (zie evolu-node.js).
//
// Wat hier NIET in hoort: iets dat de gegevens van Trezor Suite probeert te
// lezen. Deze cliënt maakt eigen sleutels en leest zijn eigen gegevens; zie de
// niet-doelen in het plan Testclient.
// ═══════════════════════════════════════════════════════════════════════════════
import {
AppName,
createAppOwner,
createEvolu,
createOwnerSecret,
createOwnerWebSocketTransport,
createQueryBuilder,
createRandomBytes,
createRun,
id,
mnemonicToOwnerSecret,
NonEmptyTrimmedString100,
Uint8Array as Uint8ArrayType,
} from '@evolu/common';
import { installPolyfills } from '@evolu/common/polyfills';
import { createNodeEvoluDeps } from './evolu-node.js';
// Node 24 mist `Map.prototype.getOrInsertComputed`, dat Evolu wel gebruikt.
// Zonder deze aanroep valt niet het aanmaken om maar de eerste schrijfactie, en
// dat is precies het soort fout dat je pas op het verkeerde moment vindt. Hier en
// niet in het startbestand, zodat elke ingang van dit gereedschap hem meekrijgt.
installPolyfills();
/**
* De naam waaronder deze cliënt zijn databases wegzet.
*
* Onderdeel van de bestandsnaam, dus veranderen betekent dat elke eigenaar zijn
* lokale gegevens kwijt is. Dat maakt op de relay niets uit (die kent alleen het
* `OwnerId`), maar de geschiedenis moet dan opnieuw opgehaald worden.
*/
export const APP_NAME = AppName.orThrow('WhatsnextRelayClient');
/**
* Het schema: één tabel met een label en een blob.
*
* Bewust klein. Wat hier beproefd wordt is de relay, niet een gegevensmodel. Het
* label is er om in een uitdraai te kunnen zien wélke rij je terugkrijgt, en de
* blob is waar de omvang in zit die de bytegrens van de relay moet raken.
*/
export const Schema = {
blob: {
id: id('Blob'),
label: NonEmptyTrimmedString100,
body: Uint8ArrayType,
},
};
const allesQuery = createQueryBuilder(Schema)((db) =>
db.selectFrom('blob').selectAll().orderBy('createdAt'),
);
// ═══════════════════════════════════════════════════════════════════════════════
// De gedeelde afhankelijkheden: één stel per proces, voor alle eigenaars samen.
//
// Dat is geen zuinigheid maar een eis. `createEvoluDeps` vraagt via
// `navigator.locks` het slot "tab" aan en kondigt de houder ervan aan als "tab
// leader"; de database-worker van een instantie breekt af met "Expected tab
// leader port." als die aankondiging er niet is. Eén slot betekent één houder,
// dus een tweede stel afhankelijkheden in hetzelfde proces wordt nooit leider en
// de tweede eigenaar valt om.
//
// Precies dat gebeurde op 09-09-2026 bij de gebruiker, bij de tweede eigenaar.
// Het stond al als valstrik 2 in PLAN.md §4e en in het commentaar van
// evolu-node.js ("Eén keer aanroepen en hergebruiken voor alle eigenaars"), en
// het was er alsnog in geslopen omdat `openStore` per aanroep een eigen stel
// maakte. In een browser is dit vanzelf goed: daar is er één tabblad met één
// gedeelde worker voor alle instanties.
//
// Meegeteld wordt hoeveel stores er open zijn, zodat het pas wordt opgeruimd als
// de laatste sluit.
// ═══════════════════════════════════════════════════════════════════════════════
let gedeeld = null;
/** Wie er een defect wil horen. Per open store één, en die gaat er bij het sluiten uit. */
const defectMelders = new Set();
/** Wie er wil weten welke WebSocket-URL er werkelijk geopend wordt. */
const socketMelders = new Set();
const haalGedeeldeDeps = (directory, consoleLevel) => {
if (gedeeld === null) {
const { deps, dispose } = createNodeEvoluDeps({
directory,
consoleLevel,
onDefect: (defect) => {
// Naar iedereen die luistert. Een defect komt uit een gedeelde worker en
// is dus niet aan één eigenaar toe te schrijven; doen alsof van wel zou
// het onderzoek de verkeerde kant op sturen.
for (const melder of defectMelders) melder(defect);
},
onWebSocket: (url) => {
for (const melder of socketMelders) melder(url);
},
});
gedeeld = { directory, deps, dispose, aantal: 0 };
} else if (gedeeld.directory !== directory) {
throw new Error(
`Er is al een gegevensmap open (${gedeeld.directory}). Eén map per proces; `
+ 'sluit eerst alle stores voordat je een andere map gebruikt.',
);
}
gedeeld.aantal += 1;
return gedeeld.deps;
};
const laatGedeeldeDepsLos = async () => {
if (gedeeld === null) return;
gedeeld.aantal -= 1;
if (gedeeld.aantal > 0) return;
const weg = gedeeld;
gedeeld = null;
await weg.dispose();
};
/** Maakt een nieuwe eigenaar met verse sleutels. */
export const nieuweEigenaar = () =>
createAppOwner(createOwnerSecret({ randomBytes: createRandomBytes() }));
/**
* Herstelt een eigenaar uit zijn mnemonic.
*
* Dit is de enige weg terug: de mnemonic is wat bewaard wordt, het `OwnerId` is
* er een afgeleide van. Een onleesbare mnemonic gooit, en dat hoort: stil een
* andere eigenaar maken zou een proef opleveren die nergens over gaat.
*/
export const eigenaarUitMnemonic = (mnemonic) => createAppOwner(mnemonicToOwnerSecret(mnemonic));
/**
* Opent de opslag van één eigenaar.
*
* `relayUrl` weglaten of leeg laten geeft een instantie zonder synchronisatie.
* Dat is niet alleen voor toetsen nuttig: het is ook hoe je een eigenaar met
* gegevens vult vóórdat je hem voor het eerst laat verbinden, en dat is wat de
* proef "toelaten en opnieuw verbinden" nodig heeft.
*
* @param {{directory: string, owner: object, relayUrl?: string, onDefect?: Function, consoleLevel?: string}} config
*/
export const openStore = async ({
directory,
owner,
relayUrl,
onDefect,
onWebSocket,
consoleLevel = 'error',
}) => {
const deps = haalGedeeldeDeps(directory, consoleLevel);
if (onDefect) defectMelders.add(onDefect);
if (onWebSocket) socketMelders.add(onWebSocket);
// Wél een eigen run per store, want die bezit de instantie: één eigenaar
// sluiten hoort de andere niet te raken. Alleen de afhankelijkheden eronder
// zijn gedeeld.
const run = createRun(deps);
// Het OwnerId gaat mee in de URL, en dat is wat de relay bij de
// WebSocket-upgrade leest om te beslissen of deze eigenaar erin mag. Zonder dat
// is er niets te beslissen en is de hele allowlist niet te beproeven; zie §10
// van HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md.
const transports = relayUrl
? [createOwnerWebSocketTransport({ url: relayUrl, ownerId: owner.id })]
: [];
// De instantie start ZONDER sync en de transport gaat er daarna met `useOwner`
// in, ook al is het de eigen appOwner. Dat is geen omweg maar het enige wat
// losmaken werkelijk losmaakt.
//
// Waarom: de socket zit niet in deze instantie maar in de gedeelde worker, en
// die blijft staan zolang er nog één store open is (zie `gedeeld` bovenaan).
// Een transport die je in de config meegeeft, kun je daarna nergens meer
// opzeggen; het gevolg was dat de relay een eigenaar bleef zien nadat je hem
// in de cliënt had losgekoppeld of zelfs vergeten, tot je het proces stopte.
// Gemeld door de gebruiker op 10-09-2026. `useOwner` geeft een opzegging terug
// en Evolu telt de verwijzingen naar een transport, dus de laatste opzegging
// sluit de socket.
const uitkomst = await run(
createEvolu(Schema, { appName: APP_NAME, appOwner: owner, transports: [] }),
);
if (!uitkomst.ok) {
await run[Symbol.asyncDispose]();
if (onDefect) defectMelders.delete(onDefect);
if (onWebSocket) socketMelders.delete(onWebSocket);
await laatGedeeldeDepsLos();
throw new Error(`Evolu wilde niet starten: ${JSON.stringify(uitkomst.error)}`);
}
const evolu = uitkomst.value;
// `useOwner` eist een niet-lege lijst, dus alleen als er een relay is.
let zegSyncOp = transports.length > 0 ? evolu.useOwner(owner, transports) : null;
return {
ownerId: owner.id,
/** De URL waarmee verbonden wordt, of null bij een instantie zonder sync. */
transportUrl: transports[0]?.url ?? null,
/**
* Schrijft een blob weg. Geeft het id van de nieuwe rij terug.
*
* Asynchroon, en dat is geen stijlkeuze. `evolu.insert` geeft meteen een id
* terug en zet de schrijfactie in de wachtrij van de database-worker; sluit
* je vlak daarna af, dan is de rij nooit op schijf beland. Dat kostte op
* 09-09-2026 een `lees` die "nog geen blobs" meldde over een blob die
* zojuist bevestigd was. De query erachter dwingt de heenreis af.
*/
schrijf: async (label, body) => {
const geschreven = evolu.insert('blob', { label, body });
await evolu.loadQuery(allesQuery);
return geschreven;
},
/** Alle blobs van deze eigenaar, oudste eerst. */
lees: () => evolu.loadQuery(allesQuery),
/** Voor wie meer wil dan schrijven en lezen. */
evolu,
/**
* Sluit af. Eerst de run, dan de afhankelijkheden: de run bezit de instantie
* en die gebruikt de workers die in de afhankelijkheden zitten. Andersom
* eindigt elk afsluiten met een SuppressedError zonder oorzaak.
*
* De gedeelde afhankelijkheden gaan pas weg als de láátste store sluit; zie
* de uitleg bovenaan bij `gedeeld`.
*/
sluit: async () => {
// Eerst de sync opzeggen en dan de run weggooien. Andersom is de instantie
// al weg als de opzegging aankomt.
if (zegSyncOp) {
zegSyncOp();
zegSyncOp = null;
}
await run[Symbol.asyncDispose]();
if (onDefect) defectMelders.delete(onDefect);
if (onWebSocket) socketMelders.delete(onWebSocket);
await laatGedeeldeDepsLos();
},
};
};
+99
View File
@@ -0,0 +1,99 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Waar staat de relay, en waar mogen de gegevens staan.
//
// Het adres van de Umbrel staat NIET in deze repo en hoort daar ook niet in:
// dit is een publieke app store, en de .gitignore van de repo-root heeft er een
// eigen kopje over. Het komt dus uit de omgeving, met `.env` als de gewone weg
// en `.env.sample` als het voorbeeld dat wél in de repo staat.
//
// Geen dotenv-pakket: dat is een afhankelijkheid voor twintig regels, en die
// twintig regels zijn hier beter te lezen dan de instellingen van een pakket.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** De wortel van dit gereedschap, ongeacht vanwaar het gestart wordt. */
export const WORTEL = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Leest een `.env`-bestand.
*
* Regels van de vorm `NAAM=waarde`. Lege regels en regels die met `#` beginnen
* worden overgeslagen, en aanhalingstekens om de waarde gaan eraf. Meer niet: wie
* hier een regel met een dubbele bodem in zet, krijgt hem letterlijk terug.
*/
export const leesEnvBestand = (inhoud) => {
const uit = {};
for (const regel of inhoud.split(/\r?\n/)) {
const kaal = regel.trim();
if (kaal === '' || kaal.startsWith('#')) continue;
const isGelijk = kaal.indexOf('=');
if (isGelijk <= 0) continue;
const naam = kaal.slice(0, isGelijk).trim();
let waarde = kaal.slice(isGelijk + 1).trim();
if (
(waarde.startsWith('"') && waarde.endsWith('"') && waarde.length >= 2)
|| (waarde.startsWith("'") && waarde.endsWith("'") && waarde.length >= 2)
) {
waarde = waarde.slice(1, -1);
}
uit[naam] = waarde;
}
return uit;
};
/**
* Maakt van een adres een WebSocket-URL, of gooit met uitleg.
*
* De relay is een WebSocket-server, dus `ws://` of `wss://`. Een `http://` dat
* iemand uit de adresbalk plakt wordt omgezet in plaats van geweigerd: dat is
* de meest voorkomende vergissing en er is niets dubbelzinnigs aan.
*
* Wat er wél uitkomt is een adres zonder schema (`umbrel.local:3852`), en dat
* krijgt `ws://`. Zonder poort blijft het zoals het is: achter een reverse proxy
* met TLS is `wss://host` zonder poort juist de goede vorm.
*/
export const naarRelayUrl = (adres) => {
if (typeof adres !== 'string' || adres.trim() === '') {
throw new Error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
}
const kaal = adres.trim();
if (kaal.startsWith('ws://') || kaal.startsWith('wss://')) return kaal;
if (kaal.startsWith('http://')) return `ws://${kaal.slice('http://'.length)}`;
if (kaal.startsWith('https://')) return `wss://${kaal.slice('https://'.length)}`;
if (kaal.includes('://')) {
throw new Error(`"${kaal}" heeft een schema dat hier niets betekent. Gebruik ws:// of wss://.`);
}
return `ws://${kaal}`;
};
/**
* De instellingen: eerst de omgeving, dan `.env`, dan de standaardwaarden.
*
* De omgeving wint van het bestand, zodat je één keer iets anders kunt proberen
* zonder het bestand aan te raken.
*/
export const leesConfig = ({ env = process.env, wortel = WORTEL } = {}) => {
let uitBestand = {};
try {
uitBestand = leesEnvBestand(readFileSync(join(wortel, '.env'), 'utf8'));
} catch (oorzaak) {
if (oorzaak.code !== 'ENOENT') throw oorzaak;
}
const haal = (naam) => env[naam] ?? uitBestand[naam];
const adres = haal('RELAY_URL');
return {
/** Het adres van de relay, of `null` als het er niet is. */
relayUrl: adres ? naarRelayUrl(adres) : null,
/** Waar het register en de databases staan. */
dataDir: resolve(wortel, haal('DATA_DIR') ?? 'data'),
/** De poort van het bedieningsvlak. */
uiPoort: Number.parseInt(haal('UI_PORT') ?? '4380', 10),
};
};
+224
View File
@@ -0,0 +1,224 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Een Evolu-cliënt op Node in elkaar zetten.
//
// Dit is het stuk waarvan het plan zei dat het eerst geverifieerd moest worden,
// en het antwoord was niet wat we hoopten: **`@evolu/nodejs` bevat geen cliënt.**
// Het pakket levert de relay plus losse bouwstenen (de better-sqlite3-driver, een
// BroadcastChannel, een klok), maar er is geen `createEvoluDeps` voor Node zoals
// `@evolu/web` die voor de browser heeft. Wie op Node een cliënt wil, zet de
// afhankelijkheden zelf in elkaar, en dat is wat hier gebeurt.
//
// Nagetrokken op 09-09-2026 in @evolu/common 8.7.0 en @evolu/nodejs 3.1.0. Wat
// `createEvolu` verlangt is `EvoluPlatformDeps`, en dat is de som van zes dingen:
//
// createDbWorker de database-worker
// sharedWorker de gedeelde worker, die de synchronisatie doet
// createBroadcastChannel, createMessageChannel, lockManager, reloadApp
//
// Alles draait hier in één proces. De "worker" en de "shared worker" zijn dus de
// in-memory varianten uit `@evolu/common`, en dat is precies waar die voor
// bestaan: hun eigen documentatie noemt ze de terugvaloptie voor platforms zonder
// echte workers. Voor een testcliënt is dat geen concessie, want er is niets te
// winnen met een tweede draad.
//
// `lockManager` komt van `navigator.locks`. Dat is er sinds Node 24, en het is de
// reden dat `package.json` die ondergrens stelt; de documentatie van Evolu noemt
// "Web and Node.js 24+" bij dit onderdeel met zoveel woorden.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import {
createConsole,
createConsoleStoreOutput,
createMessageChannel,
createMessagePort,
createRun,
createSharedWorker,
createWebSocket,
createWorker,
} from '@evolu/common';
import { createEvoluDeps, initSharedWorker, startDbWorker } from '@evolu/common/local-first';
import { createBetterSqliteDriver, createBroadcastChannel } from '@evolu/nodejs';
/**
* De SQLite-driver, maar met alle bestanden in één map.
*
* `createBetterSqliteDriver` maakt `${name}.db` in de werkmap, waarbij `name` de
* instantienaam is die Evolu uit `appName` en de eigenaar afleidt. Elke eigenaar
* krijgt dus al zijn eigen bestand; wat ontbreekt is een map om ze in te zetten.
* Dit voorvoegsel doet dat zonder `process.chdir`, want dat is procesbreed en
* deze cliënt draait meerdere eigenaars naast elkaar.
*/
const createSqliteDriverIn = (directory) => (name, options) =>
createBetterSqliteDriver(join(directory, name), options);
/**
* Zet de afhankelijkheden neer die `createEvolu` op Node nodig heeft.
*
* Geeft naast de afhankelijkheden een `dispose` terug. Die hoort aangeroepen te
* worden bij het afsluiten: de runs die hieronder gemaakt worden bezitten de
* workers, en een worker die blijft staan houdt het proces open.
*
* @param {{directory: string, consoleLevel?: string}} config
*/
export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect, onWebSocket }) => {
// De map moet er zijn vóórdat better-sqlite3 hem opent; die maakt hem niet aan
// en gooit "Cannot open database because the directory does not exist". Dat
// gebeurt binnen een worker, dus zonder deze regel is het symptoom een
// `loadQuery` die nooit antwoordt en niet een fout die je kunt lezen.
mkdirSync(directory, { recursive: true });
const console = createConsole({ level: consoleLevel });
const consoleStoreOutput = createConsoleStoreOutput();
// De basis die zowel de database-worker als de gedeelde worker verlangt.
// `consoleStoreOutputEntry` is hoe een worker zijn logregels naar buiten geeft;
// wij lezen dat niet uit, maar de afhankelijkheid moet er zijn.
const workerDeps = {
console,
consoleStoreOutputEntry: consoleStoreOutput.entry,
createMessagePort,
};
const lockManager = globalThis.navigator?.locks;
if (!lockManager) {
throw new Error(
'navigator.locks ontbreekt. Dit vraagt Node 24 of hoger; zie de kop van dit bestand.',
);
}
// Een defect in een worker is standaard een uitzondering uit een microtaak, en
// die komt dus nergens terecht waar je hem kunt lezen: het proces valt om met
// "An error was suppressed during disposal" en zonder oorzaak. Deze cliënt
// bestaat om fouten zichtbaar te maken, dus dat wordt hier omgeleid.
const reportDefect = (defect) => {
if (onDefect) {
onDefect(defect);
return;
}
console.error('[evolu] defect', defect);
};
// Twee runs, want de twee workers hebben verschillende afhankelijkheden. Ze
// worden hier vastgehouden zodat `dispose` ze allebei kan opruimen.
const dbRun = createRun({
...workerDeps,
createBroadcastChannel,
lockManager,
createSqliteDriver: createSqliteDriverIn(directory),
reportDefect,
});
// De WebSocket met een luikje erin. Wat Evolu werkelijk opent is namelijk niet
// af te leiden uit wat je hem meegeeft: de `OwnerId` in de URL bepaalt wie de
// relay ziet, en dat is precies de vraag waar dit gereedschap over gaat. Zonder
// dit luikje kun je alleen gissen waarom er een geweigerde eigenaar op de
// statuspagina verschijnt.
//
// Niet alleen welke URL, maar ook wat ermee gebeurt. Een relay die de socket
// meteen weer dichtgooit ziet er van de cliëntkant hetzelfde uit als een relay
// die traag is: Evolu probeert het gewoon opnieuw. De sluitcode is wat die twee
// uit elkaar houdt.
const gevolgdeWebSocket = onWebSocket
? (url, options = {}) => {
onWebSocket({ wat: 'openen', url });
return createWebSocket(url, {
...options,
onOpen: () => {
onWebSocket({ wat: 'open', url });
options.onOpen?.();
},
onError: (fout) => {
onWebSocket({ wat: 'fout', url, detail: fout?.type ?? String(fout) });
options.onError?.(fout);
},
onClose: (gebeurtenis) => {
onWebSocket({
wat: 'dicht',
url,
detail: `code ${gebeurtenis?.code ?? '?'}${gebeurtenis?.reason ? ` ${gebeurtenis.reason}` : ''}`,
});
options.onClose?.(gebeurtenis);
},
});
}
: createWebSocket;
const sharedRun = createRun({
...workerDeps,
createBroadcastChannel,
createMessageChannel,
createWebSocket: gevolgdeWebSocket,
lockManager,
reportDefect,
});
const createDbWorker = () =>
createWorker((self) => {
void dbRun(startDbWorker(self));
});
// `initSharedWorker` geeft een AsyncDisposableStack terug, en die moet iemand
// bezitten. Laat je hem vallen, dan wordt hij pas opgeruimd als de run eronder
// al weg is, en dat is precies de "Cannot use a disposed object" die deze
// cliënt bij elk afsluiten liet zien.
let sharedWorkerResources = null;
const sharedWorker = createSharedWorker((self) => {
void Promise.resolve(sharedRun(initSharedWorker(self))).then((result) => {
if (result.ok) sharedWorkerResources = result.value;
});
});
// En dan door `createEvoluDeps` heen, want dat is geen optionele verpakking.
// Dáár wordt de gedeelde worker aan de database-worker geknoopt en wordt de
// "tab leader" aangekondigd; zonder die stap breekt de gedeelde worker af met
// "Expected tab leader port." zodra de eerste schrijfactie binnenkomt. Dat is
// de tweede valstrik van vandaag: de typen laten `EvoluPlatformDeps` toe waar
// `EvoluDeps` hoort, dus het compileert en het faalt pas als je iets doet.
//
// Eén keer aanroepen en hergebruiken voor alle eigenaars, zoals de
// documentatie voorschrijft. Dat is ook precies goed voor deze cliënt: in een
// browser bedient één gedeelde worker alle instanties van alle tabbladen, en
// hier bedient hij alle eigenaars in dit proces.
const evoluDeps = createEvoluDeps({
console,
createDbWorker,
createBroadcastChannel,
createMessageChannel,
lockManager,
// Een cliënt zonder venster heeft niets te herladen. Evolu roept dit aan als
// hij de app wil laten herstarten; hier is dat een logregel waard en verder
// niets, want het proces herstarten zou de proef die loopt weggooien.
reloadApp: () => {
console.warn('[evolu] reloadApp aangeroepen, genegeerd in deze cliënt');
},
sharedWorker,
});
return {
deps: evoluDeps,
/**
* Ruimt de workers op. Asynchroon, en dat moet.
*
* Het afsluiten van een worker is zelf een taak op de run die hem draagt.
* Wordt die run meteen weggegooid, dan loopt dat afsluiten tegen "Cannot use
* a disposed object" aan, en dat komt naar buiten als een SuppressedError
* zonder oorzaak. De volgorde is dus: eerst de afhankelijkheden, dan de runs
* afwachten in plaats van weggooien.
*/
dispose: async () => {
evoluDeps[Symbol.dispose]();
// De in-memory workers bezorgen hun berichten asynchroon, dus op dit punt
// staat er nog post in de wacht die bij aankomst zijn eigen run gebruikt.
// Even doorlaten voordat die runs weggaan.
await new Promise((klaar) => {
setImmediate(klaar);
});
if (sharedWorkerResources) await sharedWorkerResources[Symbol.asyncDispose]();
await sharedRun[Symbol.asyncDispose]();
await dbRun[Symbol.asyncDispose]();
},
};
};
+324
View File
@@ -0,0 +1,324 @@
<!doctype html>
<!--
Het bedieningsvlak van de testclient.
Engels op het scherm zou hier misstaan: dit is gereedschap voor de eigen
werkbank en niet iets wat een bezoeker van de app store ziet. De taalgrens in
Werkafspraken.md loopt langs "ziet de gebruiker van de app dit", en dat is hier nee.
Geen framework en geen bouwstap, net als de statuspagina's van de twee apps.
-->
<html lang="nl">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Relay-testclient</title>
<style>
:root {
color-scheme: light dark;
--grond: #f6f6f4;
--kaart: #ffffff;
--rand: #e2e2dd;
--tekst: #1b1b19;
--zacht: #6b6b64;
--accent: #b45309;
--goed: #15803d;
--fout: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--grond: #17171a;
--kaart: #1f1f23;
--rand: #33333a;
--tekst: #ececea;
--zacht: #9a9a94;
--accent: #f59e0b;
--goed: #4ade80;
--fout: #f87171;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
padding: 24px 20px 64px;
background: var(--grond);
color: var(--tekst);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 940px; margin: 0 auto; }
h1 { font-size: 20px; margin: 0 0 4px; }
h2 { font-size: 15px; margin: 28px 0 10px; color: var(--zacht); font-weight: 600; }
.kop { display: flex; flex-wrap: wrap; gap: 8px 20px; align-items: baseline;
margin-bottom: 20px; color: var(--zacht); font-size: 13px; }
.kaart { background: var(--kaart); border: 1px solid var(--rand);
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px; }
.rij { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: center; }
.naam { font-weight: 600; font-size: 16px; }
.id { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
font-size: 12px; color: var(--zacht); word-break: break-all; }
.stip { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: var(--zacht); margin-right: 6px; vertical-align: 1px; }
.stip.aan { background: var(--goed); }
.duw { margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap; }
button, input {
font: inherit; border-radius: 7px; border: 1px solid var(--rand);
background: var(--kaart); color: var(--tekst); padding: 5px 11px;
}
button { cursor: pointer; }
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:disabled { opacity: 0.45; cursor: default; }
input { width: 8em; }
input.breed { width: 12em; }
.leeg, .melding { color: var(--zacht); font-size: 14px; }
.melding.fout { color: var(--fout); }
table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 10px; }
td { padding: 3px 8px 3px 0; border-top: 1px solid var(--rand); }
td.getal { text-align: right; font-variant-numeric: tabular-nums; }
#log { font-family: ui-monospace, "Cascadia Mono", Menlo, monospace;
font-size: 12px; max-height: 260px; overflow-y: auto; }
#log div { padding: 2px 0; border-top: 1px solid var(--rand); }
#log div:first-child { border-top: 0; }
#log time { color: var(--zacht); margin-right: 10px; }
</style>
</head>
<body>
<main>
<h1>Relay-testclient</h1>
<div class="kop">
<span>Relay: <b id="relay">…</b></span>
<span id="waarschuwing" class="melding fout" hidden>
Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.
</span>
</div>
<h2>Nieuwe eigenaar</h2>
<div class="kaart">
<div class="rij">
<input id="nieuwNaam" class="breed" placeholder="naam" maxlength="40" />
<button id="nieuwKnop">Aanmaken</button>
<span id="nieuwMelding" class="melding"></span>
</div>
</div>
<h2>Eigenaars</h2>
<div id="eigenaars"></div>
<h2>Log</h2>
<div class="kaart" id="log"></div>
</main>
<script>
const $ = (id) => document.getElementById(id);
let bezig = false;
// Wat er als laatste getekend is. De lijst wordt in zijn geheel opnieuw
// opgebouwd, dus zonder deze vergelijking zou elke drie seconden het veld
// met de omvang leeggaan terwijl je erin typt, en zou een knop onder je
// muis vervangen worden.
let laatsteStaat = '';
// De ingetypte omvang per eigenaar, zodat die een hertekening overleeft.
const omvangPer = new Map();
const roep = async (actie, waarden) => {
const antwoord = await fetch(`/api/${actie}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(waarden ?? {}),
});
const uit = await antwoord.json();
if (!antwoord.ok) throw new Error(uit.fout ?? 'Er ging iets mis.');
return uit;
};
const tijd = (iso) => (iso ? new Date(iso).toLocaleTimeString('nl-NL') : '');
const knop = (tekst, aan) => {
const el = document.createElement('button');
el.textContent = tekst;
el.addEventListener('click', async () => {
if (bezig) return;
bezig = true;
el.disabled = true;
try {
await aan();
} catch (oorzaak) {
alert(oorzaak.message);
} finally {
bezig = false;
el.disabled = false;
await ververs();
}
});
return el;
};
const kaartVoor = (eigenaar) => {
const kaart = document.createElement('div');
kaart.className = 'kaart';
const rij = document.createElement('div');
rij.className = 'rij';
const naam = document.createElement('span');
naam.className = 'naam';
naam.innerHTML = `<span class="stip${eigenaar.verbonden ? ' aan' : ''}"></span>`;
naam.append(eigenaar.naam);
rij.append(naam);
const toestand = document.createElement('span');
toestand.className = 'melding';
toestand.textContent = eigenaar.verbonden
? `${eigenaar.metRelay ? 'verbonden' : 'lokaal open'} sinds ${tijd(eigenaar.sinds)}${
eigenaar.blobs === null ? '' : ` · ${eigenaar.blobs} blob(s)`
}`
: 'niet verbonden';
rij.append(toestand);
if (eigenaar.test) {
const test = document.createElement('span');
test.className = eigenaar.test.geaccepteerd ? 'melding' : 'melding fout';
test.textContent = `getest ${tijd(eigenaar.test.tijd)}: ${eigenaar.test.status ?? '-'} ${eigenaar.test.uitleg}`;
rij.append(test);
}
const duw = document.createElement('div');
duw.className = 'duw';
const omvang = document.createElement('input');
omvang.value = omvangPer.get(eigenaar.naam) ?? '64';
omvang.title = 'Omvang van de blob in bytes';
omvang.style.width = '6em';
omvang.addEventListener('input', () => omvangPer.set(eigenaar.naam, omvang.value));
// Verbinden gaat uit zodra een test zegt dat deze eigenaar geweigerd
// wordt. Niet uit netheid: Evolu blijft bij een weigering eindeloos
// opnieuw proberen, en élke poging is een regel in de weigerlijst van de
// relay. Onbekend telt als toegestaan, want anders moet je eerst testen
// om iets te mogen, en dat is zelf ook een poging.
const verbindKnop = eigenaar.verbonden
? knop('Verbreken', () => roep('verbreek', { naam: eigenaar.naam }))
: knop('Verbinden', () => roep('verbind', { naam: eigenaar.naam }));
if (!eigenaar.verbonden && !eigenaar.magVerbinden) {
verbindKnop.disabled = true;
verbindKnop.title = 'De relay weigert deze eigenaar. Laat hem toe op de statuspagina van de app en test opnieuw.';
}
duw.append(
// Geen melding in een dialoogvenster: de uitkomst komt naast de naam te
// staan en in het log, en die blijven staan. Een dialoog moet je
// wegklikken en is daarna weg.
knop('Testen', () => roep('test', { naam: eigenaar.naam })),
verbindKnop,
omvang,
knop('Blob schrijven', () =>
roep('schrijf', { naam: eigenaar.naam, label: `blob ${tijd(new Date().toISOString())}`, bytes: omvang.value }),
),
knop('Lezen', async () => {
const uit = await roep('lees', { naam: eigenaar.naam });
toonRijen(kaart, uit.rijen);
}),
knop('Vergeten', async () => {
if (!confirm(`"${eigenaar.naam}" uit het register halen? De lokale database blijft staan.`)) return;
await roep('vergeet', { naam: eigenaar.naam });
}),
);
rij.append(duw);
kaart.append(rij);
const id = document.createElement('div');
id.className = 'id';
id.textContent = eigenaar.ownerId;
kaart.append(id);
return kaart;
};
const toonRijen = (kaart, rijen) => {
kaart.querySelector('table')?.remove();
const tabel = document.createElement('table');
if (rijen.length === 0) {
const cel = tabel.insertRow().insertCell();
cel.className = 'leeg';
cel.textContent = 'Nog geen blobs.';
}
for (const rij of rijen) {
const tr = tabel.insertRow();
tr.insertCell().textContent = rij.label;
const bytes = tr.insertCell();
bytes.className = 'getal';
bytes.textContent = `${rij.bytes} bytes`;
const id = tr.insertCell();
id.className = 'id';
id.textContent = rij.id;
}
kaart.append(tabel);
};
const ververs = async (altijd = false) => {
const ruw = await (await fetch('/api/status')).text();
if (!altijd && ruw === laatsteStaat) return;
laatsteStaat = ruw;
const staat = JSON.parse(ruw);
$('relay').textContent = staat.relayUrl ?? 'niet ingesteld';
$('waarschuwing').hidden = Boolean(staat.relayUrl);
const lijst = $('eigenaars');
lijst.textContent = '';
if (staat.eigenaars.length === 0) {
const leeg = document.createElement('div');
leeg.className = 'kaart leeg';
leeg.textContent = 'Nog geen eigenaars. Maak er hierboven een aan.';
lijst.append(leeg);
}
for (const eigenaar of staat.eigenaars) lijst.append(kaartVoor(eigenaar));
const log = $('log');
log.textContent = '';
if (staat.log.length === 0) {
log.className = 'kaart leeg';
log.textContent = 'Nog niets gebeurd.';
} else {
log.className = 'kaart';
for (const regel of staat.log) {
const el = document.createElement('div');
const t = document.createElement('time');
t.textContent = tijd(regel.tijd);
el.append(t, regel.tekst);
log.append(el);
}
}
};
$('nieuwKnop').addEventListener('click', async () => {
const naam = $('nieuwNaam').value.trim();
const melding = $('nieuwMelding');
melding.className = 'melding';
melding.textContent = '';
try {
const uit = await roep('nieuw', { naam });
$('nieuwNaam').value = '';
melding.textContent = `"${uit.naam}" aangemaakt.`;
await ververs();
} catch (oorzaak) {
melding.className = 'melding fout';
melding.textContent = oorzaak.message;
}
});
$('nieuwNaam').addEventListener('keydown', (gebeurtenis) => {
if (gebeurtenis.key === 'Enter') $('nieuwKnop').click();
});
void ververs();
// Verversen terwijl je met een knop bezig bent zou de kaart onder je muis
// vervangen, dus dat wordt overgeslagen.
setInterval(() => {
if (!bezig) void ververs();
}, 3000);
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Aankloppen bij de relay en de HTTP-statuscode teruggeven.
//
// Dit bestaat omdat Evolu een weigering niet van een relay die plat ligt
// onderscheidt: de cliënt is local-first en probeert het gewoon opnieuw. Voor
// een gereedschap dat juist de allowlist moet beproeven is dat het verschil
// tussen een proef en een gok; zie het plan Testclient, OPEN.md punt 1.
//
// Het kan zo goedkoop omdat de relay zijn beslissing bij de WebSocket-upgrade
// neemt en niet bij een bericht: het `OwnerId` staat in de URL, en een weigering
// is een HTTP 401 op de upgrade. Zie §10 van
// HomeGit/Docs/Referenties/UmbrelApps/Upstream-evolu-relay.md.
//
// Met node:http en niet met de WebSocket van Node, en dat is de hele reden dat
// dit bestand er is: de WebSocket-API geeft je bij een weigering een `error` en
// geen statuscode, en 401 tegenover 404 tegenover "niets luistert" is precies
// wat je wilt weten.
// ═══════════════════════════════════════════════════════════════════════════════
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { randomBytes } from 'node:crypto';
/**
* De URL waarmee deze eigenaar zou verbinden.
*
* Dezelfde vorm die `createOwnerWebSocketTransport` maakt. Hij staat hier apart
* zodat er getoetst kan worden dat de twee niet uit elkaar lopen; loopt dat wel
* uit elkaar, dan klopt de proef niet en zie je dat nergens aan.
*/
export const upgradeUrl = (relayUrl, ownerId) => `${relayUrl}?ownerId=${ownerId}`;
/**
* Klopt aan en geeft terug wat er gebeurde.
*
* @returns {Promise<{status: number|null, geaccepteerd: boolean, uitleg: string}>}
*/
export const klopAan = (relayUrl, ownerId, { timeoutMs = 5000 } = {}) =>
new Promise((klaar) => {
let url;
try {
url = new URL(upgradeUrl(relayUrl, ownerId));
} catch {
klaar({ status: null, geaccepteerd: false, uitleg: `"${relayUrl}" is geen bruikbaar adres` });
return;
}
const beveiligd = url.protocol === 'wss:';
const verzoek = (beveiligd ? httpsRequest : httpRequest)({
hostname: url.hostname,
port: url.port || (beveiligd ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': randomBytes(16).toString('base64'),
},
});
let afgerond = false;
const antwoord = (uitkomst) => {
if (afgerond) return;
afgerond = true;
verzoek.destroy();
klaar(uitkomst);
};
// Een geslaagde upgrade komt níet als 'response' binnen maar als 'upgrade'.
// Dat is meteen het antwoord: de relay heeft deze eigenaar toegelaten.
verzoek.on('upgrade', () => {
antwoord({ status: 101, geaccepteerd: true, uitleg: 'toegelaten' });
});
verzoek.on('response', (res) => {
const status = res.statusCode ?? null;
antwoord({
status,
geaccepteerd: false,
uitleg:
status === 401
? 'geweigerd: deze eigenaar mag er niet in'
: `geweigerd met status ${status}`,
});
});
verzoek.on('error', (oorzaak) => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen verbinding: ${oorzaak.message}` });
});
verzoek.setTimeout(timeoutMs, () => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen antwoord binnen ${timeoutMs} ms` });
});
verzoek.end();
});
+157
View File
@@ -0,0 +1,157 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het register: welke eigenaars bestaan er, en hoe kom je erbij terug.
//
// Eén bestand, `owners.json`, met per eigenaar een naam, zijn mnemonic en zijn
// `OwnerId`. De mnemonic is het enige dat werkelijk bewaard hoeft te worden; het
// `OwnerId` staat erbij omdat je het nodig hebt om een eigenaar op de
// statuspagina van de app terug te vinden, en omdat je het dan kunt opzoeken
// zonder Evolu te starten.
//
// **Dit is sleutelmateriaal.** Het heeft buiten deze proefopstelling geen
// waarde, maar het bestand staat in .gitignore en het hoort daar te blijven; zie
// het plan Testclient, PLAN.md §4a.
//
// Alles hier is een gewone functie op een pad. Geen Evolu, geen netwerk: dat is
// wat dit het enige deel van de cliënt maakt dat zonder relay en zonder
// fixtures te toetsen is, en het is dezelfde scheiding die policy.js aan de
// relay-kant heeft.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Een naam is een herkenpunt voor jezelf en gaat nooit de deur uit.
*
* Toch begrensd, want hij belandt in een JSON-bestand en straks op een pagina.
* Letters, cijfers, streepje en liggend streepje; dat is genoeg om "trezor-mac"
* of "proef_2" te kunnen schrijven en het sluit een naam met een schuine streep
* erin uit, die er als pad uit zou zien.
*/
const NAAM_PATROON = /^[A-Za-z0-9_-]{1,40}$/;
export const isBruikbareNaam = (naam) => typeof naam === 'string' && NAAM_PATROON.test(naam);
const LEEG = { version: 1, owners: [] };
/**
* Leest het bestand. Geeft `null` bij iets wat niet klopt.
*
* Bewust streng, en om dezelfde reden als in policy.js: een half begrepen
* bestand is gevaarlijker dan geen bestand. Hier zou dat betekenen dat een
* eigenaar stil verdwijnt terwijl zijn mnemonic wél op schijf staat, en dan is
* de proef die eroverheen liep niet meer te herhalen.
*/
export const normaliseer = (ruw) => {
if (ruw === null || typeof ruw !== 'object' || Array.isArray(ruw)) return null;
if (ruw.version !== 1) return null;
if (!Array.isArray(ruw.owners)) return null;
const owners = [];
for (const eigenaar of ruw.owners) {
if (eigenaar === null || typeof eigenaar !== 'object') return null;
if (!isBruikbareNaam(eigenaar.naam)) return null;
if (typeof eigenaar.mnemonic !== 'string' || eigenaar.mnemonic.length === 0) return null;
if (typeof eigenaar.ownerId !== 'string' || eigenaar.ownerId.length === 0) return null;
if (owners.some((eerder) => eerder.naam === eigenaar.naam)) return null;
owners.push({
naam: eigenaar.naam,
mnemonic: eigenaar.mnemonic,
ownerId: eigenaar.ownerId,
aangemaakt: typeof eigenaar.aangemaakt === 'string' ? eigenaar.aangemaakt : null,
});
}
return { version: 1, owners };
};
/**
* Opent het register in `directory`, en maakt de map aan als hij er niet is.
*
* Een ontbrekend bestand is de begintoestand en geen fout. Een onleesbaar bestand
* wél: dat wordt gemeld en niet stil vervangen, want vervangen is hier hetzelfde
* als weggooien.
*/
export const openRegister = (directory) => {
mkdirSync(directory, { recursive: true });
const pad = join(directory, 'owners.json');
let staat;
try {
staat = normaliseer(JSON.parse(readFileSync(pad, 'utf8')));
} catch (oorzaak) {
if (oorzaak.code === 'ENOENT') {
staat = { ...LEEG, owners: [] };
} else {
throw new Error(`${pad} is niet te lezen: ${oorzaak.message}`);
}
}
if (staat === null) {
throw new Error(
`${pad} klopt niet. Er is niets vervangen; kijk er zelf naar, want hier staan mnemonics in.`,
);
}
// Schrijven gaat via een tijdelijk bestand en een hernoeming. Een half
// geschreven register kost mnemonics, en een hernoeming binnen dezelfde map is
// op elk besturingssysteem dat wij raken één handeling.
const bewaar = () => {
const tijdelijk = `${pad}.nieuw`;
writeFileSync(tijdelijk, `${JSON.stringify(staat, null, 2)}\n`, 'utf8');
renameSync(tijdelijk, pad);
};
return {
pad,
/** Alle eigenaars, in de volgorde waarin ze zijn aangemaakt. */
lijst: () => staat.owners.map((eigenaar) => ({ ...eigenaar })),
/** Eén eigenaar, of `null`. */
zoek: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
return gevonden ? { ...gevonden } : null;
},
/**
* Zet een eigenaar in het register.
*
* Gooit bij een naam die niet kan of al bestaat. Overschrijven zou de
* mnemonic van de vorige eigenaar weggooien, en dat is precies het soort
* stille schade waar het commentaar bovenaan dit bestand over gaat.
*/
voegToe: ({ naam, mnemonic, ownerId, aangemaakt }) => {
if (!isBruikbareNaam(naam)) {
throw new Error(`"${naam}" kan niet als naam. Letters, cijfers, - en _, hoogstens 40 tekens.`);
}
if (staat.owners.some((eigenaar) => eigenaar.naam === naam)) {
throw new Error(`Er is al een eigenaar "${naam}".`);
}
const nieuw = {
naam,
mnemonic,
ownerId,
aangemaakt: aangemaakt ?? new Date().toISOString(),
};
staat = { ...staat, owners: [...staat.owners, nieuw] };
bewaar();
return { ...nieuw };
},
/**
* Haalt een eigenaar uit het register. Gooit als hij er niet in staat.
*
* Let op wat dit níet doet: de database van die eigenaar blijft staan, en op
* de relay verandert er helemaal niets. Vergeten is hier vergeten, geen
* wissen; zie OPEN.md punt 3 en 4 van het plan.
*/
vergeet: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
if (!gevonden) throw new Error(`Geen eigenaar "${naam}".`);
staat = { ...staat, owners: staat.owners.filter((eigenaar) => eigenaar.naam !== naam) };
bewaar();
return { ...gevonden };
},
};
};
+312
View File
@@ -0,0 +1,312 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het bedieningsvlak: één pagina waarop je alle eigenaars ziet en aanstuurt.
//
// Wat dit scherm wél doet staat in het plan Testclient, PLAN.md §4b. Wat het
// NIET doet, en dat is geen tekortkoming maar de poortindeling van de app: de
// leerstand openzetten, iemand blokkeren en labelen. Die lopen via de agent-API
// van Evolu Relay, en die hangt achter de inlog van umbrelOS. Zet hier dus geen
// knoppen bij die dat proberen; ze kunnen niet werken.
//
// De server luistert op localhost en nergens anders. Hier zitten mnemonics
// achter, en dit is gereedschap op je eigen machine.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
import { klopAan } from './probe.js';
const HIER = dirname(fileURLToPath(import.meta.url));
const MAX_BODY_BYTES = 4096;
const MAX_LOG = 200;
export const startUi = async ({ config, register, databasesIn }) => {
/** De open verbindingen, per eigenaar. Alleen deze module raakt hem aan. */
const open = new Map();
const log = [];
/**
* De laatste uitkomst van "Testen", per eigenaar.
*
* Hierop wordt de knop "Verbinden" uitgezet. Onbekend telt als toegestaan, en
* dat is met opzet: anders moet je eerst testen voordat je iets mag, en een
* test is zelf een poging die in de weigerlijst van de relay belandt.
*/
const laatsteTest = new Map();
/**
* Hoeveel sockets er achter elkaar mislukt zijn, per eigenaar.
*
* Evolu is local-first en blijft eindeloos opnieuw proberen. Dat is bij een
* relay die even weg is precies goed, maar bij een eigenaar die geweigerd
* wordt is elke poging een regel in de weigerlijst van de relay. Vandaar de
* teller: na een paar mislukkingen wordt er één keer getest, en bij een
* weigering gaat de verbinding eruit in plaats van door te blijven kloppen.
*/
const socketFouten = new Map();
const FOUTEN_VOOR_TEST = 3;
const meld = (tekst) => {
log.unshift({ tijd: new Date().toISOString(), tekst });
if (log.length > MAX_LOG) log.length = MAX_LOG;
console.log(`${new Date().toLocaleTimeString()} ${tekst}`);
};
const verbind = async (naam, metRelay) => {
if (open.has(naam)) return open.get(naam);
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (metRelay && !config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const store = await openStore({
directory: databasesIn,
owner: eigenaarUitMnemonic(regel.mnemonic),
relayUrl: metRelay ? config.relayUrl : null,
onDefect: (defect) => {
meld(`${naam}: defect uit een worker, zie de terminal`);
console.error(defect);
},
onWebSocket: (bericht) => {
if (bericht.wat === 'open') {
socketFouten.set(naam, 0);
meld(`${naam}: socket open`);
return;
}
if (bericht.wat !== 'dicht') return;
const aantal = (socketFouten.get(naam) ?? 0) + 1;
socketFouten.set(naam, aantal);
if (aantal !== FOUTEN_VOOR_TEST) return;
// Precies één keer testen, bij de derde mislukking. Daarna weten we of
// dit een weigering is of een relay die even weg is, en dat scheelt de
// relay een weigerlijst vol pogingen.
meld(`${naam}: ${aantal} sockets mislukt (${bericht.detail}), wordt getest`);
void (async () => {
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
if (uitkomst.geaccepteerd) {
meld(`${naam}: de relay laat hem toe, dus dit is iets anders dan een weigering`);
return;
}
meld(`${naam}: geweigerd (${uitkomst.uitleg}), verbinding wordt verbroken`);
await verbreek(naam);
})();
},
});
const ingang = { store, sinds: new Date().toISOString(), metRelay };
open.set(naam, ingang);
meld(metRelay ? `${naam} verbonden met ${store.transportUrl}` : `${naam} lokaal geopend`);
return ingang;
};
const verbreek = async (naam) => {
const ingang = open.get(naam);
if (!ingang) return;
open.delete(naam);
socketFouten.delete(naam);
await ingang.store.sluit();
meld(`${naam} losgekoppeld`);
};
const status = async () => {
const eigenaars = [];
for (const regel of register.lijst()) {
const ingang = open.get(regel.naam);
let blobs = null;
if (ingang) {
try {
blobs = (await ingang.store.lees()).length;
} catch {
blobs = null;
}
}
const test = laatsteTest.get(regel.naam) ?? null;
eigenaars.push({
naam: regel.naam,
ownerId: regel.ownerId,
aangemaakt: regel.aangemaakt,
verbonden: Boolean(ingang),
metRelay: ingang?.metRelay ?? false,
sinds: ingang?.sinds ?? null,
transportUrl: ingang?.store.transportUrl ?? null,
blobs,
test,
// Onbekend telt als toegestaan: eerst moeten testen om te mogen
// verbinden zou betekenen dat je altijd een poging in de weigerlijst
// van de relay zet voordat je iets kunt doen.
magVerbinden: test === null || test.geaccepteerd === true,
});
}
return { relayUrl: config.relayUrl, eigenaars, log };
};
// ── De afhandeling ──────────────────────────────────────────────────────────
const acties = {
nieuw: async ({ naam }) => {
const owner = nieuweEigenaar();
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
meld(`${regel.naam} aangemaakt, OwnerId ${regel.ownerId}`);
return { naam: regel.naam, ownerId: regel.ownerId };
},
verbind: async ({ naam }) => {
await verbind(naam, true);
return { verbonden: true };
},
verbreek: async ({ naam }) => {
await verbreek(naam);
return { verbonden: false };
},
schrijf: async ({ naam, label, bytes }) => {
const omvang = Number.parseInt(bytes ?? '64', 10);
if (!Number.isInteger(omvang) || omvang < 1 || omvang > 8 * 1024 * 1024) {
throw new Error('bytes moet tussen 1 en 8388608 liggen.');
}
// Schrijven opent zo nodig zelf een verbinding, dus dezelfde poort als bij
// de knop "Verbinden": een geweigerde eigenaar gaat lokaal verder in
// plaats van de weigerlijst van de relay te vullen.
const test = laatsteTest.get(naam);
const geweigerd = test != null && test.geaccepteerd === false;
const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl) && !geweigerd));
const body = new Uint8Array(omvang);
for (let i = 0; i < omvang; i += 1) body[i] = i % 256;
const geschreven = await ingang.store.schrijf(label || 'blob', body);
meld(`${naam}: blob van ${omvang} bytes weggeschreven (${geschreven.id})`);
return { id: geschreven.id, bytes: omvang };
},
lees: async ({ naam }) => {
const ingang = open.get(naam) ?? (await verbind(naam, false));
const rijen = await ingang.store.lees();
return {
rijen: rijen.map((rij) => ({
id: rij.id,
label: rij.label,
bytes: rij.body?.length ?? 0,
})),
};
},
test: async ({ naam }) => {
const regel = register.zoek(naam);
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
if (!config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
meld(`${naam}: testen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`);
return uitkomst;
},
vergeet: async ({ naam }) => {
await verbreek(naam);
laatsteTest.delete(naam);
const weg = register.vergeet(naam);
meld(`${weg.naam} uit het register gehaald`);
return { naam: weg.naam };
},
};
const leesBody = (verzoek) =>
new Promise((klaar, faal) => {
let ruw = '';
verzoek.on('data', (stuk) => {
ruw += stuk;
if (ruw.length > MAX_BODY_BYTES) {
faal(new Error('Verzoek te groot.'));
verzoek.destroy();
}
});
verzoek.on('end', () => {
try {
klaar(ruw === '' ? {} : JSON.parse(ruw));
} catch (oorzaak) {
faal(new Error(`Onleesbare JSON: ${oorzaak.message}`));
}
});
verzoek.on('error', faal);
});
const stuur = (antwoord, code, waarde) => {
const inhoud = JSON.stringify(waarde);
antwoord.writeHead(code, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(inhoud),
});
antwoord.end(inhoud);
};
const server = createServer((verzoek, antwoord) => {
void (async () => {
const pad = (verzoek.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/';
if (verzoek.method === 'GET' && (pad === '/' || pad === '/index.html')) {
// Elke keer van schijf. Dit is gereedschap op je eigen machine, dus een
// pagina bijwerken en verversen hoort te werken zonder herstart.
const pagina = readFileSync(join(HIER, 'index.html'));
antwoord.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': pagina.length,
});
antwoord.end(pagina);
return;
}
if (verzoek.method === 'GET' && pad === '/api/status') {
stuur(antwoord, 200, await status());
return;
}
if (verzoek.method === 'POST' && pad.startsWith('/api/')) {
const actie = acties[pad.slice('/api/'.length)];
if (!actie) {
stuur(antwoord, 404, { fout: 'Onbekende actie.' });
return;
}
try {
stuur(antwoord, 200, await actie(await leesBody(verzoek)));
} catch (oorzaak) {
// 400 en niet 500: vrijwel alles wat hier misgaat is een naam die al
// bestaat of een relay die er niet is, en dat is geen serverfout.
stuur(antwoord, 400, { fout: oorzaak.message });
}
return;
}
stuur(antwoord, 404, { fout: 'Niet gevonden.' });
})();
});
await new Promise((klaar) => {
server.listen(config.uiPoort, '127.0.0.1', klaar);
});
const adres = `http://127.0.0.1:${config.uiPoort}`;
console.log('');
console.log(` Bedieningsvlak: ${adres}`);
console.log(` Relay: ${config.relayUrl ?? 'niet ingesteld (zie .env.sample)'}`);
console.log(` Register: ${register.pad}`);
console.log('');
console.log(' Ctrl-C om te stoppen.');
console.log('');
const stop = async () => {
console.log('');
console.log('Afsluiten...');
server.close();
for (const naam of [...open.keys()]) await verbreek(naam);
process.exit(0);
};
process.on('SIGINT', () => {
void stop();
});
await new Promise(() => {});
};
+193
View File
@@ -0,0 +1,193 @@
// ═══════════════════════════════════════════════════════════════════════════════
// release-bump.mjs: zet een nieuwe image-tag met digest in de compose van een app
// en verhoogt `version` in zijn manifest.
//
// Draaien: node tools/release-bump.mjs <app-id> <image-versie> <digest>
// Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...
//
// WAAROM DIT BESTAAT. De release-pijplijn in de AssetApp-repo bouwt, duwt en leest
// de digest terug, maar werkt deze repo bewust NIET bij: een token dat daar staat en
// hier mag schrijven, kan alles naar de store duwen die umbreld anoniem kloont. Wat
// overbleef was overtypwerk, en juist daar zit de stille fout: een OUDE digest onder
// een NIEUWE tag levert de oude image, zonder melding. Dit script vervangt de hele
// `image:`-regel, dus die combinatie kan niet meer ontstaan.
//
// Lokaal draaien en zelf de diff lezen is het ontwerp en geen tussenoplossing. Het
// script pusht niets.
//
// DE TWEE NUMMERS ZIJN NIET HETZELFDE. De tag is de versie van de IMAGE, `version`
// in het manifest die van de APP, en die tweede moet bij élke wijziging omhoog, ook
// als alleen de compose verandert, anders rolt umbrelOS niets uit. Daarom kiest dit
// script hem expliciet en drukt af wat hij koos.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const HOST = 'sc.kamenier-hamer.nl';
// ── De twee herschrijvingen: puur, want daar gaan de tests over ────────────────
const DIGEST_VORM = /^sha256:[0-9a-f]{64}$/;
const VERSIE_VORM = /^\d+\.\d+\.\d+$/;
// Eén `image:`-regel van déze host. Een regel van een andere host blijft staan: een
// app mag een image van buiten gebruiken en die hoort hier niet mee te verschuiven.
function imageRegels(tekst) {
const regels = tekst.split(/\r?\n/);
const treffers = [];
regels.forEach((regel, i) => {
const m = regel.match(new RegExp(`^(\\s*image:\\s*)(${HOST}/[^\\s:@]+)(?::[^\\s@]+)?(?:@sha256:[0-9a-f]+)?\\s*$`));
if (m) treffers.push({ i, inspring: m[1], naam: m[2] });
});
return treffers;
}
// Vervangt de HELE regel en plakt er niets bij. Dat is de kern: een oude digest kan
// zo niet blijven staan. Meerdere containers uit hetzelfde image gaan samen mee, want
// twee versies naast elkaar in één compose is nooit de bedoeling.
export function herschrijfImage(tekst, versie, digest) {
if (!VERSIE_VORM.test(versie)) throw new Error(`versie heeft niet de vorm x.y.z: ${versie}`);
if (!DIGEST_VORM.test(digest)) throw new Error(`digest heeft niet de vorm sha256: plus 64 hex: ${digest}`);
const treffers = imageRegels(tekst);
if (treffers.length === 0) throw new Error(`geen image-regel van ${HOST} gevonden`);
const namen = [...new Set(treffers.map((t) => t.naam))];
if (namen.length > 1) throw new Error(`meerdere eigen images in één compose: ${namen.join(', ')}`);
const regels = tekst.split(/\r?\n/);
for (const t of treffers) regels[t.i] = `${t.inspring}${t.naam}:${versie}@${digest}`;
return { tekst: regels.join('\n'), naam: namen[0], aantal: treffers.length };
}
export function huidigeAppVersie(manifest) {
const m = manifest.match(/^version:\s*"?(\d+\.\d+\.\d+)"?\s*$/m);
if (!m) throw new Error('geen `version:` met de vorm x.y.z in het manifest');
return m[1];
}
// Hoger is hoger, per deel en niet als tekst: "0.10.0" hoort boven "0.9.0" te staan.
export function isHoger(a, b) {
const [a1, a2, a3] = a.split('.').map(Number);
const [b1, b2, b3] = b.split('.').map(Number);
return a1 !== b1 ? a1 > b1 : a2 !== b2 ? a2 > b2 : a3 > b3;
}
// De app-versie is niet de image-versie. Ligt de image erboven, dan is gelijktrekken
// het duidelijkst; ligt hij eronder of gelijk, dan is alleen de patch omhoog genoeg om
// umbrelOS te laten uitrollen. Beide gevallen worden afgedrukt, want dit is precies het
// soort keuze dat je een maand later niet meer terugziet.
export function kiesAppVersie(huidig, imageVersie) {
if (isHoger(imageVersie, huidig)) return imageVersie;
const [groot, klein, patch] = huidig.split('.').map(Number);
return `${groot}.${klein}.${patch + 1}`;
}
export function herschrijfVersie(manifest, nieuw) {
if (!VERSIE_VORM.test(nieuw)) throw new Error(`versie heeft niet de vorm x.y.z: ${nieuw}`);
const regels = manifest.split(/\r?\n/);
const i = regels.findIndex((r) => /^version:\s*"?\d+\.\d+\.\d+"?\s*$/.test(r));
if (i === -1) throw new Error('geen `version:` met de vorm x.y.z in het manifest');
regels[i] = `version: "${nieuw}"`;
return regels.join('\n');
}
// ── De controle bij het register ──────────────────────────────────────────────
// Hoort die digest wel bij die tag? Dat is de enige vraag die dit script zelf niet
// kan beantwoorden, en het is de vraag waar de stille fout zit. Anoniem, want zo
// haalt umbreld hem straks ook: faalt dit met een 401, dan staat het pakket op privé
// en zou de installatie op het apparaat struikelen.
const MEDIA = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.docker.distribution.manifest.v2+json',
].join(', ');
export async function digestBijTag(naam, versie) {
const pad = naam.slice(HOST.length + 1);
const url = `https://${HOST}/v2/${pad}/manifests/${versie}`;
let antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA } });
// Het register vraagt ook voor een openbaar pakket een token, en dat token haal je
// zonder inloggegevens op. Slaagt dát niet, dan is het pakket niet openbaar.
if (antwoord.status === 401) {
const uitdaging = antwoord.headers.get('www-authenticate') || '';
const veld = (naam) => (uitdaging.match(new RegExp(`${naam}="([^"]+)"`)) || [])[1];
const realm = veld('realm');
if (!realm) throw new Error(`register vraagt om inloggegevens en zegt niet waar: ${uitdaging}`);
const tokenUrl = new URL(realm);
if (veld('service')) tokenUrl.searchParams.set('service', veld('service'));
tokenUrl.searchParams.set('scope', veld('scope') || `repository:${pad}:pull`);
const tokenAntwoord = await fetch(tokenUrl);
if (!tokenAntwoord.ok) throw new Error(`anoniem token weigeren (${tokenAntwoord.status}): staat het pakket op privé?`);
const { token } = await tokenAntwoord.json();
antwoord = await fetch(url, { method: 'HEAD', headers: { Accept: MEDIA, Authorization: `Bearer ${token}` } });
}
if (antwoord.status === 404) throw new Error(`tag ${versie} bestaat niet in het register`);
if (!antwoord.ok) throw new Error(`register antwoordt ${antwoord.status} op ${url}`);
const digest = antwoord.headers.get('docker-content-digest');
if (!digest) throw new Error('register geeft geen docker-content-digest terug');
return digest;
}
// ── De aanroep ────────────────────────────────────────────────────────────────
// `process.exitCode` en NOOIT `process.exit()`: die tweede kapt af terwijl de fetch
// naar het register nog open staat, en op Windows sterft Node dan in libuv met code 127
// in plaats van de code die je bedoelde. Zo eindigt een nette weigering als een crash.
async function main(argv) {
const vlaggen = argv.filter((a) => a.startsWith('--'));
const [app, versie, digest] = argv.filter((a) => !a.startsWith('--'));
if (!app || !versie || !digest) {
console.error('Gebruik: node tools/release-bump.mjs <app-id> <image-versie> <digest> [--offline]');
console.error('Bijv.: node tools/release-bump.mjs whatsnext-atreides-assets 0.1.3 sha256:39c0...');
process.exitCode = 2;
return;
}
const composePad = join(app, 'docker-compose.yml');
const manifestPad = join(app, 'umbrel-app.yml');
const compose = readFileSync(composePad, 'utf8');
const manifest = readFileSync(manifestPad, 'utf8');
const { tekst: nieuweCompose, naam, aantal } = herschrijfImage(compose, versie, digest);
if (!vlaggen.includes('--offline')) {
const echt = await digestBijTag(naam, versie);
if (echt !== digest) {
console.error(`De digest hoort NIET bij tag ${versie}.`);
console.error(` gegeven: ${digest}`);
console.error(` register: ${echt}`);
console.error('Er is niets gewijzigd. Dit is precies de verwisseling die stil de oude image oplevert.');
process.exitCode = 1;
return;
}
}
const huidig = huidigeAppVersie(manifest);
const appVersie = kiesAppVersie(huidig, versie);
const nieuwManifest = herschrijfVersie(manifest, appVersie);
writeFileSync(composePad, nieuweCompose);
writeFileSync(manifestPad, nieuwManifest);
console.log(`${composePad}: ${naam}:${versie}@${digest}${aantal > 1 ? ` (${aantal} regels)` : ''}`);
console.log(`${manifestPad}: version ${huidig} → ${appVersie}`);
console.log(vlaggen.includes('--offline') ? 'Digest NIET gecontroleerd (--offline).' : 'Digest komt overeen met wat het register onder die tag heeft.');
console.log('Niets gepusht. Lees de diff en commit zelf.');
}
// `pathToFileURL` en geen zelfgebouwde `file://`-string: op Windows begint die met een
// schijfletter en dan komt er één schuine streep te weinig uit, waardoor dit bestand
// als module wél laadt maar nooit iets doet.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv.slice(2)).catch((fout) => {
console.error(String(fout.message || fout));
process.exitCode = 1;
});
}
+193
View File
@@ -0,0 +1,193 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Maakt van een statuspagina een versie die je in een browser kunt openen.
//
// Draaien:
//
// node tools/voorbeeldpagina.mjs
// node tools/voorbeeldpagina.mjs whatsnext-electrum-gate
//
// Het resultaat komt in `voorbeeld/<app>.html` en die map is gitignored.
//
// ── Waarom dit bestaat ───────────────────────────────────────────────────────
//
// Een statuspagina is niet zomaar te openen. Twee dingen staan in de weg, en ze
// zijn allebei fundamenteel en niet op te lossen door er anders naar te kijken:
//
// 1. in een `index.html.template` staan accolade-variabelen die umbreld invult.
// Onopgelost staat er letterlijk "v${APP_VERSION}" in de kop. Een pagina die
// in een eigen image zit heeft dit niet meer, maar haalt de versie dan uit
// status.json, dus zonder agent staat er niets;
// 2. de pagina haalt al zijn gegevens bij een agent die alleen in de app bestaat.
// Zonder die agent zie je één foutmelding en verder een pagina vol "unknown":
// geen lijsten, geen teller, geen knoppen.
//
// Dit script vult het eerste in en maakt het tweede na. Wat je dan ziet is de
// opmaak met plausibele gegevens erin, en dat is genoeg om te beoordelen of de
// twee apps naast elkaar hetzelfde ontwerp zijn.
//
// ── Wat het NIET is ──────────────────────────────────────────────────────────
//
// Geen test en geen bewijs. De gegevens zijn verzonnen, dus dit zegt niets over
// of de pagina de echte status juist weergeeft; dat blijft handwerk op het
// apparaat. Wat er wél mee te vinden is: alles wat met opmaak te maken heeft, en
// dat is precies waar deze pagina's steeds op aangepast worden.
//
// Voor de fouten die je niet ziet staan er twee toetsen: `test_paginas_parsen.mjs`
// (loopt het script) en de dollarteken-toets in `test_appstore_vorm.py` (blijft
// het script heel na de invulling).
// ═══════════════════════════════════════════════════════════════════════════════
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HIER = dirname(fileURLToPath(import.meta.url));
const REPO = join(HIER, '..');
const UIT = join(REPO, 'voorbeeld');
const app = process.argv[2] || 'whatsnext-evolu-relay';
// De pagina staat op een van twee plekken: als *.template in de app-map, of als
// gewoon bestand in het bouwrecept onder tools/ als hij in een eigen image zit.
// Electrum Gate is sinds 0.1.0 het tweede geval. Alleen de eerste gaat door
// envsubst; voor de tweede is de invulling hieronder een lege stap.
const template = join(REPO, app, 'index.html.template');
const kort = app.includes('-') ? app.slice(app.indexOf('-') + 1) : app;
const inImage = join(REPO, 'tools', kort, 'index.html');
const bron = existsSync(template) ? template : inImage;
if (!existsSync(bron)) {
console.error(`FOUT: geen pagina gevonden op ${template} of ${inImage}`);
process.exit(1);
}
// De variabelen die umbreld invult. Alleen wat de pagina's gebruiken; de rest zou
// op het apparaat leeg worden en dat gebeurt hieronder ook.
const WAARDEN = {
APP_VERSION: leesVersie(app),
};
function leesVersie(appId) {
const manifest = readFileSync(join(REPO, appId, 'umbrel-app.yml'), 'utf8');
for (const regel of manifest.split('\n')) {
if (regel.startsWith('version:')) {
return regel.split(':')[1].trim().replace(/['"]/g, '');
}
}
return '0.0.0';
}
// ── De nagemaakte status van Evolu Relay ──────────────────────────────────────
// Met opzet niet de gelukkige gevallen alleen: er staat een eigenaar met en zonder
// label, een geblokkeerde, twee geweigerde pogingen waarvan één met veel pogingen,
// en de teller loopt. Dat is de drukste toestand die de pagina kan hebben, en
// daarin vind je de opmaakfouten.
function relayStatus(nu) {
const iso = (msVanaf) => new Date(nu + msVanaf).toISOString();
return {
version: leesVersie('whatsnext-evolu-relay'),
relay: { reachable: true, checked: iso(-5000), publicPort: 3852 },
owners: {
problem: null,
learning: true,
learningUntil: iso(97000),
learningSecondsLeft: 97,
allowed: [
{
id: 'k7Qw2mZp9RtY4bVn6XsL1cHgJdFaEuOi',
allowed: true,
label: 'Trezor Suite, hoofdwallet',
firstSeen: iso(-86400000 * 2),
lastSeen: iso(-120000),
},
{
id: 'p3Ne8UyTr5WqAzXc2VbNm9KlJhGfDsAq',
allowed: true,
label: null,
firstSeen: iso(-3600000),
lastSeen: iso(-45000),
},
],
blocked: [
{
id: 'zZ9YyXxWwVvUuTtSsRrQqPpOoNnMmLlK',
allowed: false,
label: 'Oude telefoon',
firstSeen: iso(-86400000 * 9),
lastSeen: iso(-86400000 * 3),
},
],
rejected: [
{
id: 'aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT0uV',
label: null,
firstSeen: iso(-1800000),
lastSeen: iso(-30000),
attempts: 7,
},
{
id: 'QqWwEeRrTtYyUuIiOoPpAaSsDdFfGgHh',
label: 'Laptop van de buren?',
firstSeen: iso(-600000),
lastSeen: iso(-600000),
attempts: 1,
},
],
},
database: { bytes: 2374144, modified: iso(-120000) },
pendingCommand: false,
};
}
let html = readFileSync(bron, 'utf8');
// Doet na wat envsubst doet: bekende variabelen krijgen hun waarde, onbekende
// worden leeg. Dat laatste is geen slordigheid maar het gedrag op het apparaat.
html = html.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (heel, naam) =>
Object.prototype.hasOwnProperty.call(WAARDEN, naam) ? WAARDEN[naam] : '',
);
// Het icoon staat naast de template en niet naast het resultaat.
html = html.replace(
/src="icon\.png"/g,
`src="${join(REPO, app, 'icon.png').replace(/\\/g, '/')}"`,
);
// De agent nabootsen. Vóór het eigen script van de pagina, want dat begint meteen
// met een ronde. Alleen voor Evolu Relay; Electrum Gate leest een ander bestand en
// daarvoor is dit script (nog) niet uitgebreid, dus die pagina toont zijn eigen
// melding dat er geen gegevens zijn. Dat is genoeg voor de kop en de dialoog.
if (app === 'whatsnext-evolu-relay') {
const stub = [
'<script>',
'(function () {',
` var status = ${JSON.stringify(relayStatus(Date.now()))};`,
' window.fetch = function (url, opties) {',
" if (String(url).indexOf('status') !== -1) {",
' return Promise.resolve({ ok: true, json: function () { return Promise.resolve(status); } });',
' }',
' return Promise.resolve({ ok: true, json: function () { return Promise.resolve({}); } });',
' };',
'})();',
'</script>',
].join('\n');
const anker = '<script>\n(function () {';
if (html.indexOf(anker) === -1) {
// Hard falen en niet stil doorgaan: zonder de nabootsing is het resultaat een
// pagina vol "unknown", en dan denk je dat je naar de opmaak kijkt terwijl je
// naar een foutmelding kijkt.
console.error('FOUT: het ankerpunt voor het eigen script is niet gevonden');
process.exit(1);
}
html = html.replace(anker, `${stub}\n${anker}`);
}
mkdirSync(UIT, { recursive: true });
const doel = join(UIT, `${app}.html`);
writeFileSync(doel, html, 'utf8');
console.log(`Geschreven: ${doel}`);
console.log('');
console.log('Openen kan met de voorbeeldserver uit .claude/launch.json, of door het');
console.log('bestand rechtstreeks in een browser te slepen.');