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>
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// Het bedieningsvlak: één pagina waarop je alle eigenaars ziet en aanstuurt.
|
|
//
|
|
// Wat dit scherm wél doet staat in het plan Testclient, PLAN.md §4b. Wat het
|
|
// NIET doet, en dat is geen tekortkoming maar de poortindeling van de app: de
|
|
// leerstand openzetten, iemand blokkeren en labelen. Die lopen via de agent-API
|
|
// van Evolu Relay, en die hangt achter de inlog van umbrelOS. Zet hier dus geen
|
|
// knoppen bij die dat proberen; ze kunnen niet werken.
|
|
//
|
|
// De server luistert op localhost en nergens anders. Hier zitten mnemonics
|
|
// achter, en dit is gereedschap op je eigen machine.
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { createServer } from 'node:http';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { eigenaarUitMnemonic, nieuweEigenaar, openStore } from './client.js';
|
|
import { klopAan } from './probe.js';
|
|
|
|
const HIER = dirname(fileURLToPath(import.meta.url));
|
|
const MAX_BODY_BYTES = 4096;
|
|
const MAX_LOG = 200;
|
|
|
|
export const startUi = async ({ config, register, databasesIn }) => {
|
|
/** De open verbindingen, per eigenaar. Alleen deze module raakt hem aan. */
|
|
const open = new Map();
|
|
const log = [];
|
|
|
|
/**
|
|
* De laatste uitkomst van "Testen", per eigenaar.
|
|
*
|
|
* Hierop wordt de knop "Verbinden" uitgezet. Onbekend telt als toegestaan, en
|
|
* dat is met opzet: anders moet je eerst testen voordat je iets mag, en een
|
|
* test is zelf een poging die in de weigerlijst van de relay belandt.
|
|
*/
|
|
const laatsteTest = new Map();
|
|
|
|
/**
|
|
* Hoeveel sockets er achter elkaar mislukt zijn, per eigenaar.
|
|
*
|
|
* Evolu is local-first en blijft eindeloos opnieuw proberen. Dat is bij een
|
|
* relay die even weg is precies goed, maar bij een eigenaar die geweigerd
|
|
* wordt is elke poging een regel in de weigerlijst van de relay. Vandaar de
|
|
* teller: na een paar mislukkingen wordt er één keer getest, en bij een
|
|
* weigering gaat de verbinding eruit in plaats van door te blijven kloppen.
|
|
*/
|
|
const socketFouten = new Map();
|
|
const FOUTEN_VOOR_TEST = 3;
|
|
|
|
const meld = (tekst) => {
|
|
log.unshift({ tijd: new Date().toISOString(), tekst });
|
|
if (log.length > MAX_LOG) log.length = MAX_LOG;
|
|
console.log(`${new Date().toLocaleTimeString()} ${tekst}`);
|
|
};
|
|
|
|
const verbind = async (naam, metRelay) => {
|
|
if (open.has(naam)) return open.get(naam);
|
|
const regel = register.zoek(naam);
|
|
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
|
|
if (metRelay && !config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
|
|
|
|
const store = await openStore({
|
|
directory: databasesIn,
|
|
owner: eigenaarUitMnemonic(regel.mnemonic),
|
|
relayUrl: metRelay ? config.relayUrl : null,
|
|
onDefect: (defect) => {
|
|
meld(`${naam}: defect uit een worker, zie de terminal`);
|
|
console.error(defect);
|
|
},
|
|
onWebSocket: (bericht) => {
|
|
if (bericht.wat === 'open') {
|
|
socketFouten.set(naam, 0);
|
|
meld(`${naam}: socket open`);
|
|
return;
|
|
}
|
|
if (bericht.wat !== 'dicht') return;
|
|
|
|
const aantal = (socketFouten.get(naam) ?? 0) + 1;
|
|
socketFouten.set(naam, aantal);
|
|
if (aantal !== FOUTEN_VOOR_TEST) return;
|
|
|
|
// Precies één keer testen, bij de derde mislukking. Daarna weten we of
|
|
// dit een weigering is of een relay die even weg is, en dat scheelt de
|
|
// relay een weigerlijst vol pogingen.
|
|
meld(`${naam}: ${aantal} sockets mislukt (${bericht.detail}), wordt getest`);
|
|
void (async () => {
|
|
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
|
|
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
|
|
if (uitkomst.geaccepteerd) {
|
|
meld(`${naam}: de relay laat hem toe, dus dit is iets anders dan een weigering`);
|
|
return;
|
|
}
|
|
meld(`${naam}: geweigerd (${uitkomst.uitleg}), verbinding wordt verbroken`);
|
|
await verbreek(naam);
|
|
})();
|
|
},
|
|
});
|
|
|
|
const ingang = { store, sinds: new Date().toISOString(), metRelay };
|
|
open.set(naam, ingang);
|
|
meld(metRelay ? `${naam} verbonden met ${store.transportUrl}` : `${naam} lokaal geopend`);
|
|
return ingang;
|
|
};
|
|
|
|
const verbreek = async (naam) => {
|
|
const ingang = open.get(naam);
|
|
if (!ingang) return;
|
|
open.delete(naam);
|
|
socketFouten.delete(naam);
|
|
await ingang.store.sluit();
|
|
meld(`${naam} losgekoppeld`);
|
|
};
|
|
|
|
const status = async () => {
|
|
const eigenaars = [];
|
|
for (const regel of register.lijst()) {
|
|
const ingang = open.get(regel.naam);
|
|
let blobs = null;
|
|
if (ingang) {
|
|
try {
|
|
blobs = (await ingang.store.lees()).length;
|
|
} catch {
|
|
blobs = null;
|
|
}
|
|
}
|
|
const test = laatsteTest.get(regel.naam) ?? null;
|
|
eigenaars.push({
|
|
naam: regel.naam,
|
|
ownerId: regel.ownerId,
|
|
aangemaakt: regel.aangemaakt,
|
|
verbonden: Boolean(ingang),
|
|
metRelay: ingang?.metRelay ?? false,
|
|
sinds: ingang?.sinds ?? null,
|
|
transportUrl: ingang?.store.transportUrl ?? null,
|
|
blobs,
|
|
test,
|
|
// Onbekend telt als toegestaan: eerst moeten testen om te mogen
|
|
// verbinden zou betekenen dat je altijd een poging in de weigerlijst
|
|
// van de relay zet voordat je iets kunt doen.
|
|
magVerbinden: test === null || test.geaccepteerd === true,
|
|
});
|
|
}
|
|
return { relayUrl: config.relayUrl, eigenaars, log };
|
|
};
|
|
|
|
// ── De afhandeling ──────────────────────────────────────────────────────────
|
|
|
|
const acties = {
|
|
nieuw: async ({ naam }) => {
|
|
const owner = nieuweEigenaar();
|
|
const regel = register.voegToe({ naam, mnemonic: owner.mnemonic, ownerId: owner.id });
|
|
meld(`${regel.naam} aangemaakt, OwnerId ${regel.ownerId}`);
|
|
return { naam: regel.naam, ownerId: regel.ownerId };
|
|
},
|
|
|
|
verbind: async ({ naam }) => {
|
|
await verbind(naam, true);
|
|
return { verbonden: true };
|
|
},
|
|
|
|
verbreek: async ({ naam }) => {
|
|
await verbreek(naam);
|
|
return { verbonden: false };
|
|
},
|
|
|
|
schrijf: async ({ naam, label, bytes }) => {
|
|
const omvang = Number.parseInt(bytes ?? '64', 10);
|
|
if (!Number.isInteger(omvang) || omvang < 1 || omvang > 8 * 1024 * 1024) {
|
|
throw new Error('bytes moet tussen 1 en 8388608 liggen.');
|
|
}
|
|
// Schrijven opent zo nodig zelf een verbinding, dus dezelfde poort als bij
|
|
// de knop "Verbinden": een geweigerde eigenaar gaat lokaal verder in
|
|
// plaats van de weigerlijst van de relay te vullen.
|
|
const test = laatsteTest.get(naam);
|
|
const geweigerd = test != null && test.geaccepteerd === false;
|
|
const ingang = open.get(naam) ?? (await verbind(naam, Boolean(config.relayUrl) && !geweigerd));
|
|
const body = new Uint8Array(omvang);
|
|
for (let i = 0; i < omvang; i += 1) body[i] = i % 256;
|
|
const geschreven = await ingang.store.schrijf(label || 'blob', body);
|
|
meld(`${naam}: blob van ${omvang} bytes weggeschreven (${geschreven.id})`);
|
|
return { id: geschreven.id, bytes: omvang };
|
|
},
|
|
|
|
lees: async ({ naam }) => {
|
|
const ingang = open.get(naam) ?? (await verbind(naam, false));
|
|
const rijen = await ingang.store.lees();
|
|
return {
|
|
rijen: rijen.map((rij) => ({
|
|
id: rij.id,
|
|
label: rij.label,
|
|
bytes: rij.body?.length ?? 0,
|
|
})),
|
|
};
|
|
},
|
|
|
|
test: async ({ naam }) => {
|
|
const regel = register.zoek(naam);
|
|
if (!regel) throw new Error(`Geen eigenaar "${naam}".`);
|
|
if (!config.relayUrl) throw new Error('Geen relay-adres. Zet RELAY_URL in .env.');
|
|
const uitkomst = await klopAan(config.relayUrl, regel.ownerId);
|
|
laatsteTest.set(naam, { ...uitkomst, tijd: new Date().toISOString() });
|
|
meld(`${naam}: testen gaf ${uitkomst.status ?? '-'}, ${uitkomst.uitleg}`);
|
|
return uitkomst;
|
|
},
|
|
|
|
vergeet: async ({ naam }) => {
|
|
await verbreek(naam);
|
|
laatsteTest.delete(naam);
|
|
const weg = register.vergeet(naam);
|
|
meld(`${weg.naam} uit het register gehaald`);
|
|
return { naam: weg.naam };
|
|
},
|
|
};
|
|
|
|
const leesBody = (verzoek) =>
|
|
new Promise((klaar, faal) => {
|
|
let ruw = '';
|
|
verzoek.on('data', (stuk) => {
|
|
ruw += stuk;
|
|
if (ruw.length > MAX_BODY_BYTES) {
|
|
faal(new Error('Verzoek te groot.'));
|
|
verzoek.destroy();
|
|
}
|
|
});
|
|
verzoek.on('end', () => {
|
|
try {
|
|
klaar(ruw === '' ? {} : JSON.parse(ruw));
|
|
} catch (oorzaak) {
|
|
faal(new Error(`Onleesbare JSON: ${oorzaak.message}`));
|
|
}
|
|
});
|
|
verzoek.on('error', faal);
|
|
});
|
|
|
|
const stuur = (antwoord, code, waarde) => {
|
|
const inhoud = JSON.stringify(waarde);
|
|
antwoord.writeHead(code, {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
'Content-Length': Buffer.byteLength(inhoud),
|
|
});
|
|
antwoord.end(inhoud);
|
|
};
|
|
|
|
const server = createServer((verzoek, antwoord) => {
|
|
void (async () => {
|
|
const pad = (verzoek.url ?? '/').split('?')[0].replace(/\/+$/, '') || '/';
|
|
|
|
if (verzoek.method === 'GET' && (pad === '/' || pad === '/index.html')) {
|
|
// Elke keer van schijf. Dit is gereedschap op je eigen machine, dus een
|
|
// pagina bijwerken en verversen hoort te werken zonder herstart.
|
|
const pagina = readFileSync(join(HIER, 'index.html'));
|
|
antwoord.writeHead(200, {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Content-Length': pagina.length,
|
|
});
|
|
antwoord.end(pagina);
|
|
return;
|
|
}
|
|
|
|
if (verzoek.method === 'GET' && pad === '/api/status') {
|
|
stuur(antwoord, 200, await status());
|
|
return;
|
|
}
|
|
|
|
if (verzoek.method === 'POST' && pad.startsWith('/api/')) {
|
|
const actie = acties[pad.slice('/api/'.length)];
|
|
if (!actie) {
|
|
stuur(antwoord, 404, { fout: 'Onbekende actie.' });
|
|
return;
|
|
}
|
|
try {
|
|
stuur(antwoord, 200, await actie(await leesBody(verzoek)));
|
|
} catch (oorzaak) {
|
|
// 400 en niet 500: vrijwel alles wat hier misgaat is een naam die al
|
|
// bestaat of een relay die er niet is, en dat is geen serverfout.
|
|
stuur(antwoord, 400, { fout: oorzaak.message });
|
|
}
|
|
return;
|
|
}
|
|
|
|
stuur(antwoord, 404, { fout: 'Niet gevonden.' });
|
|
})();
|
|
});
|
|
|
|
await new Promise((klaar) => {
|
|
server.listen(config.uiPoort, '127.0.0.1', klaar);
|
|
});
|
|
|
|
const adres = `http://127.0.0.1:${config.uiPoort}`;
|
|
console.log('');
|
|
console.log(` Bedieningsvlak: ${adres}`);
|
|
console.log(` Relay: ${config.relayUrl ?? 'niet ingesteld (zie .env.sample)'}`);
|
|
console.log(` Register: ${register.pad}`);
|
|
console.log('');
|
|
console.log(' Ctrl-C om te stoppen.');
|
|
console.log('');
|
|
|
|
const stop = async () => {
|
|
console.log('');
|
|
console.log('Afsluiten...');
|
|
server.close();
|
|
for (const naam of [...open.keys()]) await verbreek(naam);
|
|
process.exit(0);
|
|
};
|
|
process.on('SIGINT', () => {
|
|
void stop();
|
|
});
|
|
|
|
await new Promise(() => {});
|
|
};
|