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:
@@ -157,18 +157,19 @@ python tests/test_relay_agent.py
|
||||
|
||||
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
|
||||
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`,
|
||||
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
|
||||
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
|
||||
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 agent zélf uitrekent in plaats van doorgeeft, en daarmee de enige plek in dit bestand waar hij eigen
|
||||
logica heeft. De HTTP-laag zit er niet in: die handlers zijn zonder socket niet aan te roepen.
|
||||
De tweede toetst de agent: de labels. Dat is het enige dat hij zélf uitrekent in plaats van doorgeeft, en
|
||||
daarmee de enige plek in dat bestand waar hij eigen logica heeft. De HTTP-laag zit er niet in: die handlers
|
||||
zijn zonder socket niet aan te roepen.
|
||||
|
||||
### 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 |
|
||||
| `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
|
||||
tabblad dat je sluit, sluit de deur niet. En `decideOwner` kijkt naar `isLearningOpen(state, now)` en niet
|
||||
naar het veld `learning`: de lus die een verlopen venster opruimt loopt elke twee seconden, en in dat gat
|
||||
zou een onbekende alsnog binnenkomen.
|
||||
**Er is één weg naar binnen: de wachtlijst.** Een onbekende eigenaar wordt geweigerd en onthouden; op de
|
||||
statuspagina laat je hem toe. De getimede leerstand die daarnaast bestond is er in 0.7.0 (09-09-2026) uit,
|
||||
op voorstel van de gebruiker: allebei de wegen vragen iemand die bij de app kan, dus het was dubbelop, en
|
||||
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
|
||||
WebSocket-upgrade gesteld.** Gevolg om te kennen voordat je iets belooft op de pagina: **blokkeren werkt pas
|
||||
|
||||
@@ -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
|
||||
> 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
|
||||
|
||||
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,
|
||||
> 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
|
||||
|
||||
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
|
||||
|
||||
- [ ] **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
|
||||
van de gebruiker; in beide kaders en over de groepen heen. Alleen de pagina, dus geen nieuwe image.
|
||||
**Nog nakijken op het apparaat.**
|
||||
|
||||
+169
-277
@@ -8,19 +8,20 @@
|
||||
//
|
||||
// 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
|
||||
// een guard, en dat is precies wat hieronder getoetst wordt: een geblokkeerde
|
||||
// eigenaar mag niet alsnog binnenkomen doordat de leerstand aanstaat.
|
||||
// een guard, en dat is precies wat hieronder de meeste aandacht krijgt.
|
||||
//
|
||||
// **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 {
|
||||
MAX_LEARNING_SECONDS,
|
||||
MAX_OWNER_ID_LENGTH,
|
||||
MAX_REJECTED,
|
||||
applyCommand,
|
||||
createEmptyState,
|
||||
decideOwner,
|
||||
expireLearning,
|
||||
isLearningOpen,
|
||||
normalizeState,
|
||||
} from '../tools/evolu-relay/src/policy.js';
|
||||
|
||||
@@ -36,338 +37,229 @@ const toets = (omschrijving, voorwaarde) => {
|
||||
console.log(`FOUT: ${omschrijving}`);
|
||||
};
|
||||
|
||||
const NU = '2026-08-28T12:00:00.000Z';
|
||||
const LATER = '2026-08-28T13: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 NU = '2026-09-09T12:00:00.000Z';
|
||||
const LATER = '2026-09-09T13:00:00.000Z';
|
||||
const EIGEN = 'owner-van-de-gebruiker';
|
||||
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();
|
||||
toets('een verse staat staat in de leerstand', vers.learning === true);
|
||||
const leeg = createEmptyState();
|
||||
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);
|
||||
toets('de eerste eigenaar wordt toegelaten', eerste.allowed === true);
|
||||
toets('en onthouden als toegelaten', eerste.state.owners.length === 1);
|
||||
toets('met de reden dat hij geleerd is', eerste.reason === 'learned');
|
||||
|
||||
const nogEens = decideOwner(eerste.state, EIGEN, LATER);
|
||||
toets('dezelfde eigenaar blijft toegelaten', nogEens.allowed === true);
|
||||
toets('en komt er niet dubbel in', nogEens.state.owners.length === 1);
|
||||
toets('de reden is nu dat hij bekend is', nogEens.reason === 'known-allowed');
|
||||
const uitkomst = decideOwner(leeg, VREEMD, NU);
|
||||
toets('een onbekende eigenaar wordt geweigerd', uitkomst.allowed === false);
|
||||
toets('met reden "waiting"', uitkomst.reason === 'waiting');
|
||||
toets('en belandt op de wachtlijst', uitkomst.state.rejected.length === 1);
|
||||
toets('met zijn id erbij', uitkomst.state.rejected[0].id === VREEMD);
|
||||
toets('en één poging', uitkomst.state.rejected[0].attempts === 1);
|
||||
toets('en de staat is veranderd', uitkomst.changed === true);
|
||||
toets('en hij staat niet stiekem bij de eigenaars', uitkomst.state.owners.length === 0);
|
||||
}
|
||||
|
||||
// ── De leerstand uit ──────────────────────────────────────────────────────────
|
||||
// ── De leerstand is er echt uit ───────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
const dicht = applyCommand(geleerd, { action: 'set-learning', value: false }, NU).state;
|
||||
// Dit is de toets die de verwijdering van 0.7.0 vasthoudt. Een staatsbestand
|
||||
// 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);
|
||||
toets('met de leerstand uit mag de bekende eigenaar er nog steeds in', eigen.allowed === true);
|
||||
const metVenster = {
|
||||
...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);
|
||||
toets('maar een onbekende wordt geweigerd', vreemd.allowed === false);
|
||||
toets('de weigering wordt onthouden voor de pagina', vreemd.state.rejected.length === 1);
|
||||
toets('en hij belandt niet in de allowlist', vreemd.state.owners.length === 1);
|
||||
const opdracht = applyCommand(createEmptyState(), { action: 'set-learning', value: true }, NU);
|
||||
toets('set-learning is een onbekende actie geworden', opdracht.error === 'unknown-action');
|
||||
toets('en verandert niets', opdracht.changed === false);
|
||||
}
|
||||
|
||||
// ── De guard die er het meest toe doet ────────────────────────────────────────
|
||||
|
||||
{
|
||||
const geleerd = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
const geblokkeerd = applyCommand(geleerd, { action: 'block', ownerId: EIGEN }, NU).state;
|
||||
// Een geblokkeerde eigenaar mag er nooit in. Dat was tot 0.6.0 het gevaar dat
|
||||
// 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);
|
||||
toets(
|
||||
'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');
|
||||
const zelfdeMoment = decideOwner(metEigenaar(EIGEN, true), EIGEN, NU);
|
||||
toets('twee keer op hetzelfde moment verandert niets', zelfdeMoment.changed === false);
|
||||
}
|
||||
|
||||
// ── Onbruikbare invoer ────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const vers = createEmptyState();
|
||||
for (const rommel of ['', null, undefined, 42, {}, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
|
||||
const uitkomst = decideOwner(vers, rommel, NU);
|
||||
toets(`een onbruikbaar id (${typeof rommel}) wordt geweigerd`, uitkomst.allowed === false);
|
||||
toets('en wordt niet onthouden', uitkomst.state.owners.length === 0);
|
||||
toets('ook niet als geweigerde poging', uitkomst.state.rejected.length === 0);
|
||||
const leeg = createEmptyState();
|
||||
for (const onzin of ['', null, 42, {}, [], 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
|
||||
const uitkomst = decideOwner(leeg, onzin, NU);
|
||||
toets(`${JSON.stringify(onzin)} wordt geweigerd`, uitkomst.allowed === false);
|
||||
// Onthouden zou hier juist de weg openzetten om het bestand vol te schrijven.
|
||||
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 };
|
||||
for (let i = 0; i < MAX_REJECTED + 5; i += 1) {
|
||||
staat = decideOwner(staat, `indringer-${i}`, NU).state;
|
||||
let staat = createEmptyState();
|
||||
for (let i = 0; i < MAX_REJECTED + 10; i += 1) {
|
||||
staat = decideOwner(staat, `vreemde-${i}`, NU).state;
|
||||
}
|
||||
toets('de lijst met weigeringen wordt afgekapt', staat.rejected.length === MAX_REJECTED);
|
||||
toets('en de nieuwste staat vooraan', staat.rejected[0].id === `indringer-${MAX_REJECTED + 4}`);
|
||||
toets('de wachtlijst wordt afgekapt', staat.rejected.length === MAX_REJECTED);
|
||||
// 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);
|
||||
toets('een herhaalde poging telt op', herhaling.state.rejected[0].attempts === 2);
|
||||
toets('en voegt geen tweede regel toe', herhaling.state.rejected.length === MAX_REJECTED);
|
||||
const nogmaals = decideOwner(staat, `vreemde-${MAX_REJECTED + 9}`, LATER);
|
||||
toets('een tweede poging telt op', nogmaals.state.rejected[0].attempts === 2);
|
||||
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('de leerstand gaat uit', uit.state.learning === false);
|
||||
toets('en dat is een wijziging', uit.changed === true);
|
||||
toets('toelaten lukt', toegelaten.error === null);
|
||||
toets('en hij staat bij de eigenaars', toegelaten.state.owners[0]?.id === VREEMD);
|
||||
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('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',
|
||||
);
|
||||
toets('en daarna mag hij erin', decideOwner(toegelaten.state, VREEMD, LATER).allowed === true);
|
||||
}
|
||||
|
||||
// ── Het tijdvenster voor nieuwe eigenaars ─────────────────────────────────────
|
||||
//
|
||||
// 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.
|
||||
// ── De overige opdrachten van de pagina ───────────────────────────────────────
|
||||
|
||||
{
|
||||
const vers = createEmptyState();
|
||||
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 staat = metEigenaar(EIGEN, true);
|
||||
|
||||
const open = applyCommand(
|
||||
vers,
|
||||
{ action: 'set-learning', value: true, seconds: TWEE_MINUTEN },
|
||||
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(
|
||||
'na het venster wordt hij geweigerd, ook al staat learning nog op true',
|
||||
erna.allowed === false,
|
||||
);
|
||||
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 bekend = decideOwner(binnen.state, EIGEN, LATER);
|
||||
toets('een geleerde eigenaar mag er na het venster nog steeds in', bekend.allowed === true);
|
||||
}
|
||||
|
||||
{
|
||||
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
|
||||
// erfde een volgende 'open zonder seconden' een venster dat niet gevraagd is.
|
||||
const dicht = applyCommand(open, { action: 'set-learning', value: false }, BINNEN);
|
||||
toets('vroegtijdig sluiten kan', dicht.state.learning === false);
|
||||
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);
|
||||
toets('openzetten zonder seconden blijft bestaan', zonderSlot.state.learning === true);
|
||||
toets('en heeft dan geen tijdslot', zonderSlot.state.learningUntil === null);
|
||||
}
|
||||
|
||||
// Wat de agent hoort tegen te houden, houdt dit bestand ook tegen. Twee zeven, en
|
||||
// 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(
|
||||
`een onbruikbaar aantal seconden wordt geweigerd: ${JSON.stringify(opdracht.seconds)}`,
|
||||
uitkomst.error === 'malformed-command',
|
||||
);
|
||||
toets('en verandert niets', uitkomst.changed === false);
|
||||
}
|
||||
const geblokkeerd = applyCommand(staat, { action: 'block', ownerId: EIGEN }, LATER);
|
||||
toets('blokkeren lukt', geblokkeerd.error === null);
|
||||
toets('en zet allowed op false', geblokkeerd.state.owners[0].allowed === false);
|
||||
|
||||
toets(
|
||||
'de bovengrens zelf mag wel',
|
||||
applyCommand(vers, { action: 'set-learning', value: true, seconds: MAX_LEARNING_SECONDS }, NU)
|
||||
.error === null,
|
||||
'blokkeren van een onbekende wordt geweigerd',
|
||||
applyCommand(staat, { action: 'block', ownerId: VREEMD }, LATER).error === 'unknown-owner',
|
||||
);
|
||||
|
||||
// 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',
|
||||
const vergeten = applyCommand(staat, { action: 'forget', ownerId: EIGEN }, LATER);
|
||||
toets('vergeten lukt', vergeten.error === null);
|
||||
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',
|
||||
);
|
||||
toets('met een onleesbare klok wordt de opdracht geweigerd', kapotteKlok.error === 'malformed-command');
|
||||
toets('en blijft de staat zoals hij was', kapotteKlok.changed === false);
|
||||
|
||||
const gewacht = decideOwner(createEmptyState(), VREEMD, NU).state;
|
||||
const weg = applyCommand(gewacht, { action: 'forget', ownerId: VREEMD }, LATER);
|
||||
toets('iemand van de wachtlijst vergeten lukt ook', weg.error === null);
|
||||
toets('en dan is de wachtlijst leeg', weg.state.rejected.length === 0);
|
||||
|
||||
for (const onzin of [null, 42, 'tekst', {}, { action: 'iets' }]) {
|
||||
const uitkomst = applyCommand(staat, onzin, NU);
|
||||
toets(`${JSON.stringify(onzin)} als opdracht verandert niets`, uitkomst.changed === false);
|
||||
toets(`${JSON.stringify(onzin)} als opdracht geeft een fout`, uitkomst.error !== null);
|
||||
}
|
||||
|
||||
// Het opruimen. Correctheid hangt hier niet aan, want `isLearningOpen` weigert al;
|
||||
// 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);
|
||||
|
||||
for (const onzin of ['', null, 42, 'x'.repeat(MAX_OWNER_ID_LENGTH + 1)]) {
|
||||
toets(
|
||||
'nog een keer opruimen doet niets',
|
||||
expireLearning(opgeruimd.state, LATER).changed === false,
|
||||
`block met ownerId ${JSON.stringify(onzin)} wordt geweigerd`,
|
||||
applyCommand(staat, { action: 'block', ownerId: onzin }, NU).error === 'malformed-command',
|
||||
);
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
// ── Wat er van schijf komt ────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
const goedeStaat = decideOwner(createEmptyState(), EIGEN, NU).state;
|
||||
const rondgang = normalizeState(JSON.parse(JSON.stringify(goedeStaat)));
|
||||
toets('een eigen staat overleeft de rondgang door JSON', rondgang !== null);
|
||||
toets('met de eigenaar er nog in', rondgang.owners[0].id === EIGEN);
|
||||
toets('null is geen staat', normalizeState(null) === null);
|
||||
toets('een array is geen staat', normalizeState([]) === null);
|
||||
toets('een verkeerde versie is geen staat', normalizeState({ version: 2, owners: [], rejected: [] }) === null);
|
||||
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 = [
|
||||
null,
|
||||
'tekst',
|
||||
[],
|
||||
{ version: 999, learning: true, owners: [], rejected: [] },
|
||||
{ version: 1, learning: 'ja', owners: [], rejected: [] },
|
||||
{ version: 1, learning: true, owners: {}, rejected: [] },
|
||||
{ version: 1, learning: true, owners: [{ id: '', allowed: true }], rejected: [] },
|
||||
{ version: 1, learning: true, owners: [{ id: EIGEN }], rejected: [] },
|
||||
];
|
||||
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: [] };
|
||||
// Dit is de toets die de installatie van de gebruiker beschermt: een bestand
|
||||
// van 0.6.0 heeft `learning` en `learningUntil` en moet gewoon lezen. Zou dat
|
||||
// afgewezen worden, 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,
|
||||
learningUntil: '2026-09-09T12:02:00.000Z',
|
||||
owners: [{ id: EIGEN, allowed: true, firstSeen: NU, lastSeen: NU }],
|
||||
rejected: [{ id: VREEMD, firstSeen: NU, lastSeen: NU, attempts: 3 }],
|
||||
};
|
||||
const gelezen = normalizeState(oud);
|
||||
toets('een bestand van vóór het tijdslot leest nog', gelezen !== null);
|
||||
toets('met de leerstand die erin stond', gelezen.learning === true);
|
||||
toets('en zonder tijdslot', gelezen.learningUntil === null);
|
||||
toets('en de eigenaar er nog in', gelezen.owners[0].id === EIGEN);
|
||||
toets('een bestand van 0.6.0 leest gewoon', gelezen !== null);
|
||||
toets('met de eigenaar erin', gelezen.owners[0].id === EIGEN);
|
||||
toets('en de wachtlijst erin', gelezen.rejected[0].attempts === 3);
|
||||
toets('en zonder de oude velden', !('learning' in gelezen) && !('learningUntil' in gelezen));
|
||||
|
||||
const metSlot = normalizeState({ ...oud, learningUntil: '2026-08-28T12:02:00.000Z' });
|
||||
toets('een geldig tijdslot blijft staan', metSlot.learningUntil === '2026-08-28T12:02:00.000Z');
|
||||
|
||||
for (const onzin of ['morgen', '', 42, {}, null]) {
|
||||
toets(
|
||||
`een onleesbaar tijdslot wordt null en geen weigering: ${JSON.stringify(onzin)}`,
|
||||
normalizeState({ ...oud, learningUntil: onzin }) !== null
|
||||
&& normalizeState({ ...oud, learningUntil: onzin }).learningUntil === null,
|
||||
'een eigenaar zonder allowed is geen staat',
|
||||
normalizeState({ version: 1, owners: [{ id: EIGEN }], rejected: [] }) === null,
|
||||
);
|
||||
toets(
|
||||
'een eigenaar met een leeg id is geen staat',
|
||||
normalizeState({ version: 1, owners: [{ id: '', allowed: true }], rejected: [] }) === 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('');
|
||||
|
||||
+10
-110
@@ -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
|
||||
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
|
||||
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;
|
||||
- **hij rekent het tijdvenster uit** dat de pagina toont. Een verlopen venster moet
|
||||
hij als dicht rapporteren, ook al staat er in owners.json nog dat de leerstand
|
||||
aan is: het relay-proces ruimt dat op zijn eigen moment op.
|
||||
Tot 0.6.0 rekende hij daarnaast het tijdvenster voor nieuwe eigenaars uit. Dat
|
||||
venster is er in 0.7.0 uit: er is nog één weg naar binnen, en dat is de wachtlijst
|
||||
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
|
||||
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")
|
||||
|
||||
|
||||
# ── 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── De opdracht met seconden ────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
|
||||
# ── De status ───────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
@@ -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("het aantal pogingen gaat mee", owners["rejected"][0]["attempts"] == 3)
|
||||
|
||||
u.check("de leerstand komt uit learning_facts", owners["learning"] is False)
|
||||
u.check("en de statusvelden voor de teller staan erin",
|
||||
"learningSecondsLeft" in owners and "learningUntil" in owners)
|
||||
# De leerstand is er in 0.7.0 uit. Deze toets houdt vast dat de agent hem ook
|
||||
# niet meer dóórgeeft: een pagina die het veld nog zou lezen, zou anders een
|
||||
# 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:
|
||||
op_schijf = json.load(f)
|
||||
@@ -338,18 +244,12 @@ def main():
|
||||
with tempfile.TemporaryDirectory() as 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_blijft_op_schijf(agent, u, state_dir)
|
||||
test_label_validatie(agent, u)
|
||||
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)
|
||||
|
||||
# Als laatste: deze vult het labelbestand tot het plafond en laat dus geen
|
||||
|
||||
@@ -23,7 +23,6 @@ import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
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
|
||||
# bestand wordt door een ander proces uitgevoerd, en een onbekende actie hoort
|
||||
# hier te stranden en niet daar.
|
||||
ALLOWED_ACTIONS = ("set-learning", "block", "allow", "forget")
|
||||
ALLOWED_ACTIONS = ("block", "allow", "forget")
|
||||
|
||||
MAX_BODY_BYTES = 4096
|
||||
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.
|
||||
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:
|
||||
# ThreadingHTTPServer geeft elk verzoek zijn eigen draad. Twee labels die op
|
||||
# hetzelfde moment binnenkomen zouden elkaar dus kunnen overschrijven, want
|
||||
@@ -179,59 +173,6 @@ def write_labels(labels):
|
||||
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():
|
||||
try:
|
||||
stat = DATABASE_FILE.stat()
|
||||
@@ -262,11 +203,6 @@ def build_status():
|
||||
state = owners["state"] or {}
|
||||
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 {
|
||||
"version": APP_VERSION or None,
|
||||
"relay": {
|
||||
@@ -276,9 +212,6 @@ def build_status():
|
||||
},
|
||||
"owners": {
|
||||
"problem": owners["problem"],
|
||||
"learning": learning["learning"],
|
||||
"learningUntil": learning["learningUntil"],
|
||||
"learningSecondsLeft": learning["learningSecondsLeft"],
|
||||
"allowed": met_label(
|
||||
[
|
||||
entry
|
||||
@@ -321,28 +254,6 @@ def valid_command(payload):
|
||||
if action not in ALLOWED_ACTIONS:
|
||||
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")
|
||||
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"
|
||||
|
||||
@@ -42,7 +42,7 @@ set -eu
|
||||
# want anders rolt umbrelOS hem niet uit. Dat heeft hier een keer een dag gekost.
|
||||
# Sinds alles in de image zit lopen ze meestal gelijk; de regel blijft: verhoog je
|
||||
# VERSION, dan ook het manifest.
|
||||
VERSION="0.6.0"
|
||||
VERSION="0.7.0"
|
||||
|
||||
# Het register staat er expres in en dit is geen smaakkwestie: umbreld haalt élke
|
||||
# image op via de Docker Engine API, dus een tag die alleen lokaal bestaat is voor
|
||||
|
||||
+103
-138
@@ -431,6 +431,20 @@ body {
|
||||
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 ────────────────────────────────────────────────────────── */
|
||||
.list-item {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
@@ -462,6 +476,13 @@ body {
|
||||
.btn:hover { background: var(--neutral-bg); }
|
||||
.btn:active { transform: scale(0.97); }
|
||||
.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:hover { background: var(--accent); opacity: 0.88; }
|
||||
.btn-danger { color: var(--neg); border-color: color-mix(in srgb, var(--neg) 45%, transparent); }
|
||||
@@ -710,28 +731,17 @@ body {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Het tijdvenster voor nieuwe eigenaars. De twee knoppen staan in de kop
|
||||
en verschijnen bij hover over dit kader.
|
||||
|
||||
De teller loopt in het relay-proces en niet hier: een teller in de
|
||||
browser verdwijnt als je het tabblad sluit, en dan blijft de deur open
|
||||
staan zonder dat iemand dat ziet. Wat de pagina doet is de resterende
|
||||
tijd van de agent aftellen tussen twee rondes. -->
|
||||
<!-- Hoeveel eigenaars er op een beslissing wachten.
|
||||
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
|
||||
nog één weg naar binnen, en dat is de wachtlijst hieronder. Twee wegen
|
||||
was dubbelop, en het venster was de zwakste van de twee omdat het
|
||||
iederéén toeliet die er toevallig in verbond. -->
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<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="card-head"><h2 class="t-h3">Waiting</h2></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-val text unknown" id="learning-state">unknown</div>
|
||||
<div class="stat-sub" id="learning-sub"></div>
|
||||
<div class="stat-val text unknown" id="waiting-state">unknown</div>
|
||||
<div class="stat-sub" id="waiting-sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- De statuswidget staat RECHTS en niet links (keuze gebruiker,
|
||||
@@ -759,14 +769,20 @@ body {
|
||||
<div id="allowed"></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
|
||||
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
|
||||
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-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>
|
||||
|
||||
@@ -776,6 +792,15 @@ body {
|
||||
<div class="card col-full">
|
||||
<div class="card-head"><h2 class="t-h3">Relay address</h2></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>
|
||||
|
||||
@@ -833,14 +858,15 @@ body {
|
||||
<h3>Who may use it</h3>
|
||||
<p>
|
||||
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
|
||||
while it is open, so open it only while you pair a device of your own. One app can use more
|
||||
than one owner id, so wait until nothing new appears before you close it again.
|
||||
over who may use it. Nobody gets in by default. An owner that connects for the first time is
|
||||
turned away and lands on the <strong>waiting list</strong> with its owner id, and you decide
|
||||
there whether it may stay. One app can use more than one owner id, so expect more than one
|
||||
row per device.
|
||||
</p>
|
||||
<p>
|
||||
An owner that is refused syncs nothing at all, and nothing of it is stored: the relay turns
|
||||
the connection away before it starts. Its device keeps everything locally and retries, so
|
||||
allowing it later still brings the whole history across.
|
||||
An owner on the waiting list syncs nothing at all, and nothing of it is stored: the relay
|
||||
turns the connection away before it starts. Its device keeps everything locally and retries,
|
||||
so allowing it later still brings the whole history across, and you do not have to be quick.
|
||||
</p>
|
||||
<p>
|
||||
Owner ids are long and meaningless on purpose. Hover a row and use <strong>Label</strong> to
|
||||
@@ -854,12 +880,6 @@ body {
|
||||
(function () {
|
||||
'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;
|
||||
|
||||
// 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.
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
@@ -951,12 +965,6 @@ body {
|
||||
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) {
|
||||
if (typeof ownerId !== 'string') { return 'unknown'; }
|
||||
@@ -1045,19 +1053,39 @@ body {
|
||||
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) {
|
||||
if (busy) { return; }
|
||||
busy = true;
|
||||
setFrozen(true);
|
||||
clearNotice();
|
||||
// De melding is met opzet kort van leven: de ronde hieronder haalt hem weg
|
||||
// zodra het relay-proces de opdracht heeft opgepakt, en dan is de nieuwe stand
|
||||
// in de kaders zelf te zien. Blijft hij staan, dan heeft de relay hem nog
|
||||
// niet gezien, en dán is er iets te melden.
|
||||
post('api/command', command, function () {
|
||||
show('alert-notice', 'Sent. The relay applies changes within a few seconds.', 'alert-neutral');
|
||||
}).then(function () {
|
||||
post('api/command', command).then(function () {
|
||||
// Verversen en pas daarná ontdooien: de ronde bouwt de lijsten opnieuw op,
|
||||
// en tot dat gebeurd is staat de oude stand er nog. Ontdooien vóór de
|
||||
// ronde zou een klikbare knop opleveren die nog bij de vorige stand hoort.
|
||||
window.setTimeout(function () {
|
||||
refresh();
|
||||
busy = false;
|
||||
window.setTimeout(refresh, 2500);
|
||||
setFrozen(false);
|
||||
}, 2500);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1069,13 +1097,6 @@ body {
|
||||
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 ────────────────────────────────────────────────
|
||||
// `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.
|
||||
@@ -1128,7 +1149,7 @@ body {
|
||||
var tags = document.createElement('div');
|
||||
tags.className = 'owner-tags';
|
||||
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);
|
||||
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
function refresh() {
|
||||
fetch('api/status', { cache: 'no-store' }).then(function (response) {
|
||||
@@ -1380,26 +1341,19 @@ body {
|
||||
put('db-modified', formatAgo(status.database.modified));
|
||||
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 =
|
||||
'http://' + window.location.hostname + ':' + status.relay.publicPort;
|
||||
'ws://' + window.location.hostname + ':' + status.relay.publicPort;
|
||||
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.
|
||||
if (editing === null) {
|
||||
fillList('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', [
|
||||
{ kind: 'blocked', entries: status.owners.blocked },
|
||||
@@ -1407,9 +1361,20 @@ body {
|
||||
], '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') {
|
||||
show('alert-owners', 'The list of owners could not be read and was moved aside. New owners '
|
||||
+ 'are refused until you open the window above again.');
|
||||
show('alert-owners', 'The list of owners could not be read and was moved aside. Everyone is '
|
||||
+ 'on the waiting list again; allow the owners you recognise below.');
|
||||
} else {
|
||||
hide('alert-owners');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { installPolyfills } from '@evolu/common/polyfills';
|
||||
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import { applyCommand, decideOwner, expireLearning } from './policy.js';
|
||||
import { applyCommand, decideOwner } from './policy.js';
|
||||
import { readState, takeCommand, writeState } from './store.js';
|
||||
|
||||
installPolyfills();
|
||||
@@ -58,8 +58,8 @@ let dirty = start.status === 'new';
|
||||
|
||||
if (start.status === 'corrupt') {
|
||||
console.log(
|
||||
'[warn] owners.json was unreadable and has been moved aside. New owners are refused until you ' +
|
||||
'enable learning again on the status page.',
|
||||
'[warn] owners.json was unreadable and has been moved aside. Everyone is on the waiting list ' +
|
||||
'again; allow the owners you recognise on the status page.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
pollCommands();
|
||||
closeExpiredLearning();
|
||||
flush();
|
||||
}, 2000).unref();
|
||||
|
||||
@@ -166,16 +147,13 @@ await runMain({ ...createRelayDeps(), console })(
|
||||
if (decision.changed) markDirty();
|
||||
|
||||
const short = typeof ownerId === 'string' ? ownerId.slice(0, 12) : '(unusable)';
|
||||
if (decision.reason === 'learned') {
|
||||
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) {
|
||||
if (decision.allowed) {
|
||||
announceOnce(`ok:${short}`, `[info] owner ${short} connected`);
|
||||
} else {
|
||||
announceOnce(
|
||||
`no:${short}`,
|
||||
`[info] refused owner ${short} (${decision.reason}); allow it on the status page if it is ` +
|
||||
'yours',
|
||||
`[info] owner ${short} is on the waiting list (${decision.reason}); allow it on the ` +
|
||||
'status page if it is yours',
|
||||
);
|
||||
}
|
||||
return decision.allowed;
|
||||
|
||||
+40
-146
@@ -2,6 +2,26 @@
|
||||
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
||||
// onbekende eigenaar.
|
||||
//
|
||||
// **Er is sinds 0.7.0 (09-09-2026) precies één weg naar binnen: de wachtlijst.**
|
||||
// Een onbekende eigenaar wordt geweigerd en onthouden; op de statuspagina staat
|
||||
// hij met zijn `OwnerId` erbij en daar laat je hem toe. De cliënt verbindt
|
||||
// vanzelf opnieuw en is dan binnen.
|
||||
//
|
||||
// Tot 0.6.0 was er daarnaast een getimede leerstand die de eerstvolgende
|
||||
// onbekende eigenaars toeliet. Die is eruit op voorstel van de gebruiker, en de
|
||||
// reden is dat het dubbelop was: allebei de wegen vragen iemand die bij de app
|
||||
// kan, en het venster is de zwakste van de twee omdat het iederéén toelaat die
|
||||
// er toevallig in verbindt. Dat weegt zwaarder nu de relay op een publiek
|
||||
// wss-adres kan staan. De oorspronkelijke reden voor de leerstand was dat je je
|
||||
// eigen `OwnerId` nergens kon aflezen, en die reden verviel toen de weigerlijst
|
||||
// dat id ging tonen.
|
||||
//
|
||||
// De velden `learning` en `learningUntil` worden nog wél gelézen, en dat is met
|
||||
// opzet: `STATE_VERSION` verhogen zou `normalizeState` een bestaand bestand
|
||||
// laten afwijzen, en dan schuift `store.js` de allowlist van een werkende
|
||||
// installatie opzij en gaat de deur dicht voor eigenaars die er al in stonden.
|
||||
// Ze worden gelezen, genegeerd en niet teruggeschreven.
|
||||
//
|
||||
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
|
||||
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
|
||||
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
|
||||
@@ -14,9 +34,14 @@
|
||||
|
||||
export const STATE_VERSION = 1;
|
||||
|
||||
// Hoeveel geweigerde eigenaars we onthouden om op de pagina te tonen. Er zit een
|
||||
// grens op omdat een onbekende die blijft proberen anders het bestand vol
|
||||
// schrijft: elke poging is een eigenaar-id dat hij zelf verzint.
|
||||
// Hoeveel wachtenden we onthouden om op de pagina te tonen. Er zit een grens op
|
||||
// omdat een onbekende die blijft proberen anders het bestand vol schrijft: elke
|
||||
// poging is een eigenaar-id dat hij zelf verzint.
|
||||
//
|
||||
// Het veld heet op schijf nog `rejected` en dat blijft zo. Op de pagina heet het
|
||||
// sinds 0.7.0 de wachtlijst, want dat is wat het is: kandidaten die op een
|
||||
// beslissing wachten. De naam in het bestand hernoemen zou een migratie zijn, en
|
||||
// die is het niet waard voor een woord dat niemand ziet.
|
||||
export const MAX_REJECTED = 20;
|
||||
|
||||
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
|
||||
@@ -24,29 +49,8 @@ export const MAX_REJECTED = 20;
|
||||
// verder geen aannames over de vorm.
|
||||
export const MAX_OWNER_ID_LENGTH = 256;
|
||||
|
||||
// De langste tijdvenster dat de pagina mag vragen. De pagina vraagt er twee
|
||||
// minuten; deze grens staat er voor het geval iets anders de postbus vult. Een
|
||||
// venster van een dag is geen venster meer, en dit is een toegangscontrole: bij
|
||||
// twijfel de kortere kant.
|
||||
export const MAX_LEARNING_SECONDS = 3600;
|
||||
|
||||
export const createEmptyState = () => ({
|
||||
version: STATE_VERSION,
|
||||
// Leerstand. Aan betekent: de eerstvolgende onbekende eigenaar wordt
|
||||
// toegelaten. Dit staat aan bij een verse installatie, want anders kan de
|
||||
// eigenaar zichzelf nooit aanmelden: Trezor Suite toont je `OwnerId` nergens.
|
||||
learning: true,
|
||||
// Tot wanneer de leerstand open is, als ISO-tijdstip, of `null` voor "tot je
|
||||
// hem zelf sluit". Dat tweede is de begintoestand: een verse installatie moet
|
||||
// te koppelen zijn zonder dat er iemand op tijd op een knop drukt.
|
||||
//
|
||||
// Toegevoegd zonder STATE_VERSION te verhogen, en dat is een keuze. Een
|
||||
// verhoging zou `normalizeState` het bestaande bestand laten afwijzen, en dan
|
||||
// schuift `store.js` de allowlist van een werkende installatie opzij en gaat de
|
||||
// deur dicht. Een veld bijzetten dat ontbrekend `null` betekent is niet
|
||||
// brekend: een oud bestand leest goed, en een oude relay leest een nieuw
|
||||
// bestand ook goed omdat hij het veld gewoon niet kent.
|
||||
learningUntil: null,
|
||||
owners: [],
|
||||
rejected: [],
|
||||
});
|
||||
@@ -56,63 +60,6 @@ const isUsableOwnerId = (value) =>
|
||||
|
||||
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
|
||||
|
||||
/** Het tijdstip als getal, of `NaN` als er iets onleesbaars staat. */
|
||||
const asMoment = (value) => (typeof value === 'string' ? Date.parse(value) : NaN);
|
||||
|
||||
/**
|
||||
* `now` plus een aantal seconden, als ISO-tijdstip. `null` als dat niet kan.
|
||||
*
|
||||
* Geen klok hierin: `now` komt van de aanroeper, net als bij alles in dit
|
||||
* bestand. Vandaar dat een onleesbare `now` een uitkomst heeft en geen fout: de
|
||||
* aanroeper beslist wat hij met `null` doet, en in `applyCommand` is dat de
|
||||
* opdracht weigeren. Stil "open zonder tijdslot" zou de verkeerde kant zijn.
|
||||
*/
|
||||
const addSeconds = (now, seconds) => {
|
||||
const start = asMoment(now);
|
||||
if (Number.isNaN(start)) return null;
|
||||
return new Date(start + seconds * 1000).toISOString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Staat de deur op dit moment open voor een onbekende eigenaar?
|
||||
*
|
||||
* Dit is de gezaghebbende vraag en niet het veld `learning` op zichzelf. Een
|
||||
* venster dat verlopen is, is dicht, ook al staat er in het bestand nog dat de
|
||||
* leerstand aan is: dat bestand wordt door de lus in `index.js` bijgewerkt en die
|
||||
* loopt op zijn eigen moment. De correctheid mag niet aan die lus hangen, want
|
||||
* dan zit er een gat van een seconde of twee in waarin een onbekende alsnog
|
||||
* binnenkomt. Vandaar dat `decideOwner` deze functie gebruikt en niet het veld.
|
||||
*/
|
||||
export const isLearningOpen = (state, now) => {
|
||||
if (state.learning !== true) return false;
|
||||
if (state.learningUntil === null || state.learningUntil === undefined) return true;
|
||||
|
||||
const deadline = asMoment(state.learningUntil);
|
||||
const moment = asMoment(now);
|
||||
// Onleesbaar aan één van de twee kanten: dicht. Dat is de veilige kant, en het
|
||||
// is dezelfde regel die `store.js` volgt bij een onleesbaar bestand.
|
||||
if (Number.isNaN(deadline) || Number.isNaN(moment)) return false;
|
||||
return moment < deadline;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ruimt een verlopen venster op.
|
||||
*
|
||||
* Puur opruimwerk: `isLearningOpen` weigert al vóórdat dit gebeurd is. Wat dit
|
||||
* oplevert is dat het bestand en de pagina hetzelfde zeggen als de klok, en dat
|
||||
* je op de pagina "closed" ziet in plaats van een venster dat afgelopen is.
|
||||
*
|
||||
* @returns {{state: object, changed: boolean}}
|
||||
*/
|
||||
export const expireLearning = (state, now) => {
|
||||
if (state.learning !== true) return { state, changed: false };
|
||||
if (state.learningUntil === null || state.learningUntil === undefined) {
|
||||
return { state, changed: false };
|
||||
}
|
||||
if (isLearningOpen(state, now)) return { state, changed: false };
|
||||
return { state: { ...state, learning: false, learningUntil: null }, changed: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
|
||||
*
|
||||
@@ -123,9 +70,13 @@ export const expireLearning = (state, now) => {
|
||||
export const normalizeState = (raw) => {
|
||||
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) 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;
|
||||
|
||||
// `learning` en `learningUntil` mogen er nog in staan en worden overgeslagen.
|
||||
// Een bestand van 0.6.0 of ouder heeft ze, en dat bestand hoort gewoon te
|
||||
// lezen; zie de kop van dit bestand voor waarom hier geen versieverhoging
|
||||
// tegenover staat.
|
||||
|
||||
const owners = [];
|
||||
for (const owner of raw.owners) {
|
||||
if (owner === null || typeof owner !== 'object') return null;
|
||||
@@ -151,20 +102,10 @@ export const normalizeState = (raw) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Ontbrekend of onleesbaar wordt `null`, en dat betekent "geen tijdslot". Dat
|
||||
// klinkt als de onveilige kant maar is het niet: `learning` moet daarnaast ook
|
||||
// nog `true` zijn, en dat staat in hetzelfde bestand. Een oud bestand zonder
|
||||
// dit veld hoort te lezen als de leerstand die het beschreef, en niet als een
|
||||
// venster dat meteen verlopen is.
|
||||
const learningUntil =
|
||||
typeof raw.learningUntil === 'string' && !Number.isNaN(asMoment(raw.learningUntil))
|
||||
? raw.learningUntil
|
||||
: null;
|
||||
|
||||
return { version: STATE_VERSION, learning: raw.learning, learningUntil, owners, rejected };
|
||||
return { version: STATE_VERSION, owners, rejected };
|
||||
};
|
||||
|
||||
const rememberRejected = (rejected, ownerId, now) => {
|
||||
const rememberWaiting = (rejected, ownerId, now) => {
|
||||
const existing = rejected.find((entry) => entry.id === ownerId);
|
||||
if (existing) {
|
||||
return rejected.map((entry) =>
|
||||
@@ -205,15 +146,13 @@ export const decideOwner = (state, ownerId, now) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (isLearningOpen(state, now)) {
|
||||
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }];
|
||||
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true };
|
||||
}
|
||||
|
||||
// Onbekend: weigeren en op de wachtlijst zetten. Dat is sinds 0.7.0 de enige
|
||||
// uitkomst voor een onbekende, en daarmee ook de enige weg naar binnen: op de
|
||||
// statuspagina staat hij met zijn `OwnerId` erbij en daar laat je hem toe.
|
||||
return {
|
||||
allowed: false,
|
||||
state: { ...state, rejected: rememberRejected(state.rejected, ownerId, now) },
|
||||
reason: 'not-learning',
|
||||
state: { ...state, rejected: rememberWaiting(state.rejected, ownerId, now) },
|
||||
reason: 'waiting',
|
||||
changed: true,
|
||||
};
|
||||
};
|
||||
@@ -233,51 +172,6 @@ export const applyCommand = (state, command, now) => {
|
||||
if (command === null || typeof command !== 'object') return unchanged('malformed-command');
|
||||
|
||||
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': {
|
||||
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
||||
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
|
||||
|
||||
@@ -59,7 +59,7 @@ services:
|
||||
# store hoort er een multi-arch index-digest met arm64 in; zie het masterplan
|
||||
# Publicatie-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
|
||||
# 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
|
||||
@@ -96,7 +96,7 @@ services:
|
||||
# zelf leegmaakt. Twee processen die in dezelfde allowlist schrijven is een
|
||||
# wedloop die je een keer per jaar treft en dan niet kunt reproduceren.
|
||||
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
|
||||
command:
|
||||
- python3
|
||||
@@ -125,7 +125,7 @@ services:
|
||||
# nginx met de pagina, uit dezelfde image. Geen mounts: pagina, configuratie en
|
||||
# icoon zitten in de image, en de pagina heeft geen eigen staat.
|
||||
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
|
||||
command:
|
||||
- nginx
|
||||
|
||||
@@ -11,7 +11,7 @@ manifestVersion: 1
|
||||
id: whatsnext-evolu-relay
|
||||
category: files
|
||||
name: Evolu Relay
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
tagline: Encrypted sync and backup for your local-first apps
|
||||
description: >-
|
||||
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
|
||||
or a reverse proxy with your own domain.
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user