// ═══════════════════════════════════════════════════════════════════════════════ // Het register: welke eigenaars bestaan er, en hoe kom je erbij terug. // // Eén bestand, `owners.json`, met per eigenaar een naam, zijn mnemonic en zijn // `OwnerId`. De mnemonic is het enige dat werkelijk bewaard hoeft te worden; het // `OwnerId` staat erbij omdat je het nodig hebt om een eigenaar op de // statuspagina van de app terug te vinden, en omdat je het dan kunt opzoeken // zonder Evolu te starten. // // **Dit is sleutelmateriaal.** Het heeft buiten deze proefopstelling geen // waarde, maar het bestand staat in .gitignore en het hoort daar te blijven; zie // het plan Testclient, PLAN.md §4a. // // Alles hier is een gewone functie op een pad. Geen Evolu, geen netwerk: dat is // wat dit het enige deel van de cliënt maakt dat zonder relay en zonder // fixtures te toetsen is, en het is dezelfde scheiding die policy.js aan de // relay-kant heeft. // ═══════════════════════════════════════════════════════════════════════════════ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; /** * Een naam is een herkenpunt voor jezelf en gaat nooit de deur uit. * * Toch begrensd, want hij belandt in een JSON-bestand en straks op een pagina. * Letters, cijfers, streepje en liggend streepje; dat is genoeg om "trezor-mac" * of "proef_2" te kunnen schrijven en het sluit een naam met een schuine streep * erin uit, die er als pad uit zou zien. */ const NAAM_PATROON = /^[A-Za-z0-9_-]{1,40}$/; export const isBruikbareNaam = (naam) => typeof naam === 'string' && NAAM_PATROON.test(naam); const LEEG = { version: 1, owners: [] }; /** * Leest het bestand. Geeft `null` bij iets wat niet klopt. * * Bewust streng, en om dezelfde reden als in policy.js: een half begrepen * bestand is gevaarlijker dan geen bestand. Hier zou dat betekenen dat een * eigenaar stil verdwijnt terwijl zijn mnemonic wél op schijf staat, en dan is * de proef die eroverheen liep niet meer te herhalen. */ export const normaliseer = (ruw) => { if (ruw === null || typeof ruw !== 'object' || Array.isArray(ruw)) return null; if (ruw.version !== 1) return null; if (!Array.isArray(ruw.owners)) return null; const owners = []; for (const eigenaar of ruw.owners) { if (eigenaar === null || typeof eigenaar !== 'object') return null; if (!isBruikbareNaam(eigenaar.naam)) return null; if (typeof eigenaar.mnemonic !== 'string' || eigenaar.mnemonic.length === 0) return null; if (typeof eigenaar.ownerId !== 'string' || eigenaar.ownerId.length === 0) return null; if (owners.some((eerder) => eerder.naam === eigenaar.naam)) return null; owners.push({ naam: eigenaar.naam, mnemonic: eigenaar.mnemonic, ownerId: eigenaar.ownerId, aangemaakt: typeof eigenaar.aangemaakt === 'string' ? eigenaar.aangemaakt : null, }); } return { version: 1, owners }; }; /** * Opent het register in `directory`, en maakt de map aan als hij er niet is. * * Een ontbrekend bestand is de begintoestand en geen fout. Een onleesbaar bestand * wél: dat wordt gemeld en niet stil vervangen, want vervangen is hier hetzelfde * als weggooien. */ export const openRegister = (directory) => { mkdirSync(directory, { recursive: true }); const pad = join(directory, 'owners.json'); let staat; try { staat = normaliseer(JSON.parse(readFileSync(pad, 'utf8'))); } catch (oorzaak) { if (oorzaak.code === 'ENOENT') { staat = { ...LEEG, owners: [] }; } else { throw new Error(`${pad} is niet te lezen: ${oorzaak.message}`); } } if (staat === null) { throw new Error( `${pad} klopt niet. Er is niets vervangen; kijk er zelf naar, want hier staan mnemonics in.`, ); } // Schrijven gaat via een tijdelijk bestand en een hernoeming. Een half // geschreven register kost mnemonics, en een hernoeming binnen dezelfde map is // op elk besturingssysteem dat wij raken één handeling. const bewaar = () => { const tijdelijk = `${pad}.nieuw`; writeFileSync(tijdelijk, `${JSON.stringify(staat, null, 2)}\n`, 'utf8'); renameSync(tijdelijk, pad); }; return { pad, /** Alle eigenaars, in de volgorde waarin ze zijn aangemaakt. */ lijst: () => staat.owners.map((eigenaar) => ({ ...eigenaar })), /** Eén eigenaar, of `null`. */ zoek: (naam) => { const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam); return gevonden ? { ...gevonden } : null; }, /** * Zet een eigenaar in het register. * * Gooit bij een naam die niet kan of al bestaat. Overschrijven zou de * mnemonic van de vorige eigenaar weggooien, en dat is precies het soort * stille schade waar het commentaar bovenaan dit bestand over gaat. */ voegToe: ({ naam, mnemonic, ownerId, aangemaakt }) => { if (!isBruikbareNaam(naam)) { throw new Error(`"${naam}" kan niet als naam. Letters, cijfers, - en _, hoogstens 40 tekens.`); } if (staat.owners.some((eigenaar) => eigenaar.naam === naam)) { throw new Error(`Er is al een eigenaar "${naam}".`); } const nieuw = { naam, mnemonic, ownerId, aangemaakt: aangemaakt ?? new Date().toISOString(), }; staat = { ...staat, owners: [...staat.owners, nieuw] }; bewaar(); return { ...nieuw }; }, /** * Haalt een eigenaar uit het register. Gooit als hij er niet in staat. * * Let op wat dit níet doet: de database van die eigenaar blijft staan, en op * de relay verandert er helemaal niets. Vergeten is hier vergeten, geen * wissen; zie OPEN.md punt 3 en 4 van het plan. */ vergeet: (naam) => { const gevonden = staat.owners.find((eigenaar) => eigenaar.naam === naam); if (!gevonden) throw new Error(`Geen eigenaar "${naam}".`); staat = { ...staat, owners: staat.owners.filter((eigenaar) => eigenaar.naam !== naam) }; bewaar(); return { ...gevonden }; }, }; };