Het relay-programma: de relay van Evolu met onze eigen allowlist
tools/evolu-relay/src/ bevat nu een eigen programma dat createRelay uit @evolu/nodejs aanroept met de twee terugroepfuncties die het bedoelde uitbreidpunt vormen. De relay zelf komt uit npm en wordt niet nagebouwd of aangepast; de opstartvolgorde is overgenomen uit apps/relay/src/index.ts van Evolu zelf. De opzet is drie bestanden met een harde scheiding, en die scheiding is de reden dat hier iets te testen valt. policy.js bevat het beleid als pure functies: geen bestanden, geen netwerk, geen klok. store.js is de enige plek met schijf erin. index.js doet niets anders dan lezen, doorgeven en opslaan. Het beleid: de eerste eigenaar die zich meldt wordt geleerd, een schakelaar bepaalt of er nog nieuwe bij mogen, en een eigenaar is te blokkeren, alsnog toe te laten of te vergeten. Geweigerde pogingen worden onthouden voor de pagina, afgekapt op twintig, want elke poging is een id dat de ander zelf verzint. Een onleesbaar owners.json wordt opzij geschoven en de app gaat dan dicht in plaats van open: we weten dan niet wie er toegelaten was, en met de leerstand aan zou de eerstvolgende die verbindt de nieuwe eigenaar worden. Met test: node tests/test_limiter.mjs, 60 toetsen, en de toetsen gaan over de guards en niet over het gelukkige pad. De beslissende regel is muteertest gedaan en de juiste toets viel om: een geblokkeerde eigenaar mag er niet alsnog in doordat de leerstand aanstaat. Het bestand is .mjs omdat de repo-root geen package.json heeft en een .js daar als CommonJS gelezen zou worden. build.sh bouwt niet langer de repo van Trezor maar onze eigen Dockerfile, dus git is er niet meer voor nodig en de pin zit nu in package.json. De image is node:24-slim en niet alpine, want better-sqlite3 heeft binaries voor glibc en niet voor musl. Er is nog geen package-lock.json; het script waarschuwt daarvoor en het staat als taak. Onderweg bleek een aanname van vanmiddag fout: de 1 MB uit de gepubliceerde image geldt per schrijfactie en niet per eigenaar. Er valt dus geen labelgeschiedenis tegenaan te lopen. Dat is rechtgezet in het plan en in de naslag, en het getal is overgenomen als bewuste keuze met RELAY_MAX_WRITE_BYTES ernaast. Wat er niet in zit en ook niet gegokt is: data per eigenaar wissen. Het beleid kan een eigenaar vergeten, maar zijn berichten staan in de SQLite van de relay, en dat is andermans schema. Tests: alle vier groen (32, 54, 39 en 60 goed, 0 fout). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// De relay van Evolu, met onze eigen toegangscontrole eromheen.
|
||||
//
|
||||
// Wat dit programma NIET is: een eigen relay. `createRelay` uit `@evolu/nodejs`
|
||||
// doet al het werk; het enige dat wij toevoegen zijn de twee terugroepfuncties
|
||||
// die het bedoelde uitbreidpunt vormen. Trezor doet in `createEvoluRelay.ts`
|
||||
// precies hetzelfde, alleen kijken hun functies in een Postgres.
|
||||
//
|
||||
// De opstartvolgorde en de `runMain`-aanroep zijn overgenomen uit
|
||||
// `apps/relay/src/index.ts` van Evolu zelf. Wijk daar niet van af zonder reden:
|
||||
// dat bestand is de referentie voor hoe deze relay hoort te starten.
|
||||
//
|
||||
// Zie het plan Umbrelapp, PLAN.md §4g, en Referenties/Upstream-evolu-relay.md §8.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { createConsole, createConsoleFormatter } from '@evolu/common';
|
||||
import { installPolyfills } from '@evolu/common/polyfills';
|
||||
import { createRelay, createRelayDeps, runMain } from '@evolu/nodejs';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
import { applyCommand, decideOwner } from './policy.js';
|
||||
import { readState, takeCommand, writeState } from './store.js';
|
||||
|
||||
installPolyfills();
|
||||
|
||||
// Zoals bovenstrooms: de database komt in een map `data` naast het programma,
|
||||
// zodat het volume van de container een voorspelbaar pad heeft. Onze allowlist
|
||||
// komt in diezelfde map te staan, naast de database en niet erin.
|
||||
mkdirSync('data', { recursive: true });
|
||||
process.chdir('data');
|
||||
|
||||
const dataDir = process.cwd();
|
||||
|
||||
const port = Number.parseInt(process.env.RELAY_PORT ?? '4000', 10);
|
||||
|
||||
// Per schrijfactie, niet per eigenaar. Dat is hoe `isOwnerWithinQuota` bij Evolu
|
||||
// bedoeld is: de relay geeft door hoeveel bytes déze actie nodig heeft. Trezor
|
||||
// telt er zelf een totaal bij op uit hun eigen tabel; wij hebben die tabel niet en
|
||||
// doen dat dus niet.
|
||||
//
|
||||
// De standaard is dezelfde 1 MB als de gepubliceerde image, en dat is een bewuste
|
||||
// keuze en geen overname: een enkele labelwijziging is klein, dus een schrijfactie
|
||||
// die hierboven uitkomt is eerder een fout of misbruik dan normaal gebruik.
|
||||
const maxWriteBytes = Number.parseInt(process.env.RELAY_MAX_WRITE_BYTES ?? '1048576', 10);
|
||||
|
||||
const console = createConsole({
|
||||
formatter: createConsoleFormatter()({ timestampFormat: 'relative' }),
|
||||
});
|
||||
|
||||
// ── De staat ──────────────────────────────────────────────────────────────────
|
||||
// In het geheugen gezaghebbend, op schijf bewaard. `isOwnerAllowed` zit in het
|
||||
// verbindingspad, dus daar wordt niet naar schijf geschreven; dat doet de
|
||||
// achtergrondlus hieronder.
|
||||
|
||||
const start = readState(dataDir, new Date().toISOString());
|
||||
let state = start.state;
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
const markDirty = () => {
|
||||
dirty = true;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (!dirty) return;
|
||||
try {
|
||||
writeState(dataDir, state);
|
||||
dirty = false;
|
||||
} catch (error) {
|
||||
// Niet fataal: de relay blijft werken met de staat in het geheugen. Wel
|
||||
// melden, want na een herstart is de allowlist dan verouderd.
|
||||
console.log(`[warn] could not write owners.json: ${String(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Opdrachten van de statuspagina ────────────────────────────────────────────
|
||||
// De pagina schrijft een bestand, dit proces past het toe. Geen Docker-socket en
|
||||
// geen tweede container die in dezelfde staat schrijft; dezelfde afspraak als bij
|
||||
// Electrum Gate.
|
||||
|
||||
const pollCommands = () => {
|
||||
const command = takeCommand(dataDir);
|
||||
if (command === null) return;
|
||||
|
||||
const result = applyCommand(state, command, new Date().toISOString());
|
||||
if (result.error !== null) {
|
||||
console.log(`[warn] command refused: ${result.error}`);
|
||||
return;
|
||||
}
|
||||
if (result.changed) {
|
||||
state = result.state;
|
||||
markDirty();
|
||||
console.log(`[info] command applied: ${command.action}`);
|
||||
}
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
pollCommands();
|
||||
flush();
|
||||
}, 2000).unref();
|
||||
|
||||
const stopCleanly = () => {
|
||||
flush();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', stopCleanly);
|
||||
process.on('SIGINT', stopCleanly);
|
||||
|
||||
// ── De relay ──────────────────────────────────────────────────────────────────
|
||||
|
||||
await runMain({ ...createRelayDeps(), console })(
|
||||
createRelay({
|
||||
port,
|
||||
|
||||
isOwnerAllowed: (ownerId) => {
|
||||
let decision;
|
||||
try {
|
||||
decision = decideOwner(state, ownerId, new Date().toISOString());
|
||||
} catch (error) {
|
||||
// Bij twijfel weigeren. Een fout in het beleid mag geen open deur worden.
|
||||
console.log(`[warn] policy failed, refusing owner: ${String(error)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
state = decision.state;
|
||||
if (decision.changed) markDirty();
|
||||
if (decision.reason === 'learned') {
|
||||
console.log('[info] learned a new owner; turn learning off on the status page when done');
|
||||
}
|
||||
return decision.allowed;
|
||||
},
|
||||
|
||||
isOwnerWithinQuota: (_ownerId, requiredBytes) => requiredBytes <= maxWriteBytes,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,207 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// 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.
|
||||
const owners = known
|
||||
? state.owners.map((owner) =>
|
||||
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
|
||||
)
|
||||
: [...state.owners, { id: command.ownerId, allowed: true, firstSeen: now, 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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// De staat op schijf: de allowlist en het opdrachtbestand van de statuspagina.
|
||||
//
|
||||
// Dit is de enige plek met bestanden erin. Het beleid zelf staat in `policy.js`
|
||||
// en weet hier niets van.
|
||||
//
|
||||
// Waar het staat: onder `${APP_DATA_DIR}/data/`, náást de database van de relay
|
||||
// en niet erin. De relay bezit zijn eigen SQLite en daar schrijven wij niet in.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
import { createEmptyState, normalizeState } from './policy.js';
|
||||
|
||||
export const stateFileName = 'owners.json';
|
||||
export const commandFileName = 'command.json';
|
||||
|
||||
/**
|
||||
* Leest de allowlist.
|
||||
*
|
||||
* Drie uitkomsten, en de derde is de interessante:
|
||||
* - `new`: er is nog niets, dus een verse staat met de leerstand aan;
|
||||
* - `ok`: gelezen en begrepen;
|
||||
* - `corrupt`: er stond iets wat we niet begrijpen. Dan schuiven we het bestand
|
||||
* opzij en beginnen we met de leerstand **uit**.
|
||||
*
|
||||
* Die laatste keuze is bewust en volgt de regel die deze store al kent van
|
||||
* Electrum Gate: bij twijfel weigert de app. Een onleesbaar bestand betekent dat
|
||||
* we niet weten wie er toegelaten was; met de leerstand aan zou de eerstvolgende
|
||||
* die verbindt stilzwijgend de nieuwe eigenaar worden, en dat hoeft je eigen
|
||||
* apparaat niet te zijn. Dan liever een app die niemand toelaat en dat op de
|
||||
* pagina toont.
|
||||
*/
|
||||
export const readState = (dataDir, now) => {
|
||||
const file = join(dataDir, stateFileName);
|
||||
if (!existsSync(file)) return { state: createEmptyState(), status: 'new' };
|
||||
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const state = parsed === null ? null : normalizeState(parsed);
|
||||
if (state !== null) return { state, status: 'ok' };
|
||||
|
||||
// Opzij schuiven en niet weggooien: als dit ooit gebeurt, wil je kunnen zien
|
||||
// wat erin stond.
|
||||
const aside = `${file}.corrupt-${now.replace(/[:.]/g, '-')}`;
|
||||
try {
|
||||
renameSync(file, aside);
|
||||
} catch {
|
||||
// Lukt het opzij schuiven niet, dan gaan we alsnog dicht. De melding hieronder
|
||||
// is dan het enige dat de gebruiker heeft, en dat is nog steeds beter dan
|
||||
// stilzwijgend iemand toelaten.
|
||||
}
|
||||
return { state: { ...createEmptyState(), learning: false }, status: 'corrupt' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Schrijft de allowlist. Eerst naar een tijdelijk bestand en dan hernoemen, zodat
|
||||
* een onderbroken schrijfactie geen half bestand achterlaat: hernoemen binnen
|
||||
* dezelfde map is atomair.
|
||||
*/
|
||||
export const writeState = (dataDir, state) => {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
const file = join(dataDir, stateFileName);
|
||||
const temporary = `${file}.tmp`;
|
||||
writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
||||
renameSync(temporary, file);
|
||||
};
|
||||
|
||||
/**
|
||||
* Haalt een opdracht van de statuspagina op en verwijdert het bestand meteen.
|
||||
*
|
||||
* Verwijderen vóór het uitvoeren is met opzet: een opdracht die het proces laat
|
||||
* struikelen mag niet bij elke ronde opnieuw worden geprobeerd.
|
||||
*/
|
||||
export const takeCommand = (dataDir) => {
|
||||
const file = join(dataDir, commandFileName);
|
||||
if (!existsSync(file)) return null;
|
||||
|
||||
let raw = null;
|
||||
try {
|
||||
raw = readFileSync(file, 'utf8');
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(file);
|
||||
} catch {
|
||||
// Niets aan te doen; de volgende ronde probeert het opnieuw.
|
||||
}
|
||||
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** De map waarin de staat hoort, afgeleid van het pad van de relay-database. */
|
||||
export const dataDirectoryFor = (relayDatabaseFile) => dirname(relayDatabaseFile);
|
||||
Reference in New Issue
Block a user