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:
commit
85d85a1295
63 files changed
+13987
No files matched your search
@@ -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
@@ -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}"
|
||||
@@ -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
@@ -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;
|
||||
@@ -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__;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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=
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+54
@@ -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."
|
||||
Generated
+616
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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]();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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(() => {});
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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.');
|
||||
Reference in new issue
Block a user