De pagina van Evolu Relay op de lijst van de gebruiker

Negen punten uit echt gebruik met 0.4.0, alle negen gedaan. Twee ervan waren
onderzoeksvragen en die staan onderaan.

Evolu Relay 0.5.0
- Een tijdvenster van twee minuten voor nieuwe eigenaars, met een stopknop. De
  teller zit in het relay-proces en niet in de pagina: een teller in een tabblad
  dat je sluit, sluit de deur niet. policy.js kreeg learningUntil, isLearningOpen
  en expireLearning.
- decideOwner kijkt naar isLearningOpen en niet naar het veld learning. De lus die
  een verlopen venster opruimt loopt elke twee seconden, en in dat gat zou een
  onbekende alsnog binnenkomen.
- Zonder STATE_VERSION te verhogen, met een toets die dat verdedigt: een verhoging
  zou de allowlist van de draaiende installatie laten afwijzen en de deur sluiten
  voor eigenaars die er al in stonden.
- Labels op een eigenaar-id, in een eigen labels.json met de agent als enige
  schrijver. Een label zegt niets over toegang, dus de relay hoeft het niet te
  weten; het is daardoor meteen opgeslagen en werkt ook als de relay omligt.
- Geblokkeerde en geweigerde eigenaars in een kader, met een badge die zegt welke
  van de twee het is. De badge staat buiten het hover-blok, anders is dat
  onderscheid onzichtbaar tenzij je over de regel gaat.
- Maatvoering gelijk aan Electrum Gate: 1760px, hetzelfde raster, icoon van 64
  pixels, dezelfde kop, versienummer erachter. Uitleg uit de kaders, knoppen pas
  bij hover, geen voetregel.

Electrum Gate 0.0.24
- Menu-item "About this app", in beide apps.
- De statuswidget zei "Answering" met "answered in 7 ms, from inside the app" en
  zegt nu "Running" met de meting eronder. De nuance dat de controle van container
  naar container loopt is verplaatst naar een eigen kopje in die dialoog, waar er
  ruimte voor is; vier woorden waren te weinig.

Toetsen en gereedschap
- tests/test_relay_agent.py (nieuw, 65 toetsen) en tests/test_paginas_parsen.mjs
  (nieuw). Muteertests gedraaid op de beslissende regels.
- Een dollarteken-toets in test_appstore_vorm.py. Het commentaar in drie bestanden
  beweerde al dat die test bestond; nu is dat waar.
- Een toets dat er geen werkbestanden in een app-map staan. umbreld kopieert de
  hele map naar het apparaat en in de back-up.
- tools/voorbeeldpagina.mjs maakt van een *.template een pagina die je in een
  browser kunt openen. Dat vond meteen twee echte opmaakfouten.

De twee onderzoeksvragen
- Een geweigerde eigenaar komt niet in de database: isOwnerAllowed zit in de
  WebSocket-upgrade, dus het is een 401 en een gesloten socket. Het gewenste gevolg
  treedt wel op, via de client: die is local-first en levert bij toelating de hele
  geschiedenis. Blokkeren werkt daarentegen pas bij de volgende verbinding, en dat
  staat als open punt.
- De blobs zijn niet met een xpub te ontcijferen; een OwnerId komt daar niet uit.
  Met de SLIP-21-node van het apparaat kan het wel, maar die geeft volledige
  zeggenschap, dus dat hoort niet in een relay. Als plan-punt opgenomen bij de tool
  in HomeGit/Trezor.

Nog niet uitgerold: de image 0.5.0 moet gebouwd en geduwd worden. De digest staat
daarom niet in de compose, want een oude digest onder een nieuwe tag levert stil de
oude relay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-08-30 12:26:56 +02:00
co-authored by Claude Opus 5
parent 83af97c2c5
commit b3b1881af2
27 changed files with 3163 additions and 411 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ set -eu
# verandert, want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een
# dag gekost. Ze zijn dus gelijk zolang alleen de image wijzigt, en lopen uiteen
# zodra er een reparatie in de app-map zit.
VERSION="0.3.0"
VERSION="0.5.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
+20 -1
View File
@@ -18,7 +18,7 @@ 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 { applyCommand, decideOwner, expireLearning } from './policy.js';
import { readState, takeCommand, writeState } from './store.js';
installPolyfills();
@@ -100,8 +100,27 @@ const pollCommands = () => {
}
};
// ── Het tijdvenster voor nieuwe eigenaars ─────────────────────────────────────
// De pagina kan de leerstand voor een aantal seconden openzetten. Dat aflopen
// gebeurt niet híer maar in `isLearningOpen`, dat `decideOwner` gebruikt: een
// verlopen venster weigert al vóór deze lus langskomt. Wat deze lus doet is het
// bestand bijwerken, zodat de pagina "closed" toont in plaats van een venster dat
// afgelopen is.
//
// Waarom de teller in de relay zit en niet in de pagina: een teller in de browser
// verdwijnt als je het tabblad sluit, en dan blijft de deur openstaan zonder dat
// iemand dat ziet. Dit is de enige plek waar de staat gezaghebbend is.
const closeExpiredLearning = () => {
const result = expireLearning(state, new Date().toISOString());
if (!result.changed) return;
state = result.state;
markDirty();
console.log('[info] the window for new owners has closed on its own');
};
setInterval(() => {
pollCommands();
closeExpiredLearning();
flush();
}, 2000).unref();
+127 -4
View File
@@ -24,12 +24,29 @@ export const MAX_REJECTED = 20;
// verder geen aannames over de vorm.
export const MAX_OWNER_ID_LENGTH = 256;
// De langste tijdvenster dat de pagina mag vragen. De pagina vraagt er twee
// minuten; deze grens staat er voor het geval iets anders de postbus vult. Een
// venster van een dag is geen venster meer, en dit is een toegangscontrole: bij
// twijfel de kortere kant.
export const MAX_LEARNING_SECONDS = 3600;
export const createEmptyState = () => ({
version: STATE_VERSION,
// Leerstand. Aan betekent: de eerstvolgende onbekende eigenaar wordt
// toegelaten. Dit staat aan bij een verse installatie, want anders kan de
// eigenaar zichzelf nooit aanmelden: Trezor Suite toont je `OwnerId` nergens.
learning: true,
// Tot wanneer de leerstand open is, als ISO-tijdstip, of `null` voor "tot je
// hem zelf sluit". Dat tweede is de begintoestand: een verse installatie moet
// te koppelen zijn zonder dat er iemand op tijd op een knop drukt.
//
// Toegevoegd zonder STATE_VERSION te verhogen, en dat is een keuze. Een
// verhoging zou `normalizeState` het bestaande bestand laten afwijzen, en dan
// schuift `store.js` de allowlist van een werkende installatie opzij en gaat de
// deur dicht. Een veld bijzetten dat ontbrekend `null` betekent is niet
// brekend: een oud bestand leest goed, en een oude relay leest een nieuw
// bestand ook goed omdat hij het veld gewoon niet kent.
learningUntil: null,
owners: [],
rejected: [],
});
@@ -39,6 +56,63 @@ const isUsableOwnerId = (value) =>
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
/** Het tijdstip als getal, of `NaN` als er iets onleesbaars staat. */
const asMoment = (value) => (typeof value === 'string' ? Date.parse(value) : NaN);
/**
* `now` plus een aantal seconden, als ISO-tijdstip. `null` als dat niet kan.
*
* Geen klok hierin: `now` komt van de aanroeper, net als bij alles in dit
* bestand. Vandaar dat een onleesbare `now` een uitkomst heeft en geen fout: de
* aanroeper beslist wat hij met `null` doet, en in `applyCommand` is dat de
* opdracht weigeren. Stil "open zonder tijdslot" zou de verkeerde kant zijn.
*/
const addSeconds = (now, seconds) => {
const start = asMoment(now);
if (Number.isNaN(start)) return null;
return new Date(start + seconds * 1000).toISOString();
};
/**
* Staat de deur op dit moment open voor een onbekende eigenaar?
*
* Dit is de gezaghebbende vraag en niet het veld `learning` op zichzelf. Een
* venster dat verlopen is, is dicht, ook al staat er in het bestand nog dat de
* leerstand aan is: dat bestand wordt door de lus in `index.js` bijgewerkt en die
* loopt op zijn eigen moment. De correctheid mag niet aan die lus hangen, want
* dan zit er een gat van een seconde of twee in waarin een onbekende alsnog
* binnenkomt. Vandaar dat `decideOwner` deze functie gebruikt en niet het veld.
*/
export const isLearningOpen = (state, now) => {
if (state.learning !== true) return false;
if (state.learningUntil === null || state.learningUntil === undefined) return true;
const deadline = asMoment(state.learningUntil);
const moment = asMoment(now);
// Onleesbaar aan één van de twee kanten: dicht. Dat is de veilige kant, en het
// is dezelfde regel die `store.js` volgt bij een onleesbaar bestand.
if (Number.isNaN(deadline) || Number.isNaN(moment)) return false;
return moment < deadline;
};
/**
* Ruimt een verlopen venster op.
*
* Puur opruimwerk: `isLearningOpen` weigert al vóórdat dit gebeurd is. Wat dit
* oplevert is dat het bestand en de pagina hetzelfde zeggen als de klok, en dat
* je op de pagina "closed" ziet in plaats van een venster dat afgelopen is.
*
* @returns {{state: object, changed: boolean}}
*/
export const expireLearning = (state, now) => {
if (state.learning !== true) return { state, changed: false };
if (state.learningUntil === null || state.learningUntil === undefined) {
return { state, changed: false };
}
if (isLearningOpen(state, now)) return { state, changed: false };
return { state: { ...state, learning: false, learningUntil: null }, changed: true };
};
/**
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
*
@@ -77,7 +151,17 @@ export const normalizeState = (raw) => {
});
}
return { version: STATE_VERSION, learning: raw.learning, owners, rejected };
// Ontbrekend of onleesbaar wordt `null`, en dat betekent "geen tijdslot". Dat
// klinkt als de onveilige kant maar is het niet: `learning` moet daarnaast ook
// nog `true` zijn, en dat staat in hetzelfde bestand. Een oud bestand zonder
// dit veld hoort te lezen als de leerstand die het beschreef, en niet als een
// venster dat meteen verlopen is.
const learningUntil =
typeof raw.learningUntil === 'string' && !Number.isNaN(asMoment(raw.learningUntil))
? raw.learningUntil
: null;
return { version: STATE_VERSION, learning: raw.learning, learningUntil, owners, rejected };
};
const rememberRejected = (rejected, ownerId, now) => {
@@ -121,7 +205,7 @@ export const decideOwner = (state, ownerId, now) => {
};
}
if (state.learning) {
if (isLearningOpen(state, now)) {
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }];
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true };
}
@@ -151,8 +235,47 @@ export const applyCommand = (state, command, now) => {
switch (command.action) {
case 'set-learning': {
if (typeof command.value !== 'boolean') return unchanged('malformed-command');
if (state.learning === command.value) return { state, changed: false, error: null };
return { state: { ...state, learning: command.value }, changed: true, error: null };
// Dicht is dicht: een tijdslot dat nog liep gaat mee weg. Zou het blijven
// staan, dan zou een volgende `set-learning true` zonder seconden een
// venster erven dat de gebruiker niet gevraagd heeft.
if (command.value === false) {
if (state.learning === false && !state.learningUntil) {
return { state, changed: false, error: null };
}
return {
state: { ...state, learning: false, learningUntil: null },
changed: true,
error: null,
};
}
// Open, en `seconds` bepaalt of dat met een tijdslot is. Ontbreekt het veld,
// dan is dat het oude gedrag: open tot je hem zelf sluit. Dat pad blijft
// bestaan omdat een verse installatie er niet mee gered is als het venster
// afloopt terwijl je nog aan het installeren bent.
if (command.seconds === undefined || command.seconds === null) {
if (state.learning === true && !state.learningUntil) {
return { state, changed: false, error: null };
}
return { state: { ...state, learning: true, learningUntil: null }, changed: true, error: null };
}
if (
!Number.isInteger(command.seconds) ||
command.seconds <= 0 ||
command.seconds > MAX_LEARNING_SECONDS
) {
return unchanged('malformed-command');
}
const until = addSeconds(now, command.seconds);
if (until === null) return unchanged('malformed-command');
// Altijd `changed`, ook als de leerstand al open stond: opnieuw op de knop
// drukken hoort de klok terug te zetten. Vergelijken met de oude waarde zou
// hier een venster laten aflopen terwijl de gebruiker net verlengde.
return { state: { ...state, learning: true, learningUntil: until }, changed: true, error: null };
}
case 'block': {
+31
View File
@@ -0,0 +1,31 @@
# De bronbestanden van de app-iconen
Hier hoort het werkbestand waaruit `icon.png` van een app komt: de gelaagde versie waarin je nog
kunt bewerken. Eén bestand per app, met de app-id als naam.
**Op dit moment staat er niets.** Beide iconen zijn buiten deze repo gemaakt. Op 28-08-2026 begon de
gebruiker aan een 3D-icoon voor Evolu Relay in Paint.NET; dat was op 30-08-2026 nog niet mooi genoeg
en is door hem weggehaald. Komt er een volgende poging, dan is dít de map.
## Waarom hier en niet in de app-map
**umbreld kopieert bij een installatie de héle app-map naar het apparaat**, met `rsync --archive`
naar `~/umbrel/app-data/<app-id>/`. Alles wat daar staat belandt dus op de Umbrel én in de back-up.
Voor een gelaagd bewerkbestand is dat allebei zinloos: het apparaat kan er niets mee en de back-up
wordt er alleen groter van.
Dat de app-map niets mag bevatten dat umbreld niet nodig heeft, controleert
`tests/test_appstore_vorm.py` sinds 30-08-2026. Kwam je hier omdat die toets over een bestand van
jou klaagde: dit is de plek waar het hoort.
## Wat er wél in de app-map staat
Alleen het resultaat: `icon.png`. Dat bestand staat **niet** in de whitelist die umbreld bij een
update ververst, dus een nieuw icoon bereikt een bestaande installatie niet. Twee dingen volgen
daaruit, en de tweede wordt makkelijk vergeten:
- het manifest verwijst met een `icon:`-regel naar de rauwe URL in deze repo, en dát is wat het
dashboard van umbrelOS toont. Een nieuw icoon is daar dus meteen zichtbaar;
- de kop van de statuspagina laadt `icon.png` uit de gemounte app-map, en die blijft na een update
het oude plaatje tonen tot de app opnieuw geïnstalleerd wordt. Reken er niet op dat een nieuw
icoon overal in één keer doorkomt.
+182
View File
@@ -0,0 +1,182 @@
// ═══════════════════════════════════════════════════════════════════════════════
// 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 `index.html.template` is niet 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. er staan accolade-variabelen in die umbreld invult. Onopgelost staat er
// letterlijk "v${APP_VERSION}" in de kop;
// 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';
const bron = join(REPO, app, 'index.html.template');
if (!existsSync(bron)) {
console.error(`FOUT: ${bron} bestaat niet`);
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 {
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.');