// ═══════════════════════════════════════════════════════════════════════════════ // 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 // er binnenkomt, en zulke logica hoort te testen te zijn zonder relay en zonder // Docker. // // De relay van Evolu roept `isOwnerAllowed(ownerId)` aan. Wij beantwoorden die // vraag hier; `src/index.js` doet niets anders dan lezen, doorgeven en opslaan. // ═══════════════════════════════════════════════════════════════════════════════ export const STATE_VERSION = 1; // 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 // buiten en belandt in een bestand. Daarom een bovengrens en een typecontrole, en // verder geen aannames over de vorm. export const MAX_OWNER_ID_LENGTH = 256; export const createEmptyState = () => ({ version: STATE_VERSION, owners: [], rejected: [], }); const isUsableOwnerId = (value) => typeof value === 'string' && value.length > 0 && value.length <= MAX_OWNER_ID_LENGTH; const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId); /** * Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt. * * Bewust streng: een half begrepen bestand is gevaarlijker dan geen bestand, * want dan denkt de app dat er een allowlist is terwijl die leeg is. De * aanroeper beslist wat er bij `null` gebeurt, en die keuze staat in `store.js`. */ export const normalizeState = (raw) => { if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null; if (raw.version !== STATE_VERSION) 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; if (!isUsableOwnerId(owner.id)) return null; if (typeof owner.allowed !== 'boolean') return null; owners.push({ id: owner.id, allowed: owner.allowed, firstSeen: typeof owner.firstSeen === 'string' ? owner.firstSeen : null, lastSeen: typeof owner.lastSeen === 'string' ? owner.lastSeen : null, }); } const rejected = []; for (const entry of raw.rejected) { if (entry === null || typeof entry !== 'object') return null; if (!isUsableOwnerId(entry.id)) return null; rejected.push({ id: entry.id, firstSeen: typeof entry.firstSeen === 'string' ? entry.firstSeen : null, lastSeen: typeof entry.lastSeen === 'string' ? entry.lastSeen : null, attempts: Number.isInteger(entry.attempts) && entry.attempts > 0 ? entry.attempts : 1, }); } return { version: STATE_VERSION, owners, rejected }; }; const rememberWaiting = (rejected, ownerId, now) => { const existing = rejected.find((entry) => entry.id === ownerId); if (existing) { return rejected.map((entry) => entry.id === ownerId ? { ...entry, lastSeen: now, attempts: entry.attempts + 1 } : entry, ); } // Nieuwste vooraan, en afkappen op MAX_REJECTED. De oudste valt eraf; dat is // hier de juiste kant om te verliezen, want een aanvaller die blijft proberen // duwt dan zijn eigen eerdere pogingen weg en niet de allowlist. return [{ id: ownerId, firstSeen: now, lastSeen: now, attempts: 1 }, ...rejected].slice( 0, MAX_REJECTED, ); }; /** * Beslist of deze eigenaar erin mag, en geeft de staat terug zoals hij daarna is. * * @returns {{allowed: boolean, state: object, reason: string, changed: boolean}} */ export const decideOwner = (state, ownerId, now) => { if (!isUsableOwnerId(ownerId)) { // Geen bruikbaar id: weigeren en niets onthouden. Onthouden zou hier juist // de weg openzetten om het bestand vol te schrijven met rommel. return { allowed: false, state, reason: 'invalid-owner-id', changed: false }; } const known = findOwner(state, ownerId); if (known) { const seen = { ...known, lastSeen: now }; const owners = state.owners.map((owner) => (owner.id === ownerId ? seen : owner)); return { allowed: known.allowed, state: { ...state, owners }, reason: known.allowed ? 'known-allowed' : 'known-blocked', changed: known.lastSeen !== now, }; } // 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: rememberWaiting(state.rejected, ownerId, now) }, reason: 'waiting', changed: true, }; }; /** * Voert een opdracht van de statuspagina uit. * * De pagina schrijft een opdrachtbestand en dit proces past hem toe; er is geen * Docker-socket en geen tweede container die in dezelfde staat schrijft. Dat is * dezelfde afspraak als bij Electrum Gate. * * @returns {{state: object, changed: boolean, error: string|null}} */ export const applyCommand = (state, command, now) => { const unchanged = (error) => ({ state, changed: false, error }); if (command === null || typeof command !== 'object') return unchanged('malformed-command'); switch (command.action) { case 'block': { if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command'); if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner'); const owners = state.owners.map((owner) => owner.id === command.ownerId ? { ...owner, allowed: false, lastSeen: owner.lastSeen } : owner, ); return { state: { ...state, owners }, changed: true, error: null }; } case 'allow': { if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command'); const known = findOwner(state, command.ownerId); // Ook een eigenaar die alleen als geweigerde poging bekend is, mag hiermee // alsnog toegelaten worden. Dat is de knop naast zo'n regel op de pagina. // // De geschiedenis van die pogingen gaat mee. Dat leek een detail tot het // op 28-08-2026 misging: de pagina meldde daarna "first seen" op het moment // van de klik, terwijl die eigenaar al twintig minuten aan de deur stond. // Juist bij een eigenaar die je alsnog toelaat wil je kunnen terugzien // sinds wanneer hij er is. const attempted = state.rejected.find((entry) => entry.id === command.ownerId); const owners = known ? state.owners.map((owner) => owner.id === command.ownerId ? { ...owner, allowed: true } : owner, ) : [ ...state.owners, { id: command.ownerId, allowed: true, firstSeen: attempted && attempted.firstSeen ? attempted.firstSeen : now, lastSeen: attempted ? attempted.lastSeen : null, }, ]; return { state: { ...state, owners, rejected: state.rejected.filter((entry) => entry.id !== command.ownerId), }, changed: true, error: null, }; } case 'forget': { if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command'); const inOwners = Boolean(findOwner(state, command.ownerId)); const inRejected = state.rejected.some((entry) => entry.id === command.ownerId); if (!inOwners && !inRejected) return unchanged('unknown-owner'); return { state: { ...state, owners: state.owners.filter((owner) => owner.id !== command.ownerId), rejected: state.rejected.filter((entry) => entry.id !== command.ownerId), }, changed: true, error: null, }; } default: return unchanged('unknown-action'); } };