Relay 0.7.0: een weg naar binnen, en de melding bij elke klik weg

Vier punten van de gebruiker, opgekomen tijdens het beproeven met de testclient.

De getimede leerstand is eruit; de wachtlijst is de enige weg naar binnen. Zijn
redenering: allebei de wegen vragen iemand die bij de app kan, dus het is
dubbelop, en het venster is de zwakste omdat het iedereen toelaat die er
toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek wss-adres
kan staan. De oorspronkelijke reden voor de leerstand, dat je je eigen OwnerId
nergens kon aflezen, verviel toen de weigerlijst dat id ging tonen. Daarmee
verdwijnt ook de bug die hij dezelfde dag meldde: een geleerde eigenaar bleef in
de weigerlijst staan terwijl hij al kon schrijven en lezen, want decideOwner
haalde hem niet van die lijst af en de knop allow wel.

"Refused owners" heet "Waiting list", met de badge Waiting en een teller waar de
widget van het tijdvenster stond. Het veld op schijf blijft rejected: hernoemen
zou een migratie zijn voor een woord dat niemand ziet.

Het adres onderaan zei http:// en dat kan nergens werken, want de relay spreekt
WebSocket en nooit HTTP. Nu ws://<host>:3852, met een regel over wss://<domein>
zonder poort achter een reverse proxy. Dat is precies de fout die diezelfde dag
een ronde kostte bij het koppelen van de testclient.

De melding bij elke klik is weg. Die stond in de gewone stroom van de pagina,
dus alles eronder schoof omlaag en weer omhoog. Nu gaan de knoppen in de lijsten
even op slot tot de ronde de nieuwe stand heeft; foutmeldingen blijven wel staan,
want die zeggen iets wat je nergens anders ziet.

STATE_VERSION blijft 1 en een owners.json van 0.6.0 leest door: learning en
learningUntil worden gelezen, genegeerd en niet teruggeschreven. Een verhoging
zou store.js de allowlist van een werkende installatie opzij laten schuiven.

Twee toetsen bewaken dat de leerstand niet terugsluipt: een onbekende eigenaar
wordt geweigerd ook met learning: true in het bestand, en set-learning is een
onbekende actie. Beide mutatie-getest.

De compose staat op 0.7.0 zonder digest, zodat het hard faalt tot de image
bestaat. Bouwen, duwen en pinnen ligt bij de gebruiker.

Suite: 424 goed, 0 fout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harmen
2026-09-09 15:45:55 +02:00
co-authored by Claude Opus 5
parent 04c20a37f0
commit a092464e4d
13 changed files with 449 additions and 804 deletions
+16 -9
View File
@@ -157,18 +157,19 @@ python tests/test_relay_agent.py
De eerste toetst het toegangsbeleid uit De eerste toetst het toegangsbeleid uit
`tools/evolu-relay/src/policy.js`: wie er op de relay mag schrijven, wat er met een onbekende eigenaar `tools/evolu-relay/src/policy.js`: wie er op de relay mag schrijven, wat er met een onbekende eigenaar
gebeurt, en of een geblokkeerde eigenaar er niet alsnog in komt doordat de leerstand aanstaat. Node 24 of gebeurt, en of een geblokkeerde eigenaar er niet alsnog in komt. Node 24 of
hoger, geen afhankelijkheden, en het bestand is met opzet `.mjs`: in de repo-root is er geen `package.json`, hoger, geen afhankelijkheden, en het bestand is met opzet `.mjs`: in de repo-root is er geen `package.json`,
dus een `.js` zou als CommonJS gelezen worden en de import falen. dus een `.js` zou als CommonJS gelezen worden en de import falen.
**Het beleid staat los van de relay en dat is de reden dat dit te testen is.** `policy.js` bevat alleen **Het beleid staat los van de relay en dat is de reden dat dit te testen is.** `policy.js` bevat alleen
pure functies: geen bestanden, geen netwerk, geen klok. Wat wél schijf raakt staat in `store.js`, en de pure functies: geen bestanden, geen netwerk, geen klok. Wat wél schijf raakt staat in `store.js`, en de
relay zelf wordt in `index.js` alleen aangeroepen. Houd die scheiding aan; anders is er van deze test niets relay zelf wordt in `index.js` alleen aangeroepen. Houd die scheiding aan; anders is er van deze test niets
meer over. **Ook het tijdvenster heeft geen klok**: `isLearningOpen(state, now)` krijgt het tijdstip mee. meer over. Twee toetsen erin bewaken dat de leerstand niet terugsluipt: een onbekende eigenaar wordt
geweigerd ook met `learning: true` in het bestand, en `set-learning` is een onbekende actie.
De tweede toetst de agent: de labels en de resterende tijd van het tijdvenster. Dat zijn de twee dingen die De tweede toetst de agent: de labels. Dat is het enige dat hij zélf uitrekent in plaats van doorgeeft, en
de agent zélf uitrekent in plaats van doorgeeft, en daarmee de enige plek in dit bestand waar hij eigen daarmee de enige plek in dat bestand waar hij eigen logica heeft. De HTTP-laag zit er niet in: die handlers
logica heeft. De HTTP-laag zit er niet in: die handlers zijn zonder socket niet aan te roepen. zijn zonder socket niet aan te roepen.
### Architectuurregels ### Architectuurregels
@@ -200,10 +201,16 @@ De verdeling:
| `command.json` | de agent | de postbus. De agent legt erin, het relay-proces past toe en ruimt op | | `command.json` | de agent | de postbus. De agent legt erin, het relay-proces past toe en ruimt op |
| `labels.json` | de agent | een label zegt niets over toegang, dus de relay hoeft het niet te weten. Dat het hier staat en niet in `owners.json` is wat labelen meteen laat werken, ook als de relay omgevallen is | | `labels.json` | de agent | een label zegt niets over toegang, dus de relay hoeft het niet te weten. Dat het hier staat en niet in `owners.json` is wat labelen meteen laat werken, ook als de relay omgevallen is |
**Het tijdvenster voor nieuwe eigenaars loopt in het relay-proces, nooit in de pagina.** Een teller in een **Er is één weg naar binnen: de wachtlijst.** Een onbekende eigenaar wordt geweigerd en onthouden; op de
tabblad dat je sluit, sluit de deur niet. En `decideOwner` kijkt naar `isLearningOpen(state, now)` en niet statuspagina laat je hem toe. De getimede leerstand die daarnaast bestond is er in 0.7.0 (09-09-2026) uit,
naar het veld `learning`: de lus die een verlopen venster opruimt loopt elke twee seconden, en in dat gat op voorstel van de gebruiker: allebei de wegen vragen iemand die bij de app kan, dus het was dubbelop, en
zou een onbekende alsnog binnenkomen. het venster was de zwakste omdat het iederéén toeliet die er toevallig in verbond. Zet hem niet terug zonder
het plan **Umbrelapp** te lezen.
De velden `learning` en `learningUntil` worden nog wél gelézen en dan genegeerd. Dat is geen slordigheid:
`STATE_VERSION` verhogen zou `normalizeState` een bestaand `owners.json` laten afwijzen, en dan schuift
`store.js` de allowlist van een werkende installatie opzij en gaat de deur dicht voor eigenaars die er al in
stonden. Het veld op schijf heet nog `rejected`; op de pagina heet het de wachtlijst.
**Het beleid is de enige plek waar staat wie er binnen mag, en `isOwnerAllowed` wordt alleen bij de **Het beleid is de enige plek waar staat wie er binnen mag, en `isOwnerAllowed` wordt alleen bij de
WebSocket-upgrade gesteld.** Gevolg om te kennen voordat je iets belooft op de pagina: **blokkeren werkt pas WebSocket-upgrade gesteld.** Gevolg om te kennen voordat je iets belooft op de pagina: **blokkeren werkt pas
+38
View File
@@ -19,6 +19,44 @@ De regel is: **verhoog je `VERSION`, dan verhoog je ook het manifest; andersom n
> geschreven uit de `releaseNotes` in het manifest en uit `PROGRESS.md` van het plan **Umbrelapp**. Ze zijn > geschreven uit de `releaseNotes` in het manifest en uit `PROGRESS.md` van het plan **Umbrelapp**. Ze zijn
> daarom korter dan de rest. > daarom korter dan de rest.
## 0.7.0 - 09-09-2026
**Nog niet gebouwd.** De compose staat op `evolu-relay:0.7.0` zónder digest, zodat een installatie hard
faalt tot de image bestaat in plaats van stil de oude te draaien. Bouwen, duwen en pinnen ligt bij de
gebruiker, op de Umbrel.
**Eén weg naar binnen: de wachtlijst.** De getimede leerstand is eruit, op voorstel van de gebruiker en met
zijn redenering: allebei de wegen vragen iemand die bij de app kan, dus het was dubbelop, en het venster was
de zwakste van de twee omdat het iederéén toeliet die er toevallig in verbond. Dat weegt zwaarder nu de
relay op een publiek wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je eigen
`OwnerId` nergens kon aflezen, en die verviel toen de weigerlijst dat id ging tonen.
Wat daarmee mee verdween is een verwarring die de gebruiker dezelfde dag meldde: een eigenaar die via de
leerstand binnenkwam bleef in de weigerlijst staan totdat je daar "allow" koos, terwijl hij ondertussen wél
kon verbinden, schrijven en lezen. `decideOwner` haalde een geleerde eigenaar niet van die lijst af; de
knop "allow" deed dat wel. Nu is er nog maar één pad en kan dat niet meer uiteenlopen.
**"Refused owners" heet "Waiting list".** Dat is wat het is: kandidaten die op een beslissing wachten. De
badge op zo'n regel heet "Waiting" in plaats van "Turned away", en de widget die het tijdvenster toonde telt
nu hoeveel eigenaars er wachten. Het veld in `owners.json` heet nog `rejected`; hernoemen zou een migratie
zijn voor een woord dat niemand ziet.
**Het adres onderaan zei `http://` en dat kan nergens werken:** de relay is een WebSocket-server en spreekt
nooit HTTP tegen een cliënt. Nu `ws://<host>:3852`, met een regel eronder over wat het achter een reverse
proxy wordt: `wss://<domein>` **zonder** poort, want de proxy luistert op 443. Dat is precies de fout die op
09-09-2026 een ronde kostte bij het koppelen van de testcliënt.
**De melding bij elke klik is weg.** Er verscheen bij elke knop "Sent. The relay applies changes within a
few seconds." in de balk bovenaan, en die balk staat in de gewone stroom van de pagina: alles eronder schoof
omlaag en bij het verdwijnen weer omhoog. Gemeld door de gebruiker. In plaats daarvan gaan de knoppen in de
lijsten even op slot tot de ronde de nieuwe stand heeft opgehaald. De foutmeldingen blijven wél staan: die
zeggen iets wat je nergens anders ziet.
**Bestaande installaties lezen door.** `STATE_VERSION` blijft 1 en `normalizeState` accepteert een
`owners.json` van 0.6.0 met `learning` en `learningUntil` erin; die velden worden gelezen, genegeerd en niet
teruggeschreven. Een verhoging zou het bestand laten afwijzen, en dan schuift `store.js` de allowlist van
een werkende installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
## 0.6.0 - 08-09-2026 ## 0.6.0 - 08-09-2026
Gebouwd en geduwd op de Umbrel op 08-09-2026, gepind als Gebouwd en geduwd op de Umbrel op 08-09-2026, gepind als
@@ -3,6 +3,33 @@
> Chronologisch sessielog, nieuwste bovenaan. Kort: 3 tot 6 regels per entry. Wat er is gebeurd en waarom, > Chronologisch sessielog, nieuwste bovenaan. Kort: 3 tot 6 regels per entry. Wat er is gebeurd en waarom,
> niet wat er nog moet: dat staat in [TAKEN.md](TAKEN.md). > niet wat er nog moet: dat staat in [TAKEN.md](TAKEN.md).
## 09-09-2026 - 0.7.0: één weg naar binnen, en de melding bij elke klik weg
Vier punten van de gebruiker, opgekomen tijdens het beproeven met de testcliënt. Drie ervan waren
eenduidig, de vierde was een ontwerpvraag die hij zelf stelde en die ik met hem beantwoord heb.
**De getimede leerstand is eruit; de wachtlijst is de enige weg naar binnen.** Zijn redenering: allebei de
wegen vragen iemand die bij de app kan, dus het is dubbelop, en het venster is de zwakste omdat het
iederéén toelaat die er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek wss-adres kan
staan. De oorspronkelijke reden voor de leerstand (je kon je eigen `OwnerId` nergens aflezen) verviel toen
de weigerlijst dat id ging tonen. Daarmee verdween ook een bug die hij dezelfde dag meldde: een geleerde
eigenaar bleef in de weigerlijst staan terwijl hij al kon schrijven en lezen; `decideOwner` haalde hem niet
van die lijst af en de knop "allow" wel.
**"Refused owners" heet "Waiting list"**, met de badge "Waiting" en een teller die de widget van het
tijdvenster vervangt. Het veld op schijf blijft `rejected`: hernoemen zou een migratie zijn voor een woord
dat niemand ziet.
**Het adres onderaan zei `http://`** en dat kan nergens werken, want de relay spreekt WebSocket. Nu
`ws://<host>:3852` met een regel over `wss://<domein>` zonder poort achter een reverse proxy. Dat is
precies de fout die diezelfde dag een ronde kostte bij het koppelen van de testcliënt.
**De melding bij elke klik is weg**, want die duwde de hele pagina omlaag en weer omhoog. In plaats
daarvan gaan de knoppen in de lijsten even op slot; foutmeldingen blijven wél in de balk staan.
`STATE_VERSION` blijft 1 en een `owners.json` van 0.6.0 leest gewoon door. **Nog niet gebouwd:** de compose
staat op `0.7.0` zonder digest, zodat het hard faalt tot de image bestaat.
## 07-09-2026 - de eigenaars op volgorde van laatst gezien ## 07-09-2026 - de eigenaars op volgorde van laatst gezien
Op verzoek van de gebruiker: de rijen stonden in de volgorde van het bestand, dus van binnenkomst, en dat Op verzoek van de gebruiker: de rijen stonden in de volgorde van het bestand, dus van binnenkomst, en dat
@@ -16,6 +16,23 @@
## Volgende stap ## Volgende stap
- [ ] **0.7.0 bouwen, duwen en pinnen op de Umbrel. Eigenaar: gebruiker.** De compose staat op
`evolu-relay:0.7.0` **zonder digest**, zodat een installatie hard faalt tot de image bestaat in
plaats van stil de oude te draaien. Na het duwen: de index-digest erbij, drie keer in de compose. De
commando's staan in [Images-pinnen.md](../../../Referenties/Images-pinnen.md)
- [x] **0.7.0 klaargezet (09-09-2026): één weg naar binnen, en de melding bij elke klik weg.** Vier punten
van de gebruiker, opgekomen tijdens het beproeven met de testcliënt:
- de getimede leerstand is eruit; de **wachtlijst** is de enige weg naar binnen. Zijn redenering:
dubbelop, en het venster liet iederéén toe die er toevallig in verbond. Daarmee verdween ook de
bug dat een geleerde eigenaar in de weigerlijst bleef staan terwijl hij al kon schrijven
- "Refused owners" heet "Waiting list", met de badge "Waiting" en een teller waar het tijdvenster
stond. Het veld op schijf blijft `rejected`
- het adres onderaan zei `http://` en zegt nu `ws://`, met een regel over `wss://<domein>` zonder
poort achter een reverse proxy
- de melding bij elke klik is weg; de knoppen in de lijsten gaan even op slot. Foutmeldingen blijven
`STATE_VERSION` blijft 1, dus een `owners.json` van 0.6.0 leest door. **Alles nog ongezien op het
apparaat**, want er is geen image
- [x] **De eigenaars gesorteerd op laatst gezien, meest recent bovenaan (07-09-2026, 0.5.6).** Op verzoek - [x] **De eigenaars gesorteerd op laatst gezien, meest recent bovenaan (07-09-2026, 0.5.6).** Op verzoek
van de gebruiker; in beide kaders en over de groepen heen. Alleen de pagina, dus geen nieuwe image. van de gebruiker; in beide kaders en over de groepen heen. Alleen de pagina, dus geen nieuwe image.
**Nog nakijken op het apparaat.** **Nog nakijken op het apparaat.**
+169 -277
View File
@@ -8,19 +8,20 @@
// //
// Waarom dit bestand er is: dit beleid is de enige plek die bepaalt wie er op de // Waarom dit bestand er is: dit beleid is de enige plek die bepaalt wie er op de
// relay mag schrijven. De duurste fout zit hier niet in een berekening maar in // relay mag schrijven. De duurste fout zit hier niet in een berekening maar in
// een guard, en dat is precies wat hieronder getoetst wordt: een geblokkeerde // een guard, en dat is precies wat hieronder de meeste aandacht krijgt.
// eigenaar mag niet alsnog binnenkomen doordat de leerstand aanstaat. //
// **Sinds 0.7.0 is er één weg naar binnen: de wachtlijst.** De getimede leerstand
// is eruit. Twee toetsen hieronder bewaken dat die niet stiekem terugkomt: een
// onbekende eigenaar wordt geweigerd ook als er `learning: true` in het
// staatsbestand staat, en `set-learning` is een onbekende actie.
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
import { import {
MAX_LEARNING_SECONDS,
MAX_OWNER_ID_LENGTH, MAX_OWNER_ID_LENGTH,
MAX_REJECTED, MAX_REJECTED,
applyCommand, applyCommand,
createEmptyState, createEmptyState,
decideOwner, decideOwner,
expireLearning,
isLearningOpen,
normalizeState, normalizeState,
} from '../tools/evolu-relay/src/policy.js'; } from '../tools/evolu-relay/src/policy.js';
@@ -36,338 +37,229 @@ const toets = (omschrijving, voorwaarde) => {
console.log(`FOUT: ${omschrijving}`); console.log(`FOUT: ${omschrijving}`);
}; };
const NU = '2026-08-28T12:00:00.000Z'; const NU = '2026-09-09T12:00:00.000Z';
const LATER = '2026-08-28T13:00:00.000Z'; const LATER = '2026-09-09T13:00:00.000Z';
// Binnen en buiten een venster van twee minuten dat op NU begint.
const BINNEN = '2026-08-28T12:01:00.000Z';
const NA = '2026-08-28T12:02:30.000Z';
const EIGEN = 'owner-van-de-gebruiker'; const EIGEN = 'owner-van-de-gebruiker';
const VREEMD = 'owner-van-een-ander'; const VREEMD = 'owner-van-een-ander';
const TWEE_MINUTEN = 120;
// ── De leerstand ────────────────────────────────────────────────────────────── /** Een staat met deze eigenaar erin, toegelaten of niet. */
const metEigenaar = (id, allowed, extra = {}) => ({
...createEmptyState(),
owners: [{ id, allowed, firstSeen: NU, lastSeen: NU, ...extra }],
});
// ── Een verse installatie laat niemand binnen ─────────────────────────────────
{ {
const vers = createEmptyState(); const leeg = createEmptyState();
toets('een verse staat staat in de leerstand', vers.learning === true); toets('een verse staat heeft geen eigenaars', leeg.owners.length === 0);
toets('en een lege wachtlijst', leeg.rejected.length === 0);
toets('en geen leerstand meer', !('learning' in leeg) && !('learningUntil' in leeg));
const eerste = decideOwner(vers, EIGEN, NU); const uitkomst = decideOwner(leeg, VREEMD, NU);
toets('de eerste eigenaar wordt toegelaten', eerste.allowed === true); toets('een onbekende eigenaar wordt geweigerd', uitkomst.allowed === false);
toets('en onthouden als toegelaten', eerste.state.owners.length === 1); toets('met reden "waiting"', uitkomst.reason === 'waiting');
toets('met de reden dat hij geleerd is', eerste.reason === 'learned'); toets('en belandt op de wachtlijst', uitkomst.state.rejected.length === 1);
toets('met zijn id erbij', uitkomst.state.rejected[0].id === VREEMD);
const nogEens = decideOwner(eerste.state, EIGEN, LATER); toets('en één poging', uitkomst.state.rejected[0].attempts === 1);
toets('dezelfde eigenaar blijft toegelaten', nogEens.allowed === true); toets('en de staat is veranderd', uitkomst.changed === true);
toets('en komt er niet dubbel in', nogEens.state.owners.length === 1); toets('en hij staat niet stiekem bij de eigenaars', uitkomst.state.owners.length === 0);
toets('de reden is nu dat hij bekend is', nogEens.reason === 'known-allowed');
} }
// ── De leerstand uit ────────────────────────────────────────────────────────── // ── De leerstand is er echt uit ───────────────────────────────────────────────
{ {
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state; // Dit is de toets die de verwijdering van 0.7.0 vasthoudt. Een staatsbestand
const dicht = applyCommand(geleerd, { action: 'set-learning', value: false }, NU).state; // van 0.6.0 heeft deze velden nog, en ze horen geen enkel effect te hebben.
const metOudeVelden = {
...createEmptyState(),
learning: true,
learningUntil: null,
};
const uitkomst = decideOwner(metOudeVelden, VREEMD, NU);
toets('`learning: true` in het bestand laat niemand meer binnen', uitkomst.allowed === false);
toets('en zet hem op de wachtlijst', uitkomst.state.rejected[0]?.id === VREEMD);
const eigen = decideOwner(dicht, EIGEN, LATER); const metVenster = {
toets('met de leerstand uit mag de bekende eigenaar er nog steeds in', eigen.allowed === true); ...createEmptyState(),
learning: true,
learningUntil: LATER,
};
toets(
'ook een venster dat nog niet verlopen is doet niets',
decideOwner(metVenster, VREEMD, NU).allowed === false,
);
const vreemd = decideOwner(dicht, VREEMD, LATER); const opdracht = applyCommand(createEmptyState(), { action: 'set-learning', value: true }, NU);
toets('maar een onbekende wordt geweigerd', vreemd.allowed === false); toets('set-learning is een onbekende actie geworden', opdracht.error === 'unknown-action');
toets('de weigering wordt onthouden voor de pagina', vreemd.state.rejected.length === 1); toets('en verandert niets', opdracht.changed === false);
toets('en hij belandt niet in de allowlist', vreemd.state.owners.length === 1);
} }
// ── De guard die er het meest toe doet ──────────────────────────────────────── // ── De guard die er het meest toe doet ────────────────────────────────────────
{ {
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state; // Een geblokkeerde eigenaar mag er nooit in. Dat was tot 0.6.0 het gevaar dat
const geblokkeerd = applyCommand(geleerd, { action: 'block', ownerId: EIGEN }, NU).state; // de leerstand hem alsnog toeliet; die is er nu uit, maar de guard blijft de
// belangrijkste regel van dit bestand.
const geblokkeerd = metEigenaar(EIGEN, false);
const uitkomst = decideOwner(geblokkeerd, EIGEN, LATER);
toets('een geblokkeerde eigenaar blijft buiten', uitkomst.allowed === false);
toets('met reden "known-blocked"', uitkomst.reason === 'known-blocked');
toets('en komt niet op de wachtlijst', uitkomst.state.rejected.length === 0);
toets('hij blijft geblokkeerd staan', uitkomst.state.owners[0].allowed === false);
toets('en "laatst gezien" wordt bijgewerkt', uitkomst.state.owners[0].lastSeen === LATER);
toets('de leerstand staat hier nog aan', geblokkeerd.learning === true); const toegelaten = decideOwner(metEigenaar(EIGEN, true), EIGEN, LATER);
toets('een toegelaten eigenaar mag erin', toegelaten.allowed === true);
toets('met reden "known-allowed"', toegelaten.reason === 'known-allowed');
toets('en "laatst gezien" wordt bijgewerkt', toegelaten.state.owners[0].lastSeen === LATER);
const opnieuw = decideOwner(geblokkeerd, EIGEN, LATER); const zelfdeMoment = decideOwner(metEigenaar(EIGEN, true), EIGEN, NU);
toets( toets('twee keer op hetzelfde moment verandert niets', zelfdeMoment.changed === false);
'een geblokkeerde eigenaar komt er niet alsnog in via de leerstand',
opnieuw.allowed === false,
);
toets('en de reden zegt dat hij geblokkeerd is', opnieuw.reason === 'known-blocked');
} }
// ── Onbruikbare invoer ──────────────────────────────────────────────────────── // ── Onbruikbare invoer ────────────────────────────────────────────────────────
{ {
const vers = createEmptyState(); const leeg = createEmptyState();
for (const rommel of ['', null, undefined, 42, {}, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) { for (const onzin of ['', null, 42, {}, [], 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
const uitkomst = decideOwner(vers, rommel, NU); const uitkomst = decideOwner(leeg, onzin, NU);
toets(`een onbruikbaar id (${typeof rommel}) wordt geweigerd`, uitkomst.allowed === false); toets(`${JSON.stringify(onzin)} wordt geweigerd`, uitkomst.allowed === false);
toets('en wordt niet onthouden', uitkomst.state.owners.length === 0); // Onthouden zou hier juist de weg openzetten om het bestand vol te schrijven.
toets('ook niet als geweigerde poging', uitkomst.state.rejected.length === 0); toets(`${JSON.stringify(onzin)} wordt niet onthouden`, uitkomst.changed === false);
toets(`${JSON.stringify(onzin)} geeft reden invalid-owner-id`, uitkomst.reason === 'invalid-owner-id');
} }
toets(
`een id van precies ${MAX_OWNER_ID_LENGTH} tekens mag wel`,
decideOwner(leeg, 'x'.repeat(MAX_OWNER_ID_LENGTH), NU).changed === true,
);
} }
// ── De lijst met weigeringen loopt niet vol ─────────────────────────────────── // ── De wachtlijst loopt niet vol ──────────────────────────────────────────────
{ {
let staat = { ...createEmptyState(), learning: false }; let staat = createEmptyState();
for (let i = 0; i < MAX_REJECTED + 5; i += 1) { for (let i = 0; i < MAX_REJECTED + 10; i += 1) {
staat = decideOwner(staat, `indringer-${i}`, NU).state; staat = decideOwner(staat, `vreemde-${i}`, NU).state;
} }
toets('de lijst met weigeringen wordt afgekapt', staat.rejected.length === MAX_REJECTED); toets('de wachtlijst wordt afgekapt', staat.rejected.length === MAX_REJECTED);
toets('en de nieuwste staat vooraan', staat.rejected[0].id === `indringer-${MAX_REJECTED + 4}`); // Nieuwste vooraan: een aanvaller die blijft proberen duwt zijn eigen eerdere
// pogingen weg en niet de eigenaar die er net bij kwam.
toets('de nieuwste staat vooraan', staat.rejected[0].id === `vreemde-${MAX_REJECTED + 9}`);
const herhaling = decideOwner(staat, `indringer-${MAX_REJECTED + 4}`, LATER); const nogmaals = decideOwner(staat, `vreemde-${MAX_REJECTED + 9}`, LATER);
toets('een herhaalde poging telt op', herhaling.state.rejected[0].attempts === 2); toets('een tweede poging telt op', nogmaals.state.rejected[0].attempts === 2);
toets('en voegt geen tweede regel toe', herhaling.state.rejected.length === MAX_REJECTED); toets('en zet geen tweede regel neer', nogmaals.state.rejected.length === MAX_REJECTED);
toets('en houdt het eerste moment vast', nogmaals.state.rejected[0].firstSeen === NU);
toets('en werkt het laatste bij', nogmaals.state.rejected[0].lastSeen === LATER);
} }
// ── De opdrachten van de pagina ─────────────────────────────────────────────── // ── Toelaten vanaf de wachtlijst ──────────────────────────────────────────────
{ {
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state; // De weg die er sinds 0.7.0 als enige is: iemand staat op de wachtlijst en jij
// laat hem toe.
const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
const toegelaten = applyCommand(gewacht, { action: 'allow', ownerId: VREEMD }, LATER);
const uit = applyCommand(geleerd, { action: 'set-learning', value: false }, NU); toets('toelaten lukt', toegelaten.error === null);
toets('de leerstand gaat uit', uit.state.learning === false); toets('en hij staat bij de eigenaars', toegelaten.state.owners[0]?.id === VREEMD);
toets('en dat is een wijziging', uit.changed === true); toets('als toegelaten', toegelaten.state.owners[0]?.allowed === true);
// De geschiedenis gaat mee. Ging dat mis, dan meldt de pagina "first seen" op
// het moment van de klik terwijl die eigenaar al twintig minuten aan de deur
// stond, en dat is juist bij een eigenaar die je alsnog toelaat wat je wilt zien.
toets('met het eerste moment van de wachtlijst', toegelaten.state.owners[0]?.firstSeen === NU);
// En dit is de klacht van de gebruiker van 09-09-2026, die met de leerstand mee
// verdween: hij mag niet in beide lijsten tegelijk staan.
toets('en hij is van de wachtlijst af', toegelaten.state.rejected.length === 0);
const nogEensUit = applyCommand(uit.state, { action: 'set-learning', value: false }, NU); toets('en daarna mag hij erin', decideOwner(toegelaten.state, VREEMD, LATER).allowed === true);
toets('nog een keer uitzetten verandert niets', nogEensUit.changed === false);
toets('en is geen fout', nogEensUit.error === null);
const vergeten = applyCommand(geleerd, { action: 'forget', ownerId: EIGEN }, NU);
toets('vergeten haalt de eigenaar uit de lijst', vergeten.state.owners.length === 0);
const onbekend = applyCommand(geleerd, { action: 'forget', ownerId: VREEMD }, NU);
toets('een onbekende vergeten is een fout', onbekend.error === 'unknown-owner');
toets('en verandert niets', onbekend.changed === false);
const blokkeerOnbekend = applyCommand(geleerd, { action: 'block', ownerId: VREEMD }, NU);
toets('een onbekende blokkeren is een fout', blokkeerOnbekend.error === 'unknown-owner');
const dicht = { ...createEmptyState(), learning: false };
const geweigerd = decideOwner(dicht, VREEMD, NU).state;
const alsnog = applyCommand(geweigerd, { action: 'allow', ownerId: VREEMD }, LATER);
toets('een geweigerde poging kan alsnog toegelaten worden', alsnog.state.owners.length === 1);
toets('en verdwijnt dan uit de weigeringen', alsnog.state.rejected.length === 0);
toets(
'en houdt het moment waarop hij zich meldde, niet dat van de klik',
alsnog.state.owners[0].firstSeen === NU,
);
toets(
'en mag er daarna in, ook met de leerstand uit',
decideOwner(alsnog.state, VREEMD, LATER).allowed === true,
);
toets(
'een onbekende actie is een fout',
applyCommand(geleerd, { action: 'sudo' }, NU).error === 'unknown-action',
);
toets(
'een opdracht zonder vorm is een fout',
applyCommand(geleerd, 'zet alles open', NU).error === 'malformed-command',
);
toets(
'een leerstand-opdracht zonder boolean is een fout',
applyCommand(geleerd, { action: 'set-learning', value: 'ja' }, NU).error === 'malformed-command',
);
} }
// ── Het tijdvenster voor nieuwe eigenaars ───────────────────────────────────── // ── De overige opdrachten van de pagina ───────────────────────────────────────
//
// De pagina zet de deur twee minuten open in plaats van een schakelaar om te
// zetten die je moet onthouden. De teller hoort in dít bestand en niet in de
// browser: een teller in een tabblad dat je sluit, sluit de deur niet.
{ {
const vers = createEmptyState(); const staat = metEigenaar(EIGEN, true);
toets('een verse staat heeft geen tijdslot', vers.learningUntil === null);
toets('en staat dus open zonder dat er een klok loopt', isLearningOpen(vers, LATER) === true);
const open = applyCommand( const geblokkeerd = applyCommand(staat, { action: 'block', ownerId: EIGEN }, LATER);
vers, toets('blokkeren lukt', geblokkeerd.error === null);
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN }, toets('en zet allowed op false', geblokkeerd.state.owners[0].allowed === false);
NU,
);
toets('openzetten met seconden is een wijziging', open.changed === true);
toets('en zet een tijdslot', open.state.learningUntil === '2026-08-28T12:02:00.000Z');
toets('binnen het venster staat de deur open', isLearningOpen(open.state, BINNEN) === true);
toets('erna niet meer', isLearningOpen(open.state, NA) === false);
}
// De guard die er het meest toe doet, en dit is de reden dat `decideOwner` naar
// `isLearningOpen` kijkt en niet naar het veld `learning`: tussen het aflopen van
// het venster en de ronde die het bestand opruimt zitten een paar seconden. In dat
// gat mag er niemand binnenkomen.
{
const open = applyCommand(
createEmptyState(),
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN },
NU,
).state;
toets('in het bestand staat de leerstand nog aan', open.learning === true);
const binnen = decideOwner(open, EIGEN, BINNEN);
toets('binnen het venster wordt een onbekende geleerd', binnen.allowed === true);
const erna = decideOwner(open, VREEMD, NA);
toets( toets(
'na het venster wordt hij geweigerd, ook al staat learning nog op true', 'blokkeren van een onbekende wordt geweigerd',
erna.allowed === false, applyCommand(staat, { action: 'block', ownerId: VREEMD }, LATER).error === 'unknown-owner',
); );
toets('met de reden dat er niet geleerd wordt', erna.reason === 'not-learning');
toets('en de poging wordt onthouden voor de pagina', erna.state.rejected.length === 1);
// En wie al binnen was, blijft binnen. Het venster gaat over onbekenden. const vergeten = applyCommand(staat, { action: 'forget', ownerId: EIGEN }, LATER);
const bekend = decideOwner(binnen.state, EIGEN, LATER); toets('vergeten lukt', vergeten.error === null);
toets('een geleerde eigenaar mag er na het venster nog steeds in', bekend.allowed === true); toets('en de eigenaar is weg', vergeten.state.owners.length === 0);
} toets(
'vergeten van een onbekende wordt geweigerd',
{ applyCommand(staat, { action: 'forget', ownerId: VREEMD }, LATER).error === 'unknown-owner',
const open = applyCommand(
createEmptyState(),
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN },
NU,
).state;
// Opnieuw indrukken verlengt. Zonder deze uitzondering zou `applyCommand`
// 'changed: false' teruggeven omdat learning al true was, en dan liep het
// venster af terwijl de gebruiker net verlengde.
const opnieuw = applyCommand(
open,
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN },
BINNEN,
); );
toets('opnieuw openzetten is een wijziging', opnieuw.changed === true);
toets('en zet de klok terug', opnieuw.state.learningUntil === '2026-08-28T12:03:00.000Z');
toets('dus is de deur na het oude venster nog open', isLearningOpen(opnieuw.state, NA) === true);
// Vroegtijdig sluiten haalt het tijdslot mee weg. Zou het blijven staan, dan const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
// erfde een volgende 'open zonder seconden' een venster dat niet gevraagd is. const weg = applyCommand(gewacht, { action: 'forget', ownerId: VREEMD }, LATER);
const dicht = applyCommand(open, { action: 'set-learning', value: false }, BINNEN); toets('iemand van de wachtlijst vergeten lukt ook', weg.error === null);
toets('vroegtijdig sluiten kan', dicht.state.learning === false); toets('en dan is de wachtlijst leeg', weg.state.rejected.length === 0);
toets('en haalt het tijdslot weg', dicht.state.learningUntil === null);
toets('de deur is dan dicht', isLearningOpen(dicht.state, BINNEN) === false);
const zonderSlot = applyCommand(dicht.state, { action: 'set-learning', value: true }, BINNEN); for (const onzin of [null, 42, 'tekst', {}, { action: 'iets' }]) {
toets('openzetten zonder seconden blijft bestaan', zonderSlot.state.learning === true); const uitkomst = applyCommand(staat, onzin, NU);
toets('en heeft dan geen tijdslot', zonderSlot.state.learningUntil === null); toets(`${JSON.stringify(onzin)} als opdracht verandert niets`, uitkomst.changed === false);
} toets(`${JSON.stringify(onzin)} als opdracht geeft een fout`, uitkomst.error !== null);
}
// Wat de agent hoort tegen te houden, houdt dit bestand ook tegen. Twee zeven, en for (const onzin of ['', null, 42, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
// dat is met opzet: dit is de kant die van buiten komt.
{
const vers = createEmptyState();
const rommel = [
{ action: 'set-learning', value: true, seconds: 0 },
{ action: 'set-learning', value: true, seconds: -60 },
{ action: 'set-learning', value: true, seconds: 1.5 },
{ action: 'set-learning', value: true, seconds: '120' },
{ action: 'set-learning', value: true, seconds: true },
{ action: 'set-learning', value: true, seconds: MAX_LEARNING_SECONDS + 1 },
];
for (const opdracht of rommel) {
const uitkomst = applyCommand(vers, opdracht, NU);
toets( toets(
`een onbruikbaar aantal seconden wordt geweigerd: ${JSON.stringify(opdracht.seconds)}`, `block met ownerId ${JSON.stringify(onzin)} wordt geweigerd`,
uitkomst.error === 'malformed-command', applyCommand(staat, { action: 'block', ownerId: onzin }, NU).error === 'malformed-command',
); );
toets('en verandert niets', uitkomst.changed === false);
} }
toets(
'de bovengrens zelf mag wel',
applyCommand(vers, { action: 'set-learning', value: true, seconds: MAX_LEARNING_SECONDS }, NU)
.error === null,
);
// Een onleesbaar `now` mag geen venster zonder einde opleveren. Dat is de reden
// dat `addSeconds` null teruggeeft in plaats van iets te verzinnen.
const kapotteKlok = applyCommand(
vers,
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN },
'geen tijdstip',
);
toets('met een onleesbare klok wordt de opdracht geweigerd', kapotteKlok.error === 'malformed-command');
toets('en blijft de staat zoals hij was', kapotteKlok.changed === false);
} }
// Het opruimen. Correctheid hangt hier niet aan, want `isLearningOpen` weigert al; // ── Wat er van schijf komt ────────────────────────────────────────────────────
// wat dit oplevert is dat het bestand en de pagina hetzelfde zeggen als de klok.
{
const open = applyCommand(
createEmptyState(),
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN },
NU,
).state;
const nogNiet = expireLearning(open, BINNEN);
toets('binnen het venster wordt er niets opgeruimd', nogNiet.changed === false);
toets('en de staat blijft dezelfde', nogNiet.state === open);
const opgeruimd = expireLearning(open, NA);
toets('na het venster gaat de leerstand uit', opgeruimd.state.learning === false);
toets('en het tijdslot weg', opgeruimd.state.learningUntil === null);
toets('en dat is een wijziging', opgeruimd.changed === true);
toets(
'nog een keer opruimen doet niets',
expireLearning(opgeruimd.state, LATER).changed === false,
);
toets(
'een leerstand zonder tijdslot wordt niet opgeruimd',
expireLearning(createEmptyState(), LATER).changed === false,
);
// Een onleesbaar tijdstip in het bestand is dicht, niet open. Dezelfde regel als
// bij een onleesbare owners.json in store.js: bij twijfel weigert de app.
const kapot = { ...createEmptyState(), learningUntil: 'ergens volgende week' };
toets('een onleesbaar tijdslot leest als dicht', isLearningOpen(kapot, NU) === false);
toets('en een onleesbare klok ook', isLearningOpen(open, 'geen tijdstip') === false);
}
// ── Lezen van schijf ──────────────────────────────────────────────────────────
{ {
const goedeStaat = decideOwner(createEmptyState(), EIGEN, NU).state; toets('null is geen staat', normalizeState(null) === null);
const rondgang = normalizeState(JSON.parse(JSON.stringify(goedeStaat))); toets('een array is geen staat', normalizeState([]) === null);
toets('een eigen staat overleeft de rondgang door JSON', rondgang !== null); toets('een verkeerde versie is geen staat', normalizeState({ version: 2, owners: [], rejected: [] }) === null);
toets('met de eigenaar er nog in', rondgang.owners[0].id === EIGEN); toets('zonder owners is het geen staat', normalizeState({ version: 1, rejected: [] }) === null);
toets('zonder rejected is het geen staat', normalizeState({ version: 1, owners: [] }) === null);
const rommel = [ // Dit is de toets die de installatie van de gebruiker beschermt: een bestand
null, // van 0.6.0 heeft `learning` en `learningUntil` en moet gewoon lezen. Zou dat
'tekst', // afgewezen worden, dan schuift store.js de allowlist opzij en gaat de deur
[], // dicht voor eigenaars die er al in stonden.
{ version: 999, learning: true, owners: [], rejected: [] }, const oud = {
{ version: 1, learning: 'ja', owners: [], rejected: [] }, version: 1,
{ version: 1, learning: true, owners: {}, rejected: [] }, learning: true,
{ version: 1, learning: true, owners: [{ id: '', allowed: true }], rejected: [] }, learningUntil: '2026-09-09T12:02:00.000Z',
{ version: 1, learning: true, owners: [{ id: EIGEN }], rejected: [] }, owners: [{ id: EIGEN, allowed: true, firstSeen: NU, lastSeen: NU }],
]; rejected: [{ id: VREEMD, firstSeen: NU, lastSeen: NU, attempts: 3 }],
for (const invoer of rommel) { };
toets(`onbegrepen staat wordt geweigerd: ${JSON.stringify(invoer)}`, normalizeState(invoer) === null);
}
// Het tijdslot is toegevoegd zonder STATE_VERSION te verhogen, en dat is de
// toets die dat verdedigt: een bestand van een draaiende installatie mag niet
// ineens onleesbaar worden. Zou dat gebeuren, dan schuift store.js de allowlist
// opzij en gaat de deur dicht voor eigenaars die er al in stonden.
const oud = { version: 1, learning: true, owners: [{ id: EIGEN, allowed: true }], rejected: [] };
const gelezen = normalizeState(oud); const gelezen = normalizeState(oud);
toets('een bestand van vóór het tijdslot leest nog', gelezen !== null); toets('een bestand van 0.6.0 leest gewoon', gelezen !== null);
toets('met de leerstand die erin stond', gelezen.learning === true); toets('met de eigenaar erin', gelezen.owners[0].id === EIGEN);
toets('en zonder tijdslot', gelezen.learningUntil === null); toets('en de wachtlijst erin', gelezen.rejected[0].attempts === 3);
toets('en de eigenaar er nog in', gelezen.owners[0].id === EIGEN); toets('en zonder de oude velden', !('learning' in gelezen) && !('learningUntil' in gelezen));
const metSlot = normalizeState({ ...oud, learningUntil: '2026-08-28T12:02:00.000Z' }); toets(
toets('een geldig tijdslot blijft staan', metSlot.learningUntil === '2026-08-28T12:02:00.000Z'); 'een eigenaar zonder allowed is geen staat',
normalizeState({ version: 1, owners: [{ id: EIGEN }], rejected: [] }) === null,
for (const onzin of ['morgen', '', 42, {}, null]) { );
toets( toets(
`een onleesbaar tijdslot wordt null en geen weigering: ${JSON.stringify(onzin)}`, 'een eigenaar met een leeg id is geen staat',
normalizeState({ ...oud, learningUntil: onzin }) !== null normalizeState({ version: 1, owners: [{ id: '', allowed: true }], rejected: [] }) === null,
&& normalizeState({ ...oud, learningUntil: onzin }).learningUntil === null, );
); toets(
} 'een wachtende zonder id is geen staat',
normalizeState({ version: 1, owners: [], rejected: [{ attempts: 1 }] }) === null,
);
toets(
'een onleesbaar aantal pogingen wordt 1 en geen weigering',
normalizeState({ version: 1, owners: [], rejected: [{ id: VREEMD, attempts: -3 }] })
?.rejected[0].attempts === 1,
);
} }
console.log(''); console.log('');
+10 -110
View File
@@ -1,4 +1,4 @@
"""Toetst de agent van Evolu Relay: de labels en het tijdvenster. """Toetst de agent van Evolu Relay: de labels.
Waarom deze test bestaat. De agent van deze app beslist niets over toegang, en dat Waarom deze test bestaat. De agent van deze app beslist niets over toegang, en dat
is precies waarom hij tot 30-08-2026 ongetoetst kon blijven: hij las een bestand en is precies waarom hij tot 30-08-2026 ongetoetst kon blijven: hij las een bestand en
@@ -9,9 +9,9 @@ eigen logica hebben:
- **hij schrijft zelf een bestand**, `labels.json`, en is daar de enige schrijver - **hij schrijft zelf een bestand**, `labels.json`, en is daar de enige schrijver
van. Dat is de eerste plek waar de agent staat bijhoudt in plaats van doorgeeft, van. Dat is de eerste plek waar de agent staat bijhoudt in plaats van doorgeeft,
en labelen is lezen-wijzigen-schrijven, dus er zit een slot om; en labelen is lezen-wijzigen-schrijven, dus er zit een slot om;
- **hij rekent het tijdvenster uit** dat de pagina toont. Een verlopen venster moet Tot 0.6.0 rekende hij daarnaast het tijdvenster voor nieuwe eigenaars uit. Dat
hij als dicht rapporteren, ook al staat er in owners.json nog dat de leerstand venster is er in 0.7.0 uit: er is nog één weg naar binnen, en dat is de wachtlijst
aan is: het relay-proces ruimt dat op zijn eigen moment op. met handmatig toelaten. De toetsen daarover zijn met de functie mee verdwenen.
Wat deze test NIET dekt: de HTTP-laag. De handlers zitten in een Wat deze test NIET dekt: de HTTP-laag. De handlers zitten in een
BaseHTTPRequestHandler en die is zonder socket niet aan te roepen; wat eronder BaseHTTPRequestHandler en die is zonder socket niet aan te roepen; wat eronder
@@ -105,66 +105,6 @@ def test_template_is_invulbaar_zonder_schade(u):
"een envsubst-stap zou die invullen en de Python-code slopen") "een envsubst-stap zou die invullen en de Python-code slopen")
# ── Het tijdvenster ─────────────────────────────────────────────────────────
def test_venster_dat_nog_loopt(agent, u):
feiten = agent.learning_facts({"learning": True, "learningUntil": iso(90)})
u.check("een lopend venster leest als open", feiten["learning"] is True)
u.check("met een resterende tijd", isinstance(feiten["learningSecondsLeft"], int))
u.check("die ergens rond de negentig seconden ligt",
85 <= feiten["learningSecondsLeft"] <= 90,
"kreeg %r" % feiten["learningSecondsLeft"])
def test_venster_dat_verlopen_is(agent, u):
"""De toets die er het meest toe doet aan deze kant.
In het bestand staat `learning: true` en een tijdstip dat voorbij is. Het
relay-proces ruimt dat op in zijn lus van twee seconden, dus er is een moment
waarop het bestand nog "aan" zegt terwijl de deur dicht is. De pagina hoort dan
"closed" te tonen en geen open deur.
"""
feiten = agent.learning_facts({"learning": True, "learningUntil": iso(-30)})
u.check("een verlopen venster leest als dicht", feiten["learning"] is False)
u.check("met nul seconden over", feiten["learningSecondsLeft"] == 0)
u.check("en zonder tijdstip", feiten["learningUntil"] is None)
def test_venster_zonder_tijdslot(agent, u):
feiten = agent.learning_facts({"learning": True, "learningUntil": None})
u.check("open zonder tijdslot blijft open", feiten["learning"] is True)
u.check("en heeft geen teller", feiten["learningSecondsLeft"] is None)
def test_onleesbaar_tijdslot(agent, u):
"""Onleesbaar hoort niet als "voor altijd open" te lezen, maar wel als open.
Anders dan bij `policy.js`, en dat is met opzet: dit is de weergave en niet de
beslissing. Het relay-proces beslist, en dáár is een onleesbaar tijdstip dicht.
De pagina die iets anders toont dan het bestand zegt zou verwarrender zijn dan
een pagina die de leerstand toont die er staat.
"""
for onzin in ["morgen", "", 42, {}, []]:
feiten = agent.learning_facts({"learning": True, "learningUntil": onzin})
u.check("een onleesbaar tijdslot valt terug op de leerstand zelf: %r" % (onzin,),
feiten["learning"] is True and feiten["learningSecondsLeft"] is None)
def test_leerstand_onbekend(agent, u):
"""Onbekend is geen synonym voor uit.
Ontbreekt owners.json, dan is er nog nooit iets geschreven en weet niemand wat
de leerstand is. Uit is een keuze, onbekend is een reden om te kijken, en de
pagina hoort dat verschil te tonen.
"""
feiten = agent.learning_facts({})
u.check("een ontbrekende leerstand blijft onbekend", feiten["learning"] is None)
u.check("en heeft geen teller", feiten["learningSecondsLeft"] is None)
uit = agent.learning_facts({"learning": False, "learningUntil": None})
u.check("en uit blijft uit", uit["learning"] is False)
# ── De labels ─────────────────────────────────────────────────────────────── # ── De labels ───────────────────────────────────────────────────────────────
def test_label_zetten_en_weghalen(agent, u): def test_label_zetten_en_weghalen(agent, u):
@@ -253,43 +193,7 @@ def test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir):
u.check("een label van alleen witruimte valt weg", "owner-f" not in labels) u.check("een label van alleen witruimte valt weg", "owner-f" not in labels)
# ── De opdracht met seconden ──────────────────────────────────────────────── # ── De status ───────────────────────────────────────────────────────────────
def test_set_learning_met_seconden(agent, u):
goed, problem = agent.valid_command({"action": "set-learning", "value": True, "seconds": 120})
u.check("openzetten met seconden mag", problem is None)
u.check("en de seconden gaan mee naar de relay", goed.get("seconds") == 120)
zonder, problem = agent.valid_command({"action": "set-learning", "value": True})
u.check("openzetten zonder seconden mag ook", problem is None)
u.check("en dan staat er geen seconds in de opdracht", "seconds" not in zonder)
for seconden, waarom in [
(0, "nul"),
(-60, "negatief"),
(1.5, "geen heel getal"),
("120", "tekst"),
# isinstance(True, int) is in Python waar, dus zonder de uitsluiting in de
# agent zou een boolean hier als aantal seconden doorglippen.
(True, "een boolean"),
(agent.MAX_LEARNING_SECONDS + 1, "boven de bovengrens"),
]:
_, problem = agent.valid_command(
{"action": "set-learning", "value": True, "seconds": seconden})
u.check("een onbruikbaar aantal seconden wordt geweigerd: %s" % waarom,
problem is not None)
_, problem = agent.valid_command(
{"action": "set-learning", "value": True, "seconds": agent.MAX_LEARNING_SECONDS})
u.check("de bovengrens zelf mag wel", problem is None)
# Seconden bij dichtzetten is onzin en hoort dus geweigerd te worden in plaats
# van stil genegeerd: stil negeren zou een sluitopdracht met een tikfout laten
# slagen terwijl er iets anders gebeurt dan er staat.
_, problem = agent.valid_command(
{"action": "set-learning", "value": False, "seconds": 120})
u.check("seconden bij dichtzetten wordt geweigerd", problem is not None)
def test_status_hangt_labels_aan_de_regels(agent, u, state_dir): def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
"""De pagina krijgt het label bij de eigenaar, en het bestand van de relay """De pagina krijgt het label bij de eigenaar, en het bestand van de relay
@@ -320,9 +224,11 @@ def test_status_hangt_labels_aan_de_regels(agent, u, state_dir):
u.check("en krijgt zijn label", owners["rejected"][0]["label"] == "Onbekend apparaat") u.check("en krijgt zijn label", owners["rejected"][0]["label"] == "Onbekend apparaat")
u.check("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3) u.check("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3)
u.check("de leerstand komt uit learning_facts", owners["learning"] is False) # De leerstand is er in 0.7.0 uit. Deze toets houdt vast dat de agent hem ook
u.check("en de statusvelden voor de teller staan erin", # niet meer dóórgeeft: een pagina die het veld nog zou lezen, zou anders een
"learningSecondsLeft" in owners and "learningUntil" in owners) # knop kunnen tonen voor iets dat niet meer bestaat.
u.check("de leerstand staat niet meer in de status",
"learning" not in owners and "learningSecondsLeft" not in owners)
with open(os.path.join(state_dir, "owners.json"), "r", encoding="utf-8") as f: with open(os.path.join(state_dir, "owners.json"), "r", encoding="utf-8") as f:
op_schijf = json.load(f) op_schijf = json.load(f)
@@ -338,18 +244,12 @@ def main():
with tempfile.TemporaryDirectory() as state_dir: with tempfile.TemporaryDirectory() as state_dir:
agent = load_agent(state_dir) agent = load_agent(state_dir)
test_venster_dat_nog_loopt(agent, u)
test_venster_dat_verlopen_is(agent, u)
test_venster_zonder_tijdslot(agent, u)
test_onleesbaar_tijdslot(agent, u)
test_leerstand_onbekend(agent, u)
test_label_zetten_en_weghalen(agent, u) test_label_zetten_en_weghalen(agent, u)
test_label_blijft_op_schijf(agent, u, state_dir) test_label_blijft_op_schijf(agent, u, state_dir)
test_label_validatie(agent, u) test_label_validatie(agent, u)
test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir) test_onleesbare_labels_zijn_geen_ramp(agent, u, state_dir)
test_set_learning_met_seconden(agent, u)
test_status_hangt_labels_aan_de_regels(agent, u, state_dir) test_status_hangt_labels_aan_de_regels(agent, u, state_dir)
# Als laatste: deze vult het labelbestand tot het plafond en laat dus geen # Als laatste: deze vult het labelbestand tot het plafond en laat dus geen
+1 -90
View File
@@ -23,7 +23,6 @@ import os
import socket import socket
import threading import threading
import time import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@@ -59,7 +58,7 @@ APP_VERSION = os.environ.get("RELAY_APP_VERSION", "")
# Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit # Wat de pagina mag vragen. Expliciet en niet doorgeven wat er binnenkomt: dit
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort # bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
# hier te stranden en niet daar. # hier te stranden en niet daar.
ALLOWED_ACTIONS = ("set-learning", "block", "allow", "forget") ALLOWED_ACTIONS = ("block", "allow", "forget")
MAX_BODY_BYTES = 4096 MAX_BODY_BYTES = 4096
MAX_OWNER_ID_LENGTH = 256 MAX_OWNER_ID_LENGTH = 256
@@ -73,11 +72,6 @@ MAX_LABEL_LENGTH = 48
# ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft. # ongeluk blijft schrijven. Ruim boven het aantal eigenaars dat iemand ooit heeft.
MAX_LABELS = 200 MAX_LABELS = 200
# Hoelang de pagina de deur voor nieuwe eigenaars openzet. Dezelfde waarde staat
# in de pagina; die stuurt hem mee en het relay-proces begrenst hem nog een keer.
# Hier staat hij omdat de agent hem moet toestaan, niet omdat hij hem kiest.
MAX_LEARNING_SECONDS = 3600
# Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig: # Eén schrijver per bestand is de afspraak, maar de agent zelf is meerdradig:
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op # ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want # hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
@@ -179,59 +173,6 @@ def write_labels(labels):
temporary.replace(LABELS_FILE) temporary.replace(LABELS_FILE)
def parse_moment(value):
"""Een ISO-tijdstip uit owners.json als datetime, of None.
Het relay-proces schrijft `new Date().toISOString()`, dus met milliseconden en
met een Z erachter. `fromisoformat` neemt die Z sinds Python 3.11; de image is
python:3-alpine en dus nieuwer. Faalt het alsnog, dan is None het antwoord en
beslist de aanroeper.
"""
if not isinstance(value, str):
return None
try:
when = datetime.fromisoformat(value)
except ValueError:
return None
if when.tzinfo is None:
return when.replace(tzinfo=timezone.utc)
return when
def learning_facts(state):
"""De leerstand zoals de pagina hem hoort te zien.
Het veld in het bestand is niet het hele antwoord: staat er een tijdstip in dat
verstreken is, dan is de deur dicht, ook al staat `learning` nog op true. Het
relay-proces ruimt dat op in zijn eigen lus, en tussen het aflopen en die ronde
zit een seconde of twee. De pagina hoort daar niet "open" te tonen.
De resterende tijd wordt hier uitgerekend en niet in de browser. Dat is met
opzet: dan telt de klok van de Umbrel en niet die van de bezoeker, en die twee
lopen niet per definitie gelijk.
"""
learning = state.get("learning")
until = state.get("learningUntil")
if learning is not True:
return {"learning": learning, "learningUntil": None, "learningSecondsLeft": None}
when = parse_moment(until)
if when is None:
# Geen tijdslot: open tot de gebruiker hem zelf sluit. Dat is de
# begintoestand van een verse installatie.
return {"learning": True, "learningUntil": None, "learningSecondsLeft": None}
resterend = (when - datetime.now(timezone.utc)).total_seconds()
if resterend <= 0:
return {"learning": False, "learningUntil": None, "learningSecondsLeft": 0}
return {
"learning": True,
"learningUntil": until,
"learningSecondsLeft": int(resterend),
}
def database_facts(): def database_facts():
try: try:
stat = DATABASE_FILE.stat() stat = DATABASE_FILE.stat()
@@ -262,11 +203,6 @@ def build_status():
state = owners["state"] or {} state = owners["state"] or {}
labels = read_labels() labels = read_labels()
# Ontbreekt de staat, dan is 'learning' onbekend en niet 'false'. De pagina
# hoort dat verschil te tonen: onbekend is een reden om te kijken, uit is een
# keuze. learning_facts() geeft None door zoals het binnenkwam.
learning = learning_facts(state)
return { return {
"version": APP_VERSION or None, "version": APP_VERSION or None,
"relay": { "relay": {
@@ -276,9 +212,6 @@ def build_status():
}, },
"owners": { "owners": {
"problem": owners["problem"], "problem": owners["problem"],
"learning": learning["learning"],
"learningUntil": learning["learningUntil"],
"learningSecondsLeft": learning["learningSecondsLeft"],
"allowed": met_label( "allowed": met_label(
[ [
entry entry
@@ -321,28 +254,6 @@ def valid_command(payload):
if action not in ALLOWED_ACTIONS: if action not in ALLOWED_ACTIONS:
return None, "onbekende actie" return None, "onbekende actie"
if action == "set-learning":
value = payload.get("value")
if not isinstance(value, bool):
return None, "waarde moet true of false zijn"
seconds = payload.get("seconds")
if seconds is None:
# Zonder tijdslot: open tot de gebruiker hem zelf sluit. Dat pad blijft
# bestaan voor een verse installatie, waar een venster van twee minuten
# zou aflopen terwijl je nog aan het koppelen bent.
return {"action": action, "value": value}, None
# isinstance(True, int) is in Python waar, dus een boolean zou hier als
# aantal seconden doorglippen. Vandaar de uitsluiting.
if isinstance(seconds, bool) or not isinstance(seconds, int):
return None, "seconds moet een heel getal zijn"
if seconds <= 0 or seconds > MAX_LEARNING_SECONDS:
return None, "seconds valt buiten het toegestane bereik"
if value is not True:
return None, "seconds hoort alleen bij openzetten"
return {"action": action, "value": value, "seconds": seconds}, None
owner_id = payload.get("ownerId") owner_id = payload.get("ownerId")
if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH: if not isinstance(owner_id, str) or not owner_id or len(owner_id) > MAX_OWNER_ID_LENGTH:
return None, "ontbrekende of te lange ownerId" return None, "ontbrekende of te lange ownerId"
+1 -1
View File
@@ -42,7 +42,7 @@ set -eu
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost. # want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
# Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je # Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je
# VERSION, dan ook het manifest. # VERSION, dan ook het manifest.
VERSION="0.6.0" VERSION="0.7.0"
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke # 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 # image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
+104 -139
View File
@@ -431,6 +431,20 @@ body {
color: var(--accent); color: var(--accent);
} }
/* De regel onder het adres: hoe het van buiten heet. Geen kader eromheen, want
het is uitleg bij de waarde erboven en geen tweede waarde. */
.note {
margin: 0.7rem 0 0;
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-sec);
}
.note code {
font-family: var(--font-mono);
font-size: 0.95em;
color: var(--accent);
}
/* ── Lijstregels ────────────────────────────────────────────────────────── */ /* ── Lijstregels ────────────────────────────────────────────────────────── */
.list-item { .list-item {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
@@ -462,6 +476,13 @@ body {
.btn:hover { background: var(--neutral-bg); } .btn:hover { background: var(--neutral-bg); }
.btn:active { transform: scale(0.97); } .btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.35; cursor: not-allowed; } .btn:disabled { opacity: 0.35; cursor: not-allowed; }
/* Bevroren terwijl er een opdracht onderweg is. Alleen de lijsten, want alleen
daar staan knoppen die iets veranderen, en de kaders erboven horen gewoon te
blijven bijwerken. Geen verschuiving en geen overlay: dit verandert niets aan
de afmetingen, en dat is precies waarom het de melding bovenaan vervangt. */
body.is-busy .list-item { opacity: 0.55; }
body.is-busy .list-item button { cursor: progress; }
.btn-solid { background: var(--accent); color: #fff; border-color: transparent; } .btn-solid { background: var(--accent); color: #fff; border-color: transparent; }
.btn-solid:hover { background: var(--accent); opacity: 0.88; } .btn-solid:hover { background: var(--accent); opacity: 0.88; }
.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); } .btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); }
@@ -710,28 +731,17 @@ body {
</div> </div>
</div> </div>
<!-- Het tijdvenster voor nieuwe eigenaars. De twee knoppen staan in de kop <!-- Hoeveel eigenaars er op een beslissing wachten.
en verschijnen bij hover over dit kader. Tot 0.6.0 stond hier het tijdvenster voor nieuwe eigenaars, met een
knop om het twee minuten open te zetten. Dat is eruit in 0.7.0: er is
De teller loopt in het relay-proces en niet hier: een teller in de nog één weg naar binnen, en dat is de wachtlijst hieronder. Twee wegen
browser verdwijnt als je het tabblad sluit, en dan blijft de deur open was dubbelop, en het venster was de zwakste van de twee omdat het
staan zonder dat iemand dat ziet. Wat de pagina doet is de resterende iederéén toeliet die er toevallig in verbond. -->
tijd van de agent aftellen tussen twee rondes. -->
<div class="card"> <div class="card">
<div class="card-head"> <div class="card-head"><h2 class="t-h3">Waiting</h2></div>
<h2 class="t-h3">New owners</h2>
<!-- "Open" en niet "Open 2 min": de twee minuten staan op de onderregel,
waar er ruimte voor is. Met de lange tekst erin brak de kop van dit
kader over twee regels en stond de waarde lager dan in de drie
kaders ernaast. -->
<div class="hover-only">
<button class="btn btn-solid hidden" id="learn-open" type="button">Open</button>
<button class="btn btn-danger hidden" id="learn-stop" type="button">Close</button>
</div>
</div>
<div class="stat-body"> <div class="stat-body">
<div class="stat-val text unknown" id="learning-state">unknown</div> <div class="stat-val text unknown" id="waiting-state">unknown</div>
<div class="stat-sub" id="learning-sub"></div> <div class="stat-sub" id="waiting-sub"></div>
</div> </div>
</div> </div>
<!-- De statuswidget staat RECHTS en niet links (keuze gebruiker, <!-- De statuswidget staat RECHTS en niet links (keuze gebruiker,
@@ -759,14 +769,20 @@ body {
<div id="allowed"></div> <div id="allowed"></div>
</div> </div>
<!-- Geweigerd en geblokkeerd in één kader, op verzoek van de gebruiker <!-- Wachtend en geblokkeerd in één kader, op verzoek van de gebruiker
(30-08-2026). Het waren twee kaders, en het onderscheid ertussen is voor (30-08-2026). Het waren twee kaders, en het onderscheid ertussen is voor
de gebruiker geen verschil in wat hij ermee doet: allebei zijn ze de gebruiker geen verschil in wat hij ermee doet: allebei zijn ze
buitengesloten en allebei kun je ze toelaten. Dat het bij de een een buitengesloten en allebei kun je ze toelaten. Dat het bij de een een
eerdere keuze van jou was en bij de ander een poging van buiten, staat als eerdere keuze van jou was en bij de ander een poging van buiten, staat als
badge op de regel. --> badge op de regel.
"Waiting list" en niet "Refused owners" sinds 0.7.0, op voorstel van de
gebruiker. Dat is wat het is: kandidaten die op een beslissing wachten, en
sinds het tijdvenster eruit is, is dit de enige weg naar binnen. Het veld
heet in owners.json nog `rejected`; hernoemen zou een migratie zijn voor
een woord dat niemand ziet. -->
<div class="card col-6"> <div class="card col-6">
<div class="card-head"><h2 class="t-h3">Refused owners</h2></div> <div class="card-head"><h2 class="t-h3">Waiting list</h2></div>
<div id="refused"></div> <div id="refused"></div>
</div> </div>
@@ -776,6 +792,15 @@ body {
<div class="card col-full"> <div class="card col-full">
<div class="card-head"><h2 class="t-h3">Relay address</h2></div> <div class="card-head"><h2 class="t-h3">Relay address</h2></div>
<div class="url-box" id="relay-url">unknown</div> <div class="url-box" id="relay-url">unknown</div>
<!-- De tweede regel is er sinds 0.7.0. Het adres hierboven werkt binnen je
eigen netwerk; van buiten loopt het via een reverse proxy, en dan is de
vorm anders én is dit de meest gemaakte fout: het schema wordt wss://
en de poort valt weg, want de proxy luistert op 443. -->
<p class="note">
That address works inside your own network. Through a reverse proxy with a certificate it
becomes <code>wss://your.domain</code>, <strong>without</strong> a port number: the proxy
listens on 443 and forwards to this one.
</p>
</div> </div>
</div> </div>
@@ -833,14 +858,15 @@ body {
<h3>Who may use it</h3> <h3>Who may use it</h3>
<p> <p>
What this app adds is the part a self-hosted relay needs and a public one does not: control What this app adds is the part a self-hosted relay needs and a public one does not: control
over who may use it. Opening the window for new owners accepts and remembers whoever connects over who may use it. Nobody gets in by default. An owner that connects for the first time is
while it is open, so open it only while you pair a device of your own. One app can use more turned away and lands on the <strong>waiting list</strong> with its owner id, and you decide
than one owner id, so wait until nothing new appears before you close it again. there whether it may stay. One app can use more than one owner id, so expect more than one
row per device.
</p> </p>
<p> <p>
An owner that is refused syncs nothing at all, and nothing of it is stored: the relay turns An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay
the connection away before it starts. Its device keeps everything locally and retries, so turns the connection away before it starts. Its device keeps everything locally and retries,
allowing it later still brings the whole history across. so allowing it later still brings the whole history across, and you do not have to be quick.
</p> </p>
<p> <p>
Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to
@@ -854,12 +880,6 @@ body {
(function () { (function () {
'use strict'; 'use strict';
// Hoelang de deur open gaat als je op de knop drukt. Twee minuten, op verzoek
// van de gebruiker (30-08-2026). Deze waarde gaat mee in de opdracht; de agent
// en het relay-proces begrenzen hem allebei nog een keer, want dit is de kant
// die van buiten komt.
var LEARNING_SECONDS = 120;
var root = document.documentElement; var root = document.documentElement;
// Eén opdracht tegelijk: de agent weigert een tweede zolang de eerste nog in de // Eén opdracht tegelijk: de agent weigert een tweede zolang de eerste nog in de
@@ -885,12 +905,6 @@ body {
// een class-attribuut die er ook om opmaakredenen kan staan. // een class-attribuut die er ook om opmaakredenen kan staan.
var refusal = false; var refusal = false;
// De resterende seconden van het tijdvenster, zoals de agent ze doorgaf, en het
// moment waarop dat was. Daartussen telt de pagina zelf af, zodat de teller
// loopt in plaats van elke vijf seconden te verspringen.
var learningLeft = null;
var learningLeftAt = 0;
// ── Hulpjes ─────────────────────────────────────────────────────────────── // ── Hulpjes ───────────────────────────────────────────────────────────────
function el(id) { return document.getElementById(id); } function el(id) { return document.getElementById(id); }
@@ -951,12 +965,6 @@ body {
return Math.round(hours / 24) + ' days ago'; return Math.round(hours / 24) + ' days ago';
} }
function formatClock(seconds) {
var whole = Math.max(0, Math.round(seconds));
var minutes = Math.floor(whole / 60);
var rest = whole % 60;
return minutes + ':' + (rest < 10 ? '0' : '') + rest;
}
function shorten(ownerId) { function shorten(ownerId) {
if (typeof ownerId !== 'string') { return 'unknown'; } if (typeof ownerId !== 'string') { return 'unknown'; }
@@ -1045,19 +1053,39 @@ body {
hide('alert-notice'); hide('alert-notice');
} }
// Bevriezen in plaats van een melding bovenaan.
//
// Tot 0.6.0 verscheen bij élke klik "Sent. The relay applies changes within a
// few seconds." in de balk bovenaan. Die balk staat in de gewone stroom van de
// pagina, dus alles eronder schoof omlaag en bij het verdwijnen weer omhoog.
// Gemeld door de gebruiker op 09-09-2026, en terecht: dat is een melding over
// het verzendmechanisme en niet over wat jij deed. Wat je deed, zie je aan de
// lijsten zodra de relay de opdracht heeft opgepakt.
//
// In plaats daarvan gaan de knoppen even op slot. Dat verschuift niets, het
// toont dat er iets loopt, en het voorkomt een tweede klik terwijl de eerste
// nog onderweg is. De foutmeldingen blijven wél in de balk staan: die zeggen
// iets wat je nergens anders ziet, en die hóórt de pagina in de weg te leggen.
function setFrozen(frozen) {
document.body.classList.toggle('is-busy', frozen);
var knoppen = document.querySelectorAll('.list-item button');
for (var i = 0; i < knoppen.length; i += 1) { knoppen[i].disabled = frozen; }
}
function send(command) { function send(command) {
if (busy) { return; } if (busy) { return; }
busy = true; busy = true;
setFrozen(true);
clearNotice(); clearNotice();
// De melding is met opzet kort van leven: de ronde hieronder haalt hem weg post('api/command', command).then(function () {
// zodra het relay-proces de opdracht heeft opgepakt, en dan is de nieuwe stand // Verversen en pas daarná ontdooien: de ronde bouwt de lijsten opnieuw op,
// in de kaders zelf te zien. Blijft hij staan, dan heeft de relay hem nog // en tot dat gebeurd is staat de oude stand er nog. Ontdooien vóór de
// niet gezien, en dán is er iets te melden. // ronde zou een klikbare knop opleveren die nog bij de vorige stand hoort.
post('api/command', command, function () { window.setTimeout(function () {
show('alert-notice', 'Sent. The relay applies changes within a few seconds.', 'alert-neutral'); refresh();
}).then(function () { busy = false;
busy = false; setFrozen(false);
window.setTimeout(refresh, 2500); }, 2500);
}); });
} }
@@ -1069,13 +1097,6 @@ body {
post('api/label', { ownerId: ownerId, label: label }).then(refresh); post('api/label', { ownerId: ownerId, label: label }).then(refresh);
} }
el('learn-open').addEventListener('click', function () {
send({ action: 'set-learning', value: true, seconds: LEARNING_SECONDS });
});
el('learn-stop').addEventListener('click', function () {
send({ action: 'set-learning', value: false });
});
// ── Een regel in een lijst ──────────────────────────────────────────────── // ── Een regel in een lijst ────────────────────────────────────────────────
// `kind` is 'allowed', 'blocked' of 'rejected'. Dat bepaalt de badge en welke // `kind` is 'allowed', 'blocked' of 'rejected'. Dat bepaalt de badge en welke
// knoppen er staan; de rest van de regel is voor alle drie hetzelfde. // knoppen er staan; de rest van de regel is voor alle drie hetzelfde.
@@ -1128,7 +1149,7 @@ body {
var tags = document.createElement('div'); var tags = document.createElement('div');
tags.className = 'owner-tags'; tags.className = 'owner-tags';
if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); } if (kind === 'blocked') { tags.appendChild(badge('Blocked', 'badge-neg')); }
if (kind === 'rejected') { tags.appendChild(badge('Turned away', 'badge-neutral')); } if (kind === 'rejected') { tags.appendChild(badge('Waiting', 'badge-neutral')); }
row.appendChild(tags); row.appendChild(tags);
var actions = document.createElement('div'); var actions = document.createElement('div');
@@ -1284,66 +1305,6 @@ body {
}); });
} }
// ── Het tijdvenster ───────────────────────────────────────────────────────
function drawLearning(owners) {
var open = el('learn-open');
var stop = el('learn-stop');
var node = el('learning-state');
if (owners.learning === true) {
// Met een tijdslot toont de widget de teller; zonder tijdslot alleen dat hij
// openstaat. Dat verschil is echt en hoort zichtbaar te zijn: "open" zonder
// teller sluit zichzelf niet.
if (typeof learningLeft === 'number') {
put('learning-state', formatClock(remainingSeconds()));
el('learning-sub').textContent = 'closes on its own';
} else {
put('learning-state', 'Open');
el('learning-sub').textContent = 'stays open until you close it';
}
node.classList.remove('bad');
node.classList.add('ok');
open.classList.remove('hidden');
open.textContent = 'Restart';
stop.classList.remove('hidden');
return;
}
node.classList.remove('ok', 'bad');
if (owners.learning === false) {
put('learning-state', 'Closed');
// Hier staat wat de knop niet zegt: hoe lang hij open gaat. Dat is de reden
// dat de knop "Open" mag heten en niet "Open 2 min" hoeft te heten.
el('learning-sub').textContent = 'opens for two minutes at a time';
open.classList.remove('hidden');
open.textContent = 'Open';
stop.classList.add('hidden');
return;
}
// Onbekend is geen synonym voor uit: uit is een keuze, onbekend is een reden
// om te kijken. Dat verschil hoort de pagina te tonen.
put('learning-state', null);
el('learning-sub').textContent = 'the relay has not reported this yet';
open.classList.add('hidden');
stop.classList.add('hidden');
}
function remainingSeconds() {
if (typeof learningLeft !== 'number') { return 0; }
return Math.max(0, learningLeft - (Date.now() - learningLeftAt) / 1000);
}
// Eén keer per seconde de teller bijwerken. Alleen de teller: de rest van de
// pagina komt uit de ronde hieronder, die elke vijf seconden loopt.
window.setInterval(function () {
if (typeof learningLeft !== 'number') { return; }
var node = el('learning-state');
if (node.classList.contains('ok')) {
node.textContent = formatClock(remainingSeconds());
}
}, 1000);
// ── De ronde ────────────────────────────────────────────────────────────── // ── De ronde ──────────────────────────────────────────────────────────────
function refresh() { function refresh() {
fetch('api/status', { cache: 'no-store' }).then(function (response) { fetch('api/status', { cache: 'no-store' }).then(function (response) {
@@ -1380,26 +1341,19 @@ body {
put('db-modified', formatAgo(status.database.modified)); put('db-modified', formatAgo(status.database.modified));
el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to'; el('db-modified-sub').textContent = formatTime(status.database.modified) || 'never written to';
// ws:// en niet http://, en dat is geen schoonheidsfoutje: de relay is een
// WebSocket-server en spreekt nooit HTTP tegen een cliënt. Er stond http://
// tot 0.7.0, en dat kostte op 09-09-2026 een ronde zoeken bij het koppelen
// van de testcliënt.
el('relay-url').textContent = el('relay-url').textContent =
'http://' + window.location.hostname + ':' + status.relay.publicPort; 'ws://' + window.location.hostname + ':' + status.relay.publicPort;
el('relay-url').classList.remove('unknown'); el('relay-url').classList.remove('unknown');
// Het tijdvenster. De resterende tijd komt van de agent, want die rekent met
// de klok van de Umbrel en niet met die van de bezoeker.
if (typeof status.owners.learningSecondsLeft === 'number'
&& status.owners.learningSecondsLeft > 0) {
learningLeft = status.owners.learningSecondsLeft;
learningLeftAt = Date.now();
} else {
learningLeft = null;
}
drawLearning(status.owners);
// De lijsten, tenzij er iemand aan een label bezig is. // De lijsten, tenzij er iemand aan een label bezig is.
if (editing === null) { if (editing === null) {
fillList('allowed', [ fillList('allowed', [
{ kind: 'allowed', entries: status.owners.allowed } { kind: 'allowed', entries: status.owners.allowed }
], 'None yet. Open the window above and connect your app.'); ], 'None yet. Connect your app; it will show up in the waiting list.');
fillList('refused', [ fillList('refused', [
{ kind: 'blocked', entries: status.owners.blocked }, { kind: 'blocked', entries: status.owners.blocked },
@@ -1407,9 +1361,20 @@ body {
], 'None.'); ], 'None.');
} }
// De teller met wachtenden. Dit is de plek waar tot 0.6.0 het tijdvenster
// stond; nu telt hij wie er op een beslissing wacht, want dat is sinds
// 0.7.0 de enige weg naar binnen.
var wachtend = (status.owners.rejected || []).length;
put('waiting-state', String(wachtend));
el('waiting-state').classList.remove('unknown');
el('waiting-sub').textContent = wachtend === 0
? 'nobody is asking to get in'
: (wachtend === 1 ? 'one owner wants in; allow it below if it is yours'
: 'owners want in; allow the ones you recognise below');
if (status.owners.problem === 'onleesbaar') { if (status.owners.problem === 'onleesbaar') {
show('alert-owners', 'The list of owners could not be read and was moved aside. New owners ' show('alert-owners', 'The list of owners could not be read and was moved aside. Everyone is '
+ 'are refused until you open the window above again.'); + 'on the waiting list again; allow the owners you recognise below.');
} else { } else {
hide('alert-owners'); hide('alert-owners');
} }
+6 -28
View File
@@ -18,7 +18,7 @@ import { installPolyfills } from '@evolu/common/polyfills';
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs'; import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
import { mkdirSync } from 'node:fs'; import { mkdirSync } from 'node:fs';
import { applyCommand, decideOwner, expireLearning } from './policy.js'; import { applyCommand, decideOwner } from './policy.js';
import { readState, takeCommand, writeState } from './store.js'; import { readState, takeCommand, writeState } from './store.js';
installPolyfills(); installPolyfills();
@@ -58,8 +58,8 @@ let dirty = start.status === 'new';
if (start.status === 'corrupt') { if (start.status === 'corrupt') {
console.log( console.log(
'[warn] owners.json was unreadable and has been moved aside. New owners are refused until you ' + '[warn] owners.json was unreadable and has been moved aside. Everyone is on the waiting list ' +
'enable learning again on the status page.', 'again; allow the owners you recognise on the status page.',
); );
} }
@@ -100,27 +100,8 @@ 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(() => { setInterval(() => {
pollCommands(); pollCommands();
closeExpiredLearning();
flush(); flush();
}, 2000).unref(); }, 2000).unref();
@@ -166,16 +147,13 @@ await runMain({ ...createRelayDeps(), console })(
if (decision.changed) markDirty(); if (decision.changed) markDirty();
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)'; const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
if (decision.reason === 'learned') { if (decision.allowed) {
console.log(`[info] learned a new owner ${short}; one app can use more than one, so keep ` +
'learning open until nothing new shows up');
} else if (decision.allowed) {
announceOnce(`ok:${short}`, `[info] owner ${short} connected`); announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
} else { } else {
announceOnce( announceOnce(
`no:${short}`, `no:${short}`,
`[info] refused owner ${short} (${decision.reason}); allow it on the status page if it is ` + `[info] owner ${short} is on the waiting list (${decision.reason}); allow it on the ` +
'yours', 'status page if it is yours',
); );
} }
return decision.allowed; return decision.allowed;
+40 -146
View File
@@ -2,6 +2,26 @@
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een // Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
// onbekende eigenaar. // onbekende eigenaar.
// //
// **Er is sinds 0.7.0 (09-09-2026) precies één weg naar binnen: de wachtlijst.**
// Een onbekende eigenaar wordt geweigerd en onthouden; op de statuspagina staat
// hij met zijn `OwnerId` erbij en daar laat je hem toe. De cliënt verbindt
// vanzelf opnieuw en is dan binnen.
//
// Tot 0.6.0 was er daarnaast een getimede leerstand die de eerstvolgende
// onbekende eigenaars toeliet. Die is eruit op voorstel van de gebruiker, en de
// reden is dat het dubbelop was: allebei de wegen vragen iemand die bij de app
// kan, en het venster is de zwakste van de twee omdat het iederéén toelaat die
// er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek
// wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je
// eigen `OwnerId` nergens kon aflezen, en die reden verviel toen de weigerlijst
// dat id ging tonen.
//
// De velden `learning` en `learningUntil` worden nog wél gelézen, en dat is met
// opzet: `STATE_VERSION` verhogen zou `normalizeState` een bestaand bestand
// laten afwijzen, en dan schuift `store.js` de allowlist van een werkende
// installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
// Ze worden gelezen, genegeerd en niet teruggeschreven.
//
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De // Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een // aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie // nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
@@ -14,9 +34,14 @@
export const STATE_VERSION = 1; export const STATE_VERSION = 1;
// Hoeveel geweigerde eigenaars we onthouden om op de pagina te tonen. Er zit een // Hoeveel wachtenden we onthouden om op de pagina te tonen. Er zit een grens op
// grens op omdat een onbekende die blijft proberen anders het bestand vol // omdat een onbekende die blijft proberen anders het bestand vol schrijft: elke
// schrijft: elke poging is een eigenaar-id dat hij zelf verzint. // poging is een eigenaar-id dat hij zelf verzint.
//
// Het veld heet op schijf nog `rejected` en dat blijft zo. Op de pagina heet het
// sinds 0.7.0 de wachtlijst, want dat is wat het is: kandidaten die op een
// beslissing wachten. De naam in het bestand hernoemen zou een migratie zijn, en
// die is het niet waard voor een woord dat niemand ziet.
export const MAX_REJECTED = 20; export const MAX_REJECTED = 20;
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van // Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
@@ -24,29 +49,8 @@ export const MAX_REJECTED = 20;
// verder geen aannames over de vorm. // verder geen aannames over de vorm.
export const MAX_OWNER_ID_LENGTH = 256; 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 = () => ({ export const createEmptyState = () => ({
version: STATE_VERSION, 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: [], owners: [],
rejected: [], rejected: [],
}); });
@@ -56,63 +60,6 @@ const isUsableOwnerId = (value) =>
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId); 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. * Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
* *
@@ -123,9 +70,13 @@ export const expireLearning = (state, now) => {
export const normalizeState = (raw) => { export const normalizeState = (raw) => {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null; if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
if (raw.version !== STATE_VERSION) return null; if (raw.version !== STATE_VERSION) return null;
if (typeof raw.learning !== 'boolean') return null;
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null; if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
// `learning` en `learningUntil` mogen er nog in staan en worden overgeslagen.
// Een bestand van 0.6.0 of ouder heeft ze, en dat bestand hoort gewoon te
// lezen; zie de kop van dit bestand voor waarom hier geen versieverhoging
// tegenover staat.
const owners = []; const owners = [];
for (const owner of raw.owners) { for (const owner of raw.owners) {
if (owner === null || typeof owner !== 'object') return null; if (owner === null || typeof owner !== 'object') return null;
@@ -151,20 +102,10 @@ export const normalizeState = (raw) => {
}); });
} }
// Ontbrekend of onleesbaar wordt `null`, en dat betekent "geen tijdslot". Dat return { version: STATE_VERSION, owners, rejected };
// 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) => { const rememberWaiting = (rejected, ownerId, now) => {
const existing = rejected.find((entry) => entry.id === ownerId); const existing = rejected.find((entry) => entry.id === ownerId);
if (existing) { if (existing) {
return rejected.map((entry) => return rejected.map((entry) =>
@@ -205,15 +146,13 @@ export const decideOwner = (state, ownerId, now) => {
}; };
} }
if (isLearningOpen(state, now)) { // Onbekend: weigeren en op de wachtlijst zetten. Dat is sinds 0.7.0 de enige
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }]; // uitkomst voor een onbekende, en daarmee ook de enige weg naar binnen: op de
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true }; // statuspagina staat hij met zijn `OwnerId` erbij en daar laat je hem toe.
}
return { return {
allowed: false, allowed: false,
state: { ...state, rejected: rememberRejected(state.rejected, ownerId, now) }, state: { ...state, rejected: rememberWaiting(state.rejected, ownerId, now) },
reason: 'not-learning', reason: 'waiting',
changed: true, changed: true,
}; };
}; };
@@ -233,51 +172,6 @@ export const applyCommand = (state, command, now) => {
if (command === null || typeof command !== 'object') return unchanged('malformed-command'); if (command === null || typeof command !== 'object') return unchanged('malformed-command');
switch (command.action) { switch (command.action) {
case 'set-learning': {
if (typeof command.value !== 'boolean') return unchanged('malformed-command');
// 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': { case 'block': {
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command'); if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner'); if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
+3 -3
View File
@@ -59,7 +59,7 @@ services:
# store hoort er een multi-arch index-digest met arm64 in; zie het masterplan # store hoort er een multi-arch index-digest met arm64 in; zie het masterplan
# Publicatie-Relay. # Publicatie-Relay.
relay: relay:
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.6.0@sha256:4dc56b8a35a1b79ca6e6a731eb520e20d05c25ad6bfc6c44b3e328f6f08b5b54 image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.0
restart: on-failure restart: on-failure
# Als node en niet als root, zoals de image tot 0.5.6 zelf al deed met USER. # Als node en niet als root, zoals de image tot 0.5.6 zelf al deed met USER.
# Nu de image ook nginx en de agent draagt, staat er geen USER meer in en # Nu de image ook nginx en de agent draagt, staat er geen USER meer in en
@@ -96,7 +96,7 @@ services:
# zelf leegmaakt. Twee processen die in dezelfde allowlist schrijven is een # zelf leegmaakt. Twee processen die in dezelfde allowlist schrijven is een
# wedloop die je een keer per jaar treft en dan niet kunt reproduceren. # wedloop die je een keer per jaar treft en dan niet kunt reproduceren.
agent: agent:
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.6.0@sha256:4dc56b8a35a1b79ca6e6a731eb520e20d05c25ad6bfc6c44b3e328f6f08b5b54 image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.0
restart: on-failure restart: on-failure
command: command:
- python3 - python3
@@ -125,7 +125,7 @@ services:
# nginx met de pagina, uit dezelfde image. Geen mounts: pagina, configuratie en # nginx met de pagina, uit dezelfde image. Geen mounts: pagina, configuratie en
# icoon zitten in de image, en de pagina heeft geen eigen staat. # icoon zitten in de image, en de pagina heeft geen eigen staat.
server: server:
image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.6.0@sha256:4dc56b8a35a1b79ca6e6a731eb520e20d05c25ad6bfc6c44b3e328f6f08b5b54 image: sc.kamenier-hamer.nl/sysop/evolu-relay:0.7.0
restart: on-failure restart: on-failure
command: command:
- nginx - nginx
+17 -1
View File
@@ -11,7 +11,7 @@ manifestVersion: 1
id: whatsnext-evolu-relay id: whatsnext-evolu-relay
category: files category: files
name: Evolu Relay name: Evolu Relay
version: "0.6.0" version: "0.7.0"
tagline: Encrypted sync and backup for your local-first apps tagline: Encrypted sync and backup for your local-first apps
description: >- description: >-
Local-first apps keep your data on your own device and work whether or not you have a Local-first apps keep your data on your own device and work whether or not you have a
@@ -43,6 +43,22 @@ description: >-
your app at the address shown there. Away from home you will need a way in, such as Tailscale your app at the address shown there. Away from home you will need a way in, such as Tailscale
or a reverse proxy with your own domain. or a reverse proxy with your own domain.
releaseNotes: >- releaseNotes: >-
There is now one way in: the waiting list. An owner that connects for the first time is turned away and
shows up there with its owner id, and you decide whether it may stay. The timed window that let in
whoever connected within two minutes is gone; it did the same job less carefully, and both ways need
someone at this page anyway. An owner you allow now leaves the waiting list instead of staying in both
panels at once.
Two smaller things. Clicking a button no longer pushes the page down with a notice; the buttons freeze
until the change has landed, and only real problems still get a message. And the relay address now
reads ws:// instead of http://, with a line about what it becomes behind a reverse proxy: the relay
speaks WebSocket and never HTTP, so the old address could not work anywhere.
Everything in 0.6.0:
The whole app now ships as one image: the relay, the status page and the small helper behind it. Until The whole app now ships as one image: the relay, the status page and the small helper behind it. Until
now only the relay did, and the other two were mounted into stock images from the app folder. Nothing now only the relay did, and the other two were mounted into stock images from the app folder. Nothing
changes in what the app does; the app data folder now holds only your data, and the icon updates along changes in what the app does; the app data folder now holds only your data, and the icon updates along