Testclient fase 2, 3 en 5: opdrachtregel, bedieningsvlak en toetsen

De client staat. Een register (owners.json met naam, mnemonic en OwnerId), acht
opdrachten op de opdrachtregel, en een bedieningsvlak op 127.0.0.1:4380 met per
eigenaar de knoppen uit PLAN.md 4b. Start.bat en Start.command erbij op verzoek
van de gebruiker: die draaien npm install als het nodig is, waarschuwen als .env
ontbreekt, openen de browser en houden het venster open bij een fout.

OPEN.md punt 1 is opgelost voordat het een probleem werd. src/probe.js doet de
WebSocket-upgrade met node:http in plaats van met de WebSocket van Node, want
die geeft je bij een weigering een error en geen statuscode. De opdracht klop en
de knop Aankloppen tonen dus 101 of 401, en dat is precies wat de proeven uit
4c moeten kunnen aflezen.

Twee echte fouten gevonden en vastgezet in een toets. De databasemap werd niet
aangemaakt, en omdat better-sqlite3 dat in een worker meldt was het symptoom een
lees die nooit antwoordde. En evolu.insert geeft meteen een id terug maar zet de
schrijfactie in de wachtrij van de worker, dus wie vlak daarna afsluit is de rij
kwijt; schrijf wacht nu op een query erachter en de toets sluit af en heropent.

tests/test_client_register.mjs heeft geen pakketten nodig, want register.js,
argumenten.js en config.js raken Evolu niet aan. Drie mutaties geprobeerd en
alle drie lieten de juiste toets omvallen.

In de browser nagekeken: schrijven en teruglezen werkt vanuit de pagina, de
gegevens zijn dezelfde als die van de opdrachtregel, en licht en donker kloppen.

Alles wat de relay raakt is gebouwd en niets ervan is beproefd; daarvoor wacht
er een RELAY_URL in .env, en die komt van de gebruiker.

Suite: 475 goed, 0 fout over alle negen de toetsen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-09 13:39:47 +02:00
co-authored by Claude Opus 5
parent de955b607d
commit f0809a80c2
18 changed files with 1728 additions and 41 deletions
+1 -1
View File
@@ -23,7 +23,7 @@
| Plan | App | Volgende stap | Status | | Plan | App | Volgende stap | Status |
|-|-|-|-| |-|-|-|-|
| [Testclient](Plannen/Actief/003-Testclient/TAKEN.md) | Relay | **De cliënt met de relay laten praten. Daarvoor is het adres van de Umbrel nodig, en dat komt van de gebruiker.** Fase 1 is af op 09-09-2026: `@evolu/nodejs` blijkt géén cliënt te bevatten, dus de afhankelijkheden voor `createEvolu` worden zelf samengesteld in `tools/relay-client/src/evolu-node.js`. Bewezen zonder relay: een eigenaar aanmaken, een blob wegschrijven en teruglezen, en schoon afsluiten. Vier valstrikken kostten dat, alle vier stil falend; ze staan in `PLAN.md` §4e | 🔶 | | [Testclient](Plannen/Actief/003-Testclient/TAKEN.md) | Relay | **De cliënt staat; er wacht één regel in `.env`. Eigenaar: gebruiker,** want het adres van de Umbrel gaat niet in deze publieke repo. Op 09-09-2026 gebouwd: het register, acht opdrachten op de opdrachtregel, een bedieningsvlak op `127.0.0.1:4380` met `Start.bat` en `Start.command`, en `klop` die de HTTP-status van de WebSocket-upgrade toont (101 of 401). **Alles wat de relay raakt is gebouwd en niets ervan is beproefd.** Uitkomst die het meeste stuurt: `@evolu/nodejs` bevat géén cliënt, dus de afhankelijkheden voor `createEvolu` worden zelf samengesteld; de vier stil falende valstrikken staan in `PLAN.md` §4e | 🔶 |
## B - Los oppakbaar (geen blokkade, geen vaste volgorde) ## B - Los oppakbaar (geen blokkade, geen vaste volgorde)
+30 -16
View File
@@ -3,19 +3,22 @@
> Vragen die het plan niet beantwoordt en die tijdens het werk beslist moeten worden. Wat af is, verhuist > Vragen die het plan niet beantwoordt en die tijdens het werk beslist moeten worden. Wat af is, verhuist
> naar [PROGRESS.md](PROGRESS.md); wat een taak wordt, naar [TAKEN.md](TAKEN.md). > naar [PROGRESS.md](PROGRESS.md); wat een taak wordt, naar [TAKEN.md](TAKEN.md).
1. **Hoe zichtbaar is een 401 in de cliënt?** Evolu probeert opnieuw en is ontworpen om een relay te 1. ~~**Hoe zichtbaar is een 401 in de cliënt?**~~ **Opgelost op 09-09-2026, vóórdat het een probleem
overleven die er niet is. Als een weigering en een relay die plat ligt in de cliënt hetzelfde signaal werd.** `src/probe.js` doet de WebSocket-upgrade zelf met `node:http` en leest de statuscode af; de
geven, is de eerste proef uit `PLAN.md` §4c niet af te lezen. De uitweg blijft binnen dit gereedschap: opdracht `klop` en de knop "Aankloppen" tonen 101 of 401. Met `node:http` en niet met de WebSocket van
een eigen WebSocket-poging naast Evolu, met dezelfde `OwnerId` in de URL, die de statuscode wel te zien Node, en dat is de hele reden dat dat bestand bestaat: die laatste geeft je bij een weigering een
krijgt. Dat is een handeling van een paar regels, want de `OwnerId` staat in de URL van de verbinding `error` en geen status, en 401 tegenover 404 tegenover "niets luistert" is precies wat je wilt weten.
(§10 van [Upstream-evolu-relay.md](../../../Referenties/Upstream-evolu-relay.md)). Wat er nog niet is, is een keer zien dat het klopt tegen een echte relay.
2. **Wat is een zinnige blob om mee te experimenteren?** Willekeurige bytes zijn genoeg voor de bytegrens, 2. **Wat is een zinnige blob om mee te experimenteren?** Voorlopig een oplopend bytepatroon (`i % 256`)
maar niet om te zien of gegevens werkelijk heen en weer gaan. Iets met leesbare inhoud en een teller is met een label erbij: genoeg om de bytegrens te raken en om bij het teruglezen te zien dát het jouw
waarschijnlijk beter. bytes zijn. Wat er nog niet is, is inhoud die iets betekent, en dat gaat pas meespelen zodra twee
eigenaars werkelijk iets over de relay uitwisselen.
3. **Wordt de gegevensmap van de proef opgeruimd?** Twintig eigenaars die blijven staan is rommel, en het 3. **De gegevensmap wordt niet opgeruimd.** `vergeet` haalt een eigenaar uit het register en laat zijn
is sleutelmateriaal. Waarschijnlijk een opdracht die er één weggooit en één die alles weggooit. database staan; dat staat er ook bij in de uitdraai. Zolang het er een handvol zijn is dat prima, maar
het is wél sleutelmateriaal dat je dan kwijt bent zonder het weg te gooien. Een opdracht die de
database meeneemt hoort erbij, en misschien een die alles opruimt.
4. **Data per eigenaar wissen op de relay staat al open bij Umbrelapp.** Dit gereedschap maakt dat 4. **Data per eigenaar wissen op de relay staat al open bij Umbrelapp.** Dit gereedschap maakt dat
makkelijk te beproeven maar lost het niet op; het blijft schrijven in andermans schema (§8 van hetzelfde makkelijk te beproeven maar lost het niet op; het blijft schrijven in andermans schema (§8 van hetzelfde
@@ -27,12 +30,23 @@
wel dat de samenstelling uit `PLAN.md` §4e bij elke verhoging opnieuw langs moet. Beslissen bij de wel dat de samenstelling uit `PLAN.md` §4e bij elke verhoging opnieuw langs moet. Beslissen bij de
eerste verhoging, niet nu. eerste verhoging, niet nu.
6. **Waar staat de relay voor deze cliënt?** Het adres van de Umbrel staat nergens in deze repo en hoort 6. ~~**Waar staat de relay voor deze cliënt?**~~ **Beslist op 09-09-2026:** `RELAY_URL` in `.env`, met
daar ook niet in: dit is een publieke repo (zie `.gitignore`, "Geheimen"). Dus een instelling die de `.env.sample` als voorbeeld in de repo. Het adres van de Umbrel staat nergens in deze repo en hoort daar
gebruiker zelf zet, met een voorbeeldbestand ernaast. Voor de hand ligt `.env.sample`, want dat patroon ook niet in; dit is een publieke repo (zie `.gitignore`, "Geheimen"). **Wat er nog niet is, is de
staat al in de `.gitignore` van de repo-root. waarde**, en dat is de volgende stap in [TAKEN.md](TAKEN.md).
7. **Het afsluiten is opgelost maar niet doorgrond.** De volgorde in `dispose` (afhankelijkheden, dan een 7. **Het bedieningsvlak tekent de hele lijst opnieuw.** Er zit nu een vergelijking voor: verandert de
status niet, dan gebeurt er niets. Dat was nodig omdat het veld met de omvang anders elke drie seconden
leegging terwijl je erin typt. Wordt de lijst langer of komt er meer per eigenaar bij, dan is dit het
punt waarop het opnieuw gaat schuren, en dan is per eigenaar bijwerken de juiste stap. Nu niet: dat is
machinerie voor een probleem dat er nog niet is.
8. **`sync` en het bedieningsvlak weten niet of een verbinding er nog is.** Er staat "verbonden" zodra de
instantie gemaakt is, en Evolu meldt niet dat de relay weggevallen is (hij probeert het gewoon opnieuw).
Voor de proeven is `klop` het antwoord, maar op de pagina staat een groen bolletje dat meer belooft dan
het weet. Evolu heeft een `SyncState`; uitzoeken of daar iets bruikbaars in zit.
9. **Het afsluiten is opgelost maar niet doorgrond.** De volgorde in `dispose` (afhankelijkheden, dan een
tik doorlaten, dan de resources van de gedeelde worker, dan de runs) is gevonden door te proberen, niet tik doorlaten, dan de resources van de gedeelde worker, dan de runs) is gevonden door te proberen, niet
door de bron van Evolu te lezen. Hij werkt en er is een reden bij opgeschreven, maar het is geen bewijs. door de bron van Evolu te lezen. Hij werkt en er is een reden bij opgeschreven, maar het is geen bewijs.
Valt het bij een volgende versie opnieuw om, lees dan eerst `Task.js` in plaats van opnieuw te schuiven. Valt het bij een volgende versie opnieuw om, lees dan eerst `Task.js` in plaats van opnieuw te schuiven.
@@ -20,3 +20,27 @@ wegschrijven en teruglezen. De bytes komen er ongeschonden uit en het proces slu
**Nog niet geprobeerd: praten met de relay.** Daar is het adres van de Umbrel voor nodig en dat komt van de **Nog niet geprobeerd: praten met de relay.** Daar is het adres van de Umbrel voor nodig en dat komt van de
gebruiker; het gaat niet in deze publieke repo. gebruiker; het gaat niet in deze publieke repo.
## 09-09-2026 - de cliënt staat: opdrachtregel, bedieningsvlak en twee starters
Fase 2, 3 en 5 in één sessie. Er is een register (`owners.json` met naam, mnemonic en `OwnerId`), een
opdrachtregel met acht opdrachten, en een bedieningsvlak op `http://127.0.0.1:4380` met per eigenaar de
knoppen uit `PLAN.md` §4b. `Start.bat` en `Start.command` erbij op verzoek van de gebruiker: die draaien
`npm install` als het nodig is, waarschuwen als `.env` ontbreekt en openen de browser.
`OPEN.md` punt 1 is opgelost voordat het een probleem werd. `src/probe.js` doet de WebSocket-upgrade met
`node:http` in plaats van met de WebSocket van Node, want die laatste geeft je bij een weigering een
`error` en geen statuscode. De opdracht `klop` en de knop "Aankloppen" tonen dus 101 of 401.
Twee echte fouten gevonden en vastgezet in een toets. De eerste: de databasemap werd niet aangemaakt, en
omdat better-sqlite3 dat in een worker meldt was het symptoom een `lees` die nooit antwoordde. De tweede,
en die is er een om te onthouden: **`evolu.insert` geeft meteen een id terug en zet de schrijfactie in de
wachtrij van de worker**, dus wie vlak daarna afsluit is de rij kwijt. Het symptoom was een `lees` die
"nog geen blobs" meldde over een blob die zojuist bevestigd was. `schrijf` wacht nu op een query erachter,
en de toets sluit af en heropent.
In de browser nagekeken: een blob schrijven en teruglezen werkt vanuit de pagina, de gegevens zijn dezelfde
als die van de opdrachtregel, en licht en donker kloppen allebei.
**Wat er nu ligt te wachten is één regel in `.env`.** Alles wat de relay raakt is gebouwd en niets ervan is
beproefd, en dat blijft zo tot het adres er is.
+43 -20
View File
@@ -12,9 +12,10 @@
## Volgende stap ## Volgende stap
- [ ] **De cliënt met de relay laten praten.** De samenstelling werkt lokaal; wat nog niet geprobeerd is, - [ ] **`.env` invullen met het adres van de relay, en dan fase 4.** Alles staat klaar: de cliënt, het
is `transports` met `createOwnerWebSocketTransport` erin. **Hiervoor is het adres van de relay bedieningsvlak en de opdracht `klop` die de HTTP-status van de upgrade laat zien. Wat er nog nooit
nodig**, en dat komt van de gebruiker en gaat niet in de repo; zie [OPEN.md](OPEN.md) punt 6 gebeurd is, is een verbinding met een echte relay. **Eigenaar: gebruiker**, want het adres gaat niet
in deze publieke repo; kopieer `.env.sample` naar `.env` en zet `RELAY_URL`
## Fase 1 - de samenstelling (af) ## Fase 1 - de samenstelling (af)
@@ -27,20 +28,34 @@
`transports: []`, een rij met een `Uint8Array`-kolom wegschrijven en teruglezen. De bytes komen er `transports: []`, een rij met een `Uint8Array`-kolom wegschrijven en teruglezen. De bytes komen er
ongeschonden uit en het proces sluit schoon af, zonder defect en zonder te blijven hangen ongeschonden uit en het proces sluit schoon af, zonder defect en zonder te blijven hangen
## Fase 2 - de opdrachtregel ## Fase 2 - de opdrachtregel (af, 09-09-2026)
- [ ] Een eigenaar aanmaken en bewaren: mnemonic plus database in een eigen map, in een pad dat - [x] **Een eigenaar aanmaken en bewaren.** `src/register.js`: één `owners.json` met naam, mnemonic en
gitignored is. Het `OwnerId` wordt afgedrukt, de mnemonic niet `OwnerId`, geschreven via een tijdelijk bestand en een hernoeming. Het `OwnerId` wordt afgedrukt, de
- [ ] Een register van eigenaars: aanmaken, opsommen, teruglezen, weggooien mnemonic nooit
- [ ] Verbinden met de relay via `createOwnerWebSocketTransport`, met het adres uit een instelling - [x] **Aanmaken, opsommen, teruglezen, weggooien.** `nieuw`, `lijst`, `vergeet`. Vergeten is vergeten:
- [ ] Een blob schrijven met een instelbare omvang, en teruglezen de lokale database blijft staan en op de relay verandert er niets, en dat zegt de uitdraai er ook bij
- [ ] De losse WebSocket-poging die de HTTP-statuscode wél te zien krijgt; zie [OPEN.md](OPEN.md) punt 1 - [x] **Verbinden met de relay via `createOwnerWebSocketTransport`**, met het adres uit `.env`
(`src/config.js`, met `.env.sample` ernaast). **Gebouwd maar niet beproefd**, want er is nog geen
adres; dat is de volgende stap
- [x] **Een blob schrijven met een instelbare omvang, en teruglezen.** `schrijf` en `lees`, met
`--bytes` en `--lokaal`
- [x] **De losse WebSocket-poging die de HTTP-statuscode wél te zien krijgt:** `src/probe.js` en de
opdracht `klop`. Met `node:http` en niet met de WebSocket van Node, want die geeft je bij een
weigering een `error` en geen 401. Daarmee is [OPEN.md](OPEN.md) punt 1 opgelost vóórdat het een
probleem werd
## Fase 3 - het bedieningsvlak ## Fase 3 - het bedieningsvlak (af, 09-09-2026)
- [ ] De HTTP-server met de pagina, in de stijl van de twee statuspagina's - [x] **De HTTP-server met de pagina**, op localhost. `src/ui.js` en `src/index.html`, geen framework en
- [ ] De lijst met eigenaars, met per eigenaar de toestand en de knoppen uit [PLAN.md](PLAN.md) §4b geen bouwstap, net als de twee statuspagina's
- [ ] Het log, met tijdstip per gebeurtenis - [x] **De lijst met eigenaars**, met per eigenaar de toestand, het aantal blobs en de knoppen uit
[PLAN.md](PLAN.md) §4b: aankloppen, verbinden, verbreken, blob schrijven met omvang, lezen, vergeten
- [x] **Het log**, met tijdstip per gebeurtenis
- [x] **`Start.bat` en `Start.command`**, op verzoek van de gebruiker. Ze draaien `npm install` als het
nodig is, waarschuwen als `.env` ontbreekt, openen de browser en houden het venster open bij een fout
- [x] **In de browser nagekeken** (09-09-2026): een blob schrijven en teruglezen werkt vanuit de pagina,
de gegevens zijn dezelfde als die van de opdrachtregel, en licht en donker kloppen allebei
## Fase 4 - de proeven ## Fase 4 - de proeven
@@ -58,12 +73,20 @@ ze komen na fase 2 en kunnen deels vóór fase 3.
plan **Umbrelapp**. Dit is de opbrengst van het hele plan; zonder deze stap blijft het gereedschap plan **Umbrelapp**. Dit is de opbrengst van het hele plan; zonder deze stap blijft het gereedschap
zonder resultaat zonder resultaat
## Fase 5 - de toetsen ## Fase 5 - de toetsen (af, 09-09-2026)
Wat automatisch kan is klein, en [PLAN.md](PLAN.md) §7 zegt waarom. Wat automatisch kan is klein, en [PLAN.md](PLAN.md) §7 zegt waarom.
- [ ] Het register: aanmaken, teruglezen, een naam die al bestaat, een half aangelegde map - [x] **Het register**, in `tests/test_client_register.mjs`: aanmaken, teruglezen, een naam die al
- [ ] Een vaste mnemonic met een vast verwacht `OwnerId`. Dit is de toets die omvalt als Evolu onder ons bestaat, een onleesbaar bestand, een onbekende versie. Deze toets heeft géén pakketten nodig, want
iets anders gaat doen `register.js`, `argumenten.js` en `config.js` raken Evolu niet aan
- [ ] Het lezen van de opdrachtregel - [x] **Een vaste mnemonic met een vast verwacht `OwnerId`**, in `tests/test_client_lokaal.mjs`. Dit is de
- [ ] De nieuwe toetsen mutatie-testen, zoals `HomeGit/CLAUDE.md` voorschrijft toets die omvalt als Evolu onder ons iets anders gaat doen met de afleiding
- [x] **Het lezen van de opdrachtregel** en van `.env`, met de omzetting van `http://` naar `ws://`
- [x] **Afsluiten en heropenen**, en dat is er een die een echte fout vasthoudt: `evolu.insert` zet de
schrijfactie in de wachtrij en geeft meteen een id terug, dus wie vlak daarna afsluit is de rij kwijt.
`schrijf` wacht daarom op een query erachter
- [x] **Mutatie-getest**, drie keer: de polyfill uitzetten, de dubbele-naam-controle uitzetten, en de
omzetting van `http://` slopen. Alle drie lieten de júiste toets omvallen. De eerste liet zien dat de
suite bij een defect in een worker blíjft hangen in plaats van rood te worden, en daarom heeft
`test_client_lokaal.mjs` nu een wachthond van dertig seconden
+19 -2
View File
@@ -104,9 +104,11 @@ let store = null;
try { try {
const defecten = []; const defecten = [];
// Vastgehouden, want verderop wordt met dezelfde eigenaar heropend.
const eigenaar = nieuweEigenaar();
store = await openStore({ store = await openStore({
directory: map, directory: map,
owner: nieuweEigenaar(), owner: eigenaar,
onDefect: (defect) => { onDefect: (defect) => {
defecten.push(defect); defecten.push(defect);
}, },
@@ -115,7 +117,7 @@ try {
toets('zonder relayUrl is er geen transport', store.transportUrl === null); toets('zonder relayUrl is er geen transport', store.transportUrl === null);
const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]); const bytes = new Uint8Array([0, 1, 2, 253, 254, 255]);
const geschreven = store.schrijf('toets', bytes); const geschreven = await store.schrijf('toets', bytes);
toets('een blob wegschrijven geeft een id terug', typeof geschreven?.id === 'string'); toets('een blob wegschrijven geeft een id terug', typeof geschreven?.id === 'string');
const rijen = await store.lees(); const rijen = await store.lees();
@@ -131,6 +133,21 @@ try {
store = null; store = null;
await sluiten(); await sluiten();
// Opnieuw openen met dezelfde eigenaar en dezelfde map. Dit is de toets die
// een echte fout van 09-09-2026 vasthoudt: `evolu.insert` zet de schrijfactie
// in de wachtrij van de database-worker en geeft meteen een id terug, dus wie
// vlak daarna afsluit is de rij kwijt. Het symptoom was een `lees` die "nog
// geen blobs" meldde over een blob die zojuist bevestigd was, en dát is precies
// het soort fout dat een toets binnen één instantie niet vindt.
store = await openStore({ directory: map, owner: eigenaar });
const naHeropenen = await store.lees();
toets('de blob overleeft het afsluiten en heropenen', naHeropenen.length === 1);
toets('met hetzelfde label', naHeropenen[0]?.label === 'toets');
const nogmaalsSluiten = store.sluit;
store = null;
await nogmaalsSluiten();
// Afsluiten hoort stil te zijn. Ging dat mis, dan bleef er een worker draaien // Afsluiten hoort stil te zijn. Ging dat mis, dan bleef er een worker draaien
// die zijn eigen run al kwijt was, en dat merk je pas als een proces blijft // die zijn eigen run al kwijt was, en dat merk je pas als een proces blijft
// hangen. Dat was de vierde valstrik uit PLAN.md §4e. // hangen. Dat was de vierde valstrik uit PLAN.md §4e.
+243
View File
@@ -0,0 +1,243 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Toetst het register, de opdrachtregel en de instellingen van de testclient.
//
// Draaien: node tests/test_client_register.mjs
//
// Géén afhankelijkheden nodig, anders dan test_client_lokaal.mjs: register.js,
// argumenten.js en config.js raken Evolu niet aan. Dat is met opzet zo gebouwd
// en het is dezelfde scheiding die policy.js aan de relay-kant heeft: alles wat
// te toetsen valt zonder netwerk, staat los van alles wat dat niet is.
//
// Waar dit op let: het register bevat mnemonics. De duurste fout zit hier niet
// in een berekening maar in stil overschrijven of stil vervangen, en dat is dus
// wat hieronder het meeste aandacht krijgt.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { isBruikbareNaam, normaliseer, openRegister } from '../tools/relay-client/src/register.js';
import { leesArgumenten, OPDRACHTEN } from '../tools/relay-client/src/argumenten.js';
import { leesConfig, leesEnvBestand, naarRelayUrl } from '../tools/relay-client/src/config.js';
let goed = 0;
let fout = 0;
const toets = (omschrijving, voorwaarde) => {
if (voorwaarde) {
goed += 1;
return;
}
fout += 1;
console.log(`FOUT: ${omschrijving}`);
};
const gooit = (aan) => {
try {
aan();
return false;
} catch {
return true;
}
};
const verseMap = () => mkdtempSync(join(tmpdir(), 'relay-client-register-'));
const mappen = [];
const map = () => {
const nieuw = verseMap();
mappen.push(nieuw);
return nieuw;
};
// ── Namen ─────────────────────────────────────────────────────────────────────
{
for (const naam of ['a', 'proef-1', 'trezor_mac', 'A'.repeat(40)]) {
toets(`"${naam}" mag als naam`, isBruikbareNaam(naam));
}
for (const naam of ['', ' ', 'met spatie', '../ontsnapt', 'pad/naam', 'A'.repeat(41), null, 42]) {
toets(`${JSON.stringify(naam)} mag niet als naam`, !isBruikbareNaam(naam));
}
}
// ── Aanmaken, teruglezen, weggooien ───────────────────────────────────────────
{
const waar = map();
const register = openRegister(waar);
toets('een vers register is leeg', register.lijst().length === 0);
toets('en een onbekende naam geeft null', register.zoek('piet') === null);
const toegevoegd = register.voegToe({ naam: 'piet', mnemonic: 'woord ' .repeat(11) + 'woord', ownerId: 'AAA' });
toets('toevoegen geeft de regel terug', toegevoegd.naam === 'piet' && toegevoegd.ownerId === 'AAA');
toets('met een tijdstip erbij', typeof toegevoegd.aangemaakt === 'string');
toets('en hij staat in de lijst', register.lijst().length === 1);
toets('en is op naam te vinden', register.zoek('piet')?.ownerId === 'AAA');
// Het niet-gelukkige pad, en het belangrijkste van dit bestand: overschrijven
// zou de mnemonic van de vorige eigenaar weggooien.
toets(
'dezelfde naam nog eens toevoegen wordt geweigerd',
gooit(() => register.voegToe({ naam: 'piet', mnemonic: 'iets anders', ownerId: 'BBB' })),
);
toets('en de eerste staat er nog', register.zoek('piet')?.ownerId === 'AAA');
toets(
'een onbruikbare naam wordt geweigerd',
gooit(() => register.voegToe({ naam: 'met spatie', mnemonic: 'x', ownerId: 'CCC' })),
);
toets('vergeten van een onbekende naam wordt geweigerd', gooit(() => register.vergeet('jan')));
const weg = register.vergeet('piet');
toets('vergeten geeft de regel terug', weg.ownerId === 'AAA');
toets('en daarna is het register leeg', register.lijst().length === 0);
}
// ── Wat er op schijf komt, komt er weer af ────────────────────────────────────
{
const waar = map();
const eerste = openRegister(waar);
eerste.voegToe({ naam: 'een', mnemonic: 'mnemonic een', ownerId: 'ID1' });
eerste.voegToe({ naam: 'twee', mnemonic: 'mnemonic twee', ownerId: 'ID2' });
const tweede = openRegister(waar);
toets('een tweede register leest hetzelfde bestand', tweede.lijst().length === 2);
toets('in dezelfde volgorde', tweede.lijst()[0].naam === 'een' && tweede.lijst()[1].naam === 'twee');
toets('met de mnemonic erbij', tweede.zoek('twee')?.mnemonic === 'mnemonic twee');
// Een register dat je aanpast mag geen lijst teruggeven die meeverandert:
// dan zou een aanroeper per ongeluk in de staat kunnen schrijven.
const kopie = tweede.lijst();
kopie[0].ownerId = 'GEHACKT';
toets('de lijst is een kopie', tweede.zoek('een')?.ownerId === 'ID1');
}
// ── Een onleesbaar bestand wordt niet stil vervangen ──────────────────────────
{
const waar = map();
const register = openRegister(waar);
register.voegToe({ naam: 'een', mnemonic: 'geheim', ownerId: 'ID1' });
const pad = join(waar, 'owners.json');
const origineel = readFileSync(pad, 'utf8');
writeFileSync(pad, '{ dit is geen json', 'utf8');
toets('onleesbare JSON wordt gemeld', gooit(() => openRegister(waar)));
toets('en het bestand blijft staan', readFileSync(pad, 'utf8') === '{ dit is geen json');
writeFileSync(pad, JSON.stringify({ version: 2, owners: [] }), 'utf8');
toets('een onbekende versie wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, JSON.stringify({ version: 1, owners: [{ naam: 'een' }] }), 'utf8');
toets('een halve regel wordt gemeld', gooit(() => openRegister(waar)));
writeFileSync(pad, origineel, 'utf8');
toets('en het originele bestand leest weer goed', openRegister(waar).zoek('een')?.ownerId === 'ID1');
}
// ── normaliseer, los ──────────────────────────────────────────────────────────
{
toets('null is geen staat', normaliseer(null) === null);
toets('een array is geen staat', normaliseer([]) === null);
toets('zonder owners is het geen staat', normaliseer({ version: 1 }) === null);
toets(
'twee keer dezelfde naam is geen staat',
normaliseer({
version: 1,
owners: [
{ naam: 'a', mnemonic: 'x', ownerId: '1' },
{ naam: 'a', mnemonic: 'y', ownerId: '2' },
],
}) === null,
);
toets(
'een lege mnemonic is geen staat',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: '', ownerId: '1' }] }) === null,
);
toets(
'een geldige staat komt er heel uit',
normaliseer({ version: 1, owners: [{ naam: 'a', mnemonic: 'x', ownerId: '1' }] })?.owners[0]
?.naam === 'a',
);
}
// ── De opdrachtregel ──────────────────────────────────────────────────────────
{
toets('niets geeft geen opdracht en geen fout', (() => {
const uit = leesArgumenten([]);
return uit.fout === null && uit.opdracht === null;
})());
toets('een onbekende opdracht is een fout', leesArgumenten(['bestaatniet']).fout !== null);
const nieuw = leesArgumenten(['nieuw', 'piet']);
toets('nieuw piet leest goed', nieuw.fout === null && nieuw.waarden.naam === 'piet');
toets('nieuw zonder naam is een fout', leesArgumenten(['nieuw']).fout !== null);
// Streng, en dat is de bedoeling: `schrijf piet mijn label` zou anders stil
// "mijn" als label nemen en je naar een blob laten kijken die anders heet.
toets('een argument te veel is een fout', leesArgumenten(['nieuw', 'piet', 'jan']).fout !== null);
const schrijf = leesArgumenten(['schrijf', 'piet', 'mijn label', '--bytes', '512']);
toets('schrijf leest beide argumenten', schrijf.waarden.naam === 'piet' && schrijf.waarden.label === 'mijn label');
toets('en de vlag ernaast', schrijf.vlaggen.bytes === '512');
toets('--bytes=512 leest ook', leesArgumenten(['lijst', '--bytes=512']).vlaggen.bytes === '512');
toets('een losse vlag is true', leesArgumenten(['lijst', '--lokaal']).vlaggen.lokaal === true);
toets(
'een losse vlag gevolgd door een vlag blijft true',
leesArgumenten(['lijst', '--lokaal', '--bytes=8']).vlaggen.lokaal === true,
);
toets('elke opdracht heeft uitleg', Object.values(OPDRACHTEN).every((v) => typeof v.uitleg === 'string' && v.uitleg.length > 0));
}
// ── De instellingen ───────────────────────────────────────────────────────────
{
const gelezen = leesEnvBestand(
['# commentaar', '', 'RELAY_URL=ws://voorbeeld:3852', 'MET_QUOTES="waarde"', 'ROMMEL', 'LEEG='].join('\n'),
);
toets('een gewone regel leest', gelezen.RELAY_URL === 'ws://voorbeeld:3852');
toets('aanhalingstekens gaan eraf', gelezen.MET_QUOTES === 'waarde');
toets('een regel zonder = wordt overgeslagen', gelezen.ROMMEL === undefined);
toets('een lege waarde is een lege string', gelezen.LEEG === '');
toets('commentaar telt niet mee', Object.keys(gelezen).length === 3);
toets('ws:// blijft', naarRelayUrl('ws://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('wss:// blijft', naarRelayUrl('wss://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('http:// wordt ws://', naarRelayUrl('http://umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('https:// wordt wss://', naarRelayUrl('https://voorbeeld.nl') === 'wss://voorbeeld.nl');
toets('zonder schema wordt ws://', naarRelayUrl('umbrel.local:3852') === 'ws://umbrel.local:3852');
toets('spaties eromheen doen niets', naarRelayUrl(' umbrel.local:3852 ') === 'ws://umbrel.local:3852');
toets('een leeg adres wordt geweigerd', gooit(() => naarRelayUrl('')));
toets('een onbekend schema wordt geweigerd', gooit(() => naarRelayUrl('ftp://umbrel.local')));
// Een map zonder .env: dan is er geen relay-adres, en dat is geen fout maar een
// toestand die de UI moet kunnen tonen.
const zonder = leesConfig({ env: {}, wortel: map() });
toets('zonder .env is er geen relay-adres', zonder.relayUrl === null);
toets('en staat de UI-poort op de standaard', zonder.uiPoort === 4380);
const wortel = map();
writeFileSync(join(wortel, '.env'), 'RELAY_URL=umbrel.local:3852\nUI_PORT=5000\n', 'utf8');
const met = leesConfig({ env: {}, wortel });
toets('een .env wordt gelezen', met.relayUrl === 'ws://umbrel.local:3852');
toets('en de poort ook', met.uiPoort === 5000);
const overstemd = leesConfig({ env: { RELAY_URL: 'ws://anders:1234' }, wortel });
toets('de omgeving wint van het bestand', overstemd.relayUrl === 'ws://anders:1234');
}
for (const weg of mappen) rmSync(weg, { recursive: true, force: true });
console.log('');
console.log(`${goed} goed, ${fout} fout`);
process.exit(fout === 0 ? 0 : 1);
+21
View File
@@ -0,0 +1,21 @@
# 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://.
RELAY_URL=ws://umbrel.local:3852
# Waar het register en de databases komen te staan. Relatief aan
# tools/relay-client/. Hier staan mnemonics, dus deze map is gitignored.
#DATA_DIR=data
# De poort van het bedieningsvlak in de browser.
#UI_PORT=4380
+55
View File
@@ -0,0 +1,55 @@
@echo off
rem ============================================================================
rem Start het bedieningsvlak van de testclient en opent de browser.
rem
rem Dubbelklikken werkt: het venster blijft open zolang de server draait, en
rem blijft ook open als er iets misgaat, want anders lees je de fout nooit.
rem
rem De macOS-tegenhanger is Start.command ernaast.
rem ============================================================================
setlocal
cd /d "%~dp0"
where node >nul 2>nul
if errorlevel 1 (
echo Node is niet gevonden. Deze client heeft Node 24 of hoger nodig.
echo Zie https://nodejs.org
echo.
pause
exit /b 1
)
if not exist "node_modules" (
echo Pakketten ontbreken, npm install wordt gedraaid...
call npm install
if errorlevel 1 (
echo.
echo npm install is mislukt.
pause
exit /b 1
)
echo.
)
if not exist ".env" (
echo Let op: er is geen .env, dus er is geen relay-adres.
echo Kopieer .env.sample naar .env en vul RELAY_URL in.
echo Zonder dat werkt alles behalve verbinden met de relay.
echo.
)
rem De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem
rem dan in .env als UI_PORT en niet hier: dan weet de client het ook.
set UI_ADRES=http://127.0.0.1:4380
rem De browser gaat nu open, terwijl de server nog moet starten. Dat is goed:
rem het tabblad ververst zichzelf elke drie seconden, dus een eerste poging die
rem te vroeg komt herstelt vanzelf.
start "" "%UI_ADRES%"
node src\cli.js ui
echo.
echo De client is gestopt.
pause
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# =============================================================================
# Start het bedieningsvlak van de testclient en opent de browser. Voor macOS.
#
# Dubbelklikken in Finder werkt, maar alleen als het bestand uitvoerbaar is.
# Git bewaart dat bit, dus na een verse kloon hoort het te kloppen; is dat niet
# zo, dan eenmalig: chmod +x Start.command
#
# De Windows-tegenhanger is Start.bat ernaast.
# =============================================================================
set -u
cd "$(dirname "$0")"
if ! command -v node >/dev/null 2>&1; then
echo "Node is niet gevonden. Deze client heeft Node 24 of hoger nodig."
echo "Zie https://nodejs.org"
echo
read -r -p "Enter om te sluiten."
exit 1
fi
if [ ! -d node_modules ]; then
echo "Pakketten ontbreken, npm install wordt gedraaid..."
if ! npm install; then
echo
echo "npm install is mislukt."
read -r -p "Enter om te sluiten."
exit 1
fi
echo
fi
if [ ! -f .env ]; then
echo "Let op: er is geen .env, dus er is geen relay-adres."
echo "Kopieer .env.sample naar .env en vul RELAY_URL in."
echo "Zonder dat werkt alles behalve verbinden met de relay."
echo
fi
# De poort staat ook in src/config.js als standaard. Wijk je hier af, zet hem dan
# in .env als UI_PORT en niet hier: dan weet de client het ook.
UI_ADRES="http://127.0.0.1:4380"
# De browser gaat nu open, terwijl de server nog moet starten. Dat is goed: het
# tabblad ververst zichzelf elke drie seconden, dus een eerste poging die te
# vroeg komt herstelt vanzelf.
open "$UI_ADRES" >/dev/null 2>&1 || true
node src/cli.js ui
echo
echo "De client is gestopt."
read -r -p "Enter om te sluiten."
+99
View File
@@ -0,0 +1,99 @@
// ═══════════════════════════════════════════════════════════════════════════════
// 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' },
klop: { argumenten: ['naam'], uitleg: 'Klopt aan bij de relay en toont de HTTP-status' },
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(' --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');
};
+211
View File
@@ -0,0 +1,211 @@
#!/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 { 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);
},
});
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();
}
},
klop: async ({ naam }) => {
eisRelay();
const { regel } = haalEigenaarOp(naam);
console.log(`Aankloppen 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);
},
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);
}
+14 -2
View File
@@ -119,8 +119,20 @@ export const openStore = async ({ directory, owner, relayUrl, onDefect, consoleL
/** De URL waarmee verbonden wordt, of null bij een instantie zonder sync. */ /** De URL waarmee verbonden wordt, of null bij een instantie zonder sync. */
transportUrl: transports[0]?.url ?? null, transportUrl: transports[0]?.url ?? null,
/** Schrijft een blob weg. Geeft het id van de nieuwe rij terug. */ /**
schrijf: (label, body) => evolu.insert('blob', { label, body }), * 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. */ /** Alle blobs van deze eigenaar, oudste eerst. */
lees: () => evolu.loadQuery(allesQuery), lees: () => evolu.loadQuery(allesQuery),
+99
View File
@@ -0,0 +1,99 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Waar staat de relay, en waar mogen de gegevens staan.
//
// Het adres van de Umbrel staat NIET in deze repo en hoort daar ook niet in:
// dit is een publieke app store, en de .gitignore van de repo-root heeft er een
// eigen kopje over. Het komt dus uit de omgeving, met `.env` als de gewone weg
// en `.env.sample` als het voorbeeld dat wél in de repo staat.
//
// Geen dotenv-pakket: dat is een afhankelijkheid voor twintig regels, en die
// twintig regels zijn hier beter te lezen dan de instellingen van een pakket.
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** De wortel van dit gereedschap, ongeacht vanwaar het gestart wordt. */
export const WORTEL = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/**
* Leest een `.env`-bestand.
*
* Regels van de vorm `NAAM=waarde`. Lege regels en regels die met `#` beginnen
* worden overgeslagen, en aanhalingstekens om de waarde gaan eraf. Meer niet: wie
* hier een regel met een dubbele bodem in zet, krijgt hem letterlijk terug.
*/
export const leesEnvBestand = (inhoud) => {
const uit = {};
for (const regel of inhoud.split(/\r?\n/)) {
const kaal = regel.trim();
if (kaal === '' || kaal.startsWith('#')) continue;
const isGelijk = kaal.indexOf('=');
if (isGelijk <= 0) continue;
const naam = kaal.slice(0, isGelijk).trim();
let waarde = kaal.slice(isGelijk + 1).trim();
if (
(waarde.startsWith('"') && waarde.endsWith('"') && waarde.length >= 2)
|| (waarde.startsWith("'") && waarde.endsWith("'") && waarde.length >= 2)
) {
waarde = waarde.slice(1, -1);
}
uit[naam] = waarde;
}
return uit;
};
/**
* Maakt van een adres een WebSocket-URL, of gooit met uitleg.
*
* De relay is een WebSocket-server, dus `ws://` of `wss://`. Een `http://` dat
* iemand uit de adresbalk plakt wordt omgezet in plaats van geweigerd: dat is
* de meest voorkomende vergissing en er is niets dubbelzinnigs aan.
*
* Wat er wél uitkomt is een adres zonder schema (`umbrel.local:3852`), en dat
* krijgt `ws://`. Zonder poort blijft het zoals het is: achter een reverse proxy
* met TLS is `wss://host` zonder poort juist de goede vorm.
*/
export const naarRelayUrl = (adres) => {
if (typeof adres !== 'string' || adres.trim() === '') {
throw new Error('Geen relay-adres. Zet RELAY_URL in .env; zie .env.sample.');
}
const kaal = adres.trim();
if (kaal.startsWith('ws://') || kaal.startsWith('wss://')) return kaal;
if (kaal.startsWith('http://')) return `ws://${kaal.slice('http://'.length)}`;
if (kaal.startsWith('https://')) return `wss://${kaal.slice('https://'.length)}`;
if (kaal.includes('://')) {
throw new Error(`"${kaal}" heeft een schema dat hier niets betekent. Gebruik ws:// of wss://.`);
}
return `ws://${kaal}`;
};
/**
* De instellingen: eerst de omgeving, dan `.env`, dan de standaardwaarden.
*
* De omgeving wint van het bestand, zodat je één keer iets anders kunt proberen
* zonder het bestand aan te raken.
*/
export const leesConfig = ({ env = process.env, wortel = WORTEL } = {}) => {
let uitBestand = {};
try {
uitBestand = leesEnvBestand(readFileSync(join(wortel, '.env'), 'utf8'));
} catch (oorzaak) {
if (oorzaak.code !== 'ENOENT') throw oorzaak;
}
const haal = (naam) => env[naam] ?? uitBestand[naam];
const adres = haal('RELAY_URL');
return {
/** Het adres van de relay, of `null` als het er niet is. */
relayUrl: adres ? naarRelayUrl(adres) : null,
/** Waar het register en de databases staan. */
dataDir: resolve(wortel, haal('DATA_DIR') ?? 'data'),
/** De poort van het bedieningsvlak. */
uiPoort: Number.parseInt(haal('UI_PORT') ?? '4380', 10),
};
};
+7
View File
@@ -26,6 +26,7 @@
// "Web and Node.js 24+" bij dit onderdeel met zoveel woorden. // "Web and Node.js 24+" bij dit onderdeel met zoveel woorden.
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { import {
@@ -63,6 +64,12 @@ const createSqliteDriverIn = (directory) => (name, options) =>
* @param {{directory: string, consoleLevel?: string}} config * @param {{directory: string, consoleLevel?: string}} config
*/ */
export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect }) => { export const createNodeEvoluDeps = ({ directory, consoleLevel = 'warn', onDefect }) => {
// 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 console = createConsole({ level: consoleLevel });
const consoleStoreOutput = createConsoleStoreOutput(); const consoleStoreOutput = createConsoleStoreOutput();
+305
View File
@@ -0,0 +1,305 @@
<!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
CLAUDE.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);
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));
duw.append(
knop('Aankloppen', async () => {
const uit = await roep('klop', { naam: eigenaar.naam });
alert(`status ${uit.status ?? '-'}: ${uit.uitleg}`);
}),
eigenaar.verbonden
? knop('Verbreken', () => roep('verbreek', { naam: eigenaar.naam }))
: knop('Verbinden', () => roep('verbind', { naam: eigenaar.naam })),
omvang,
knop('Blob schrijven', () =>
roep('schrijf', { naam: eigenaar.naam, label: `blob ${tijd(new Date().toISOString())}`, bytes: omvang.value }),
),
knop('Lezen', async () => {
const uit = await roep('lees', { naam: eigenaar.naam });
toonRijen(kaart, uit.rijen);
}),
knop('Vergeten', async () => {
if (!confirm(`"${eigenaar.naam}" uit het register halen? De lokale database blijft staan.`)) return;
await roep('vergeet', { naam: eigenaar.naam });
}),
);
rij.append(duw);
kaart.append(rij);
const id = document.createElement('div');
id.className = 'id';
id.textContent = eigenaar.ownerId;
kaart.append(id);
return kaart;
};
const toonRijen = (kaart, rijen) => {
kaart.querySelector('table')?.remove();
const tabel = document.createElement('table');
if (rijen.length === 0) {
const cel = tabel.insertRow().insertCell();
cel.className = 'leeg';
cel.textContent = 'Nog geen blobs.';
}
for (const rij of rijen) {
const tr = tabel.insertRow();
tr.insertCell().textContent = rij.label;
const bytes = tr.insertCell();
bytes.className = 'getal';
bytes.textContent = `${rij.bytes} bytes`;
const id = tr.insertCell();
id.className = 'id';
id.textContent = rij.id;
}
kaart.append(tabel);
};
const ververs = async (altijd = false) => {
const ruw = await (await fetch('/api/status')).text();
if (!altijd && ruw === laatsteStaat) return;
laatsteStaat = ruw;
const staat = JSON.parse(ruw);
$('relay').textContent = staat.relayUrl ?? 'niet ingesteld';
$('waarschuwing').hidden = Boolean(staat.relayUrl);
const lijst = $('eigenaars');
lijst.textContent = '';
if (staat.eigenaars.length === 0) {
const leeg = document.createElement('div');
leeg.className = 'kaart leeg';
leeg.textContent = 'Nog geen eigenaars. Maak er hierboven een aan.';
lijst.append(leeg);
}
for (const eigenaar of staat.eigenaars) lijst.append(kaartVoor(eigenaar));
const log = $('log');
log.textContent = '';
if (staat.log.length === 0) {
log.className = 'kaart leeg';
log.textContent = 'Nog niets gebeurd.';
} else {
log.className = 'kaart';
for (const regel of staat.log) {
const el = document.createElement('div');
const t = document.createElement('time');
t.textContent = tijd(regel.tijd);
el.append(t, regel.tekst);
log.append(el);
}
}
};
$('nieuwKnop').addEventListener('click', async () => {
const naam = $('nieuwNaam').value.trim();
const melding = $('nieuwMelding');
melding.className = 'melding';
melding.textContent = '';
try {
const uit = await roep('nieuw', { naam });
$('nieuwNaam').value = '';
melding.textContent = `"${uit.naam}" aangemaakt.`;
await ververs();
} catch (oorzaak) {
melding.className = 'melding fout';
melding.textContent = oorzaak.message;
}
});
$('nieuwNaam').addEventListener('keydown', (gebeurtenis) => {
if (gebeurtenis.key === 'Enter') $('nieuwKnop').click();
});
void ververs();
// Verversen terwijl je met een knop bezig bent zou de kaart onder je muis
// vervangen, dus dat wordt overgeslagen.
setInterval(() => {
if (!bezig) void ververs();
}, 3000);
</script>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Aankloppen bij de relay en de HTTP-statuscode teruggeven.
//
// Dit bestaat omdat Evolu een weigering niet van een relay die plat ligt
// onderscheidt: de cliënt is local-first en probeert het gewoon opnieuw. Voor
// een gereedschap dat juist de allowlist moet beproeven is dat het verschil
// tussen een proef en een gok; zie het plan Testclient, OPEN.md punt 1.
//
// Het kan zo goedkoop omdat de relay zijn beslissing bij de WebSocket-upgrade
// neemt en niet bij een bericht: het `OwnerId` staat in de URL, en een weigering
// is een HTTP 401 op de upgrade. Zie §10 van
// Docs/Referenties/Upstream-evolu-relay.md.
//
// Met node:http en niet met de WebSocket van Node, en dat is de hele reden dat
// dit bestand er is: de WebSocket-API geeft je bij een weigering een `error` en
// geen statuscode, en 401 tegenover 404 tegenover "niets luistert" is precies
// wat je wilt weten.
// ═══════════════════════════════════════════════════════════════════════════════
import { request as httpRequest } from 'node:http';
import { request as httpsRequest } from 'node:https';
import { randomBytes } from 'node:crypto';
/**
* De URL waarmee deze eigenaar zou verbinden.
*
* Dezelfde vorm die `createOwnerWebSocketTransport` maakt. Hij staat hier apart
* zodat er getoetst kan worden dat de twee niet uit elkaar lopen; loopt dat wel
* uit elkaar, dan klopt de proef niet en zie je dat nergens aan.
*/
export const upgradeUrl = (relayUrl, ownerId) => `${relayUrl}?ownerId=${ownerId}`;
/**
* Klopt aan en geeft terug wat er gebeurde.
*
* @returns {Promise<{status: number|null, geaccepteerd: boolean, uitleg: string}>}
*/
export const klopAan = (relayUrl, ownerId, { timeoutMs = 5000 } = {}) =>
new Promise((klaar) => {
let url;
try {
url = new URL(upgradeUrl(relayUrl, ownerId));
} catch {
klaar({ status: null, geaccepteerd: false, uitleg: `"${relayUrl}" is geen bruikbaar adres` });
return;
}
const beveiligd = url.protocol === 'wss:';
const verzoek = (beveiligd ? httpsRequest : httpRequest)({
hostname: url.hostname,
port: url.port || (beveiligd ? 443 : 80),
path: `${url.pathname}${url.search}`,
headers: {
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': randomBytes(16).toString('base64'),
},
});
let afgerond = false;
const antwoord = (uitkomst) => {
if (afgerond) return;
afgerond = true;
verzoek.destroy();
klaar(uitkomst);
};
// Een geslaagde upgrade komt níet als 'response' binnen maar als 'upgrade'.
// Dat is meteen het antwoord: de relay heeft deze eigenaar toegelaten.
verzoek.on('upgrade', () => {
antwoord({ status: 101, geaccepteerd: true, uitleg: 'toegelaten' });
});
verzoek.on('response', (res) => {
const status = res.statusCode ?? null;
antwoord({
status,
geaccepteerd: false,
uitleg:
status === 401
? 'geweigerd: deze eigenaar mag er niet in'
: `geweigerd met status ${status}`,
});
});
verzoek.on('error', (oorzaak) => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen verbinding: ${oorzaak.message}` });
});
verzoek.setTimeout(timeoutMs, () => {
antwoord({ status: null, geaccepteerd: false, uitleg: `geen antwoord binnen ${timeoutMs} ms` });
});
verzoek.end();
});
+157
View File
@@ -0,0 +1,157 @@
// ═══════════════════════════════════════════════════════════════════════════════
// Het register: welke eigenaars bestaan er, en hoe kom je erbij terug.
//
// Eén bestand, `owners.json`, met per eigenaar een naam, zijn mnemonic en zijn
// `OwnerId`. De mnemonic is het enige dat werkelijk bewaard hoeft te worden; het
// `OwnerId` staat erbij omdat je het nodig hebt om een eigenaar op de
// statuspagina van de app terug te vinden, en omdat je het dan kunt opzoeken
// zonder Evolu te starten.
//
// **Dit is sleutelmateriaal.** Het heeft buiten deze proefopstelling geen
// waarde, maar het bestand staat in .gitignore en het hoort daar te blijven; zie
// het plan Testclient, PLAN.md §4a.
//
// Alles hier is een gewone functie op een pad. Geen Evolu, geen netwerk: dat is
// wat dit het enige deel van de cliënt maakt dat zonder relay en zonder
// fixtures te toetsen is, en het is dezelfde scheiding die policy.js aan de
// relay-kant heeft.
// ═══════════════════════════════════════════════════════════════════════════════
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Een naam is een herkenpunt voor jezelf en gaat nooit de deur uit.
*
* Toch begrensd, want hij belandt in een JSON-bestand en straks op een pagina.
* Letters, cijfers, streepje en liggend streepje; dat is genoeg om "trezor-mac"
* of "proef_2" te kunnen schrijven en het sluit een naam met een schuine streep
* erin uit, die er als pad uit zou zien.
*/
const NAAM_PATROON = /^[A-Za-z0-9_-]{1,40}$/;
export const isBruikbareNaam = (naam) => typeof naam === 'string' && NAAM_PATROON.test(naam);
const LEEG = { version: 1, owners: [] };
/**
* Leest het bestand. Geeft `null` bij iets wat niet klopt.
*
* Bewust streng, en om dezelfde reden als in policy.js: een half begrepen
* bestand is gevaarlijker dan geen bestand. Hier zou dat betekenen dat een
* eigenaar stil verdwijnt terwijl zijn mnemonic wél op schijf staat, en dan is
* de proef die eroverheen liep niet meer te herhalen.
*/
export const normaliseer = (ruw) => {
if (ruw === null || typeof ruw !== 'object' || Array.isArray(ruw)) return null;
if (ruw.version !== 1) return null;
if (!Array.isArray(ruw.owners)) return null;
const owners = [];
for (const eigenaar of ruw.owners) {
if (eigenaar === null || typeof eigenaar !== 'object') return null;
if (!isBruikbareNaam(eigenaar.naam)) return null;
if (typeof eigenaar.mnemonic !== 'string' || eigenaar.mnemonic.length === 0) return null;
if (typeof eigenaar.ownerId !== 'string' || eigenaar.ownerId.length === 0) return null;
if (owners.some((eerder) => eerder.naam === eigenaar.naam)) return null;
owners.push({
naam: eigenaar.naam,
mnemonic: eigenaar.mnemonic,
ownerId: eigenaar.ownerId,
aangemaakt: typeof eigenaar.aangemaakt === 'string' ? eigenaar.aangemaakt : null,
});
}
return { version: 1, owners };
};
/**
* Opent het register in `directory`, en maakt de map aan als hij er niet is.
*
* Een ontbrekend bestand is de begintoestand en geen fout. Een onleesbaar bestand
* wél: dat wordt gemeld en niet stil vervangen, want vervangen is hier hetzelfde
* als weggooien.
*/
export const openRegister = (directory) => {
mkdirSync(directory, { recursive: true });
const pad = join(directory, 'owners.json');
let staat;
try {
staat = normaliseer(JSON.parse(readFileSync(pad, 'utf8')));
} catch (oorzaak) {
if (oorzaak.code === 'ENOENT') {
staat = { ...LEEG, owners: [] };
} else {
throw new Error(`${pad} is niet te lezen: ${oorzaak.message}`);
}
}
if (staat === null) {
throw new Error(
`${pad} klopt niet. Er is niets vervangen; kijk er zelf naar, want hier staan mnemonics in.`,
);
}
// Schrijven gaat via een tijdelijk bestand en een hernoeming. Een half
// geschreven register kost mnemonics, en een hernoeming binnen dezelfde map is
// op elk besturingssysteem dat wij raken één handeling.
const bewaar = () => {
const tijdelijk = `${pad}.nieuw`;
writeFileSync(tijdelijk, `${JSON.stringify(staat, null, 2)}\n`, 'utf8');
renameSync(tijdelijk, pad);
};
return {
pad,
/** Alle eigenaars, in de volgorde waarin ze zijn aangemaakt. */
lijst: () => staat.owners.map((eigenaar) => ({ ...eigenaar })),
/** Eén eigenaar, of `null`. */
zoek: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
return gevonden ? { ...gevonden } : null;
},
/**
* Zet een eigenaar in het register.
*
* Gooit bij een naam die niet kan of al bestaat. Overschrijven zou de
* mnemonic van de vorige eigenaar weggooien, en dat is precies het soort
* stille schade waar het commentaar bovenaan dit bestand over gaat.
*/
voegToe: ({ naam, mnemonic, ownerId, aangemaakt }) => {
if (!isBruikbareNaam(naam)) {
throw new Error(`"${naam}" kan niet als naam. Letters, cijfers, - en _, hoogstens 40 tekens.`);
}
if (staat.owners.some((eigenaar) => eigenaar.naam === naam)) {
throw new Error(`Er is al een eigenaar "${naam}".`);
}
const nieuw = {
naam,
mnemonic,
ownerId,
aangemaakt: aangemaakt ?? new Date().toISOString(),
};
staat = { ...staat, owners: [...staat.owners, nieuw] };
bewaar();
return { ...nieuw };
},
/**
* Haalt een eigenaar uit het register. Gooit als hij er niet in staat.
*
* Let op wat dit níet doet: de database van die eigenaar blijft staan, en op
* de relay verandert er helemaal niets. Vergeten is hier vergeten, geen
* wissen; zie OPEN.md punt 3 en 4 van het plan.
*/
vergeet: (naam) => {
const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam);
if (!gevonden) throw new Error(`Geen eigenaar "${naam}".`);
staat = { ...staat, owners: staat.owners.filter((eigenaar) => eigenaar.naam !== naam) };
bewaar();
return { ...gevonden };
},
};
};
+250
View File
@@ -0,0 +1,250 @@
// ═══════════════════════════════════════════════════════════════════════════════
// 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 = [];
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);
},
});
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);
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;
}
}
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,
});
}
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.');
}
const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl)));
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,
})),
};
},
klop: 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);
meld(`${naam}: aankloppen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`);
return uitkomst;
},
vergeet: async ({ naam }) => {
await verbreek(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(() => {});
};