Drie dingen die vandaag misgingen of ontbraken, en die alle drie uit gebruik kwamen en niet uit nadenken. De allowlist logde niets als een bekende eigenaar terugkwam en niets als er een geweigerd werd. Daardoor stond er een tweede eigenaar drieentwintig keer aan de deur zonder dat het log er iets over zei; het stond alleen op de pagina. Nu is er een regel per eigenaar per keer dat het proces draait, dus ook bij terugkomst en bij weigering, met een bovengrens omdat een weigering een id bevat dat de ander zelf verzint. Een geweigerde schrijfactie logt voortaan hoeveel bytes er gevraagd werden en welke grens gold, met de naam van de variabele erbij. Allow gooide de geschiedenis weg. Een eigenaar die je alsnog toeliet kreeg "first seen" op het moment van de klik, terwijl hij al twintig minuten stond te kloppen. Juist daar wil je zien sinds wanneer. Met een toets erop. En de knoppen stonden niet recht: id, gegevens en knoppen stonden naast elkaar, dus de lengte van de gegevensregel bepaalde of de rij afbrak. Een eigenaar met een last seen duwde zijn knoppen naar de volgende regel en dus naar links. Nu staan naam en gegevens onder elkaar in een kolom en worden de knoppen altijd naar rechts geduwd. Inhoudelijk het belangrijkste: "de eerste eigenaar wint" is te smal. Een enkele app kan meer dan een eigenaar gebruiken, en de pagina adviseerde de deur te sluiten zodra de eerste binnen was. Daarmee sluit je je eigen tweede eigenaar buiten. De tekst zegt nu te wachten tot er een minuut niets nieuws meer bij komt. Image naar 0.3.0. De digest van 0.2.0 is uit de compose gehaald in plaats van blijven staan: die zou naar de vorige image wijzen terwijl de tag iets anders belooft. Tests: alle vier groen (34, 54, 39 en 61 goed, 0 fout). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
223 lines
9.0 KiB
JavaScript
223 lines
9.0 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
|
// onbekende eigenaar.
|
|
//
|
|
// 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 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.
|
|
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,
|
|
// 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,
|
|
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 (typeof raw.learning !== 'boolean') return null;
|
|
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
|
|
|
|
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, learning: raw.learning, owners, rejected };
|
|
};
|
|
|
|
const rememberRejected = (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,
|
|
};
|
|
}
|
|
|
|
if (state.learning) {
|
|
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }];
|
|
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true };
|
|
}
|
|
|
|
return {
|
|
allowed: false,
|
|
state: { ...state, rejected: rememberRejected(state.rejected, ownerId, now) },
|
|
reason: 'not-learning',
|
|
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 'set-learning': {
|
|
if (typeof command.value !== 'boolean') return unchanged('malformed-command');
|
|
if (state.learning === command.value) return { state, changed: false, error: null };
|
|
return { state: { ...state, learning: command.value }, changed: true, error: null };
|
|
}
|
|
|
|
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');
|
|
}
|
|
};
|