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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user