Files
UmbrelApps/tools/evolu-relay/src/policy.js
T
HarmenandClaude Opus 5.5 85d85a1295 UmbrelApps: community app store whatsnext
Electrum Gate, Evolu Relay en Atreides Assets, met de image-recepten in tools/ en de tests in tests/. Begonnen zonder voorgeschiedenis op 09-10-2026.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:40:25 +02:00

240 lines
10 KiB
JavaScript

// ═══════════════════════════════════════════════════════════════════════════════
// 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');
}
};