/* Minimale lengte van een brief, in tekens */ export const MIN_CHARS = 300; /* * De schaal van het leeftijdsfilter: waar de invoervelden op staan, wat de server toetst en waarbinnen * het startfilter valt. Eén plek, want een grens die op drie plekken los is opgeschreven, klopt op een * dag op twee. * * De bovengrens is 120 en niet 99. Mensen worden weldegelijk ouder dan honderd — de oudst bekende mens * werd 122 — en wie 101 is hoort niet buiten zijn eigen app te vallen omdat de schuifknop op staat. * Het kostte hier ook echt iets: met 99 als plafond liep het startfilter van een hoogbejaarde vast op * `check (seek_min_birth <= seek_max_birth)` en eindigde registreren in "interne fout". */ export const AGE_MIN = 18; export const AGE_MAX = 120; /* * Het beginfilter: hoe ver de zoekband bij een nieuw account boven en onder de eigen leeftijd ligt. * * Wat er vastligt is de **afstand naar boven**, en die is overal positief: * * u(a) = FILTER_GAP_MIN + (FILTER_GAP_MAX − FILTER_GAP_MIN) / (1 + e^(−(a − FILTER_GAP_MID)/FILTER_GAP_STEEP)) * oudste(a) = a + u(a) * * De ondergrens is geen tweede formule maar de **omkering van de eerste**: de jongste die ik zie is * degene die mij nog net als oudste heeft. Daarmee is de band wederkerig — twee mensen op de * beginstand zien elkaar allebei of allebei niet, nooit eenzijdig — en dat is hier de eigenschap die * telt, want de matchquery is symmetrisch (MUTUAL_MATCH). * * Waarom een gladde s-vorm en niet een regel als "helft plus zeven": zulke regels zijn lijnen met * ergens een punt waar de band nul breed wordt (bij half plus zeven is dat 14 jaar). Zo'n punt is een * artefact — het is de plek waar de formule zichzelf opblaast — en het dwingt overal klemmen en * knikken af om hem te verbergen. Hier is er geen: u loopt van FILTER_GAP_MIN naar FILTER_GAP_MAX en * wordt nergens nul. De kromme is ook onder de 18 zinnig (16 → 13–19), en dat wij bij 18 afkappen is * onze grens, niet die van de wiskunde. * * De vier getallen, met wat ze betekenen: * GAP_MIN de kleinste afstand, in de vroege tienerjaren * GAP_MAX het plafond; vanaf een jaar of 50 verandert de band niet meer (dan 24 jaar breed) * GAP_MID waar het verbreden zit — bijna alles gebeurt tussen 15 en 35, daarna weinig * GAP_STEEP hoe scherp die overgang is * * Geijkt op wat er sociaal klopt, niet op literatuur: 25 → 20–33, 33 → 25–44, 50 → 38–62, 80 → 68–92. * Tinder publiceert deze cijfers niet en de wél gepubliceerde zijn uitgesplitst naar gender — en dat * verschil (mannen kijken omlaag, vrouwen omhoog) modelleren we bewust niet: de app doet geen aanname * over jou op grond van je gender. Zodra `npm run health` genoeg leden ziet om te zeggen welke banden * mensen zélf instellen, horen deze vier getallen daarop bijgesteld te worden. Daarom staan ze hier * en niet in een query. */ export const FILTER_GAP_MIN = 2; export const FILTER_GAP_MAX = 13; export const FILTER_GAP_MID = 24; export const FILTER_GAP_STEEP = 5; /* Correspondentie: zoveel brieven per keer laden (nieuwste eerst; oudere laden bij), ongelezen altijd allemaal */ export const THREAD_PAGE = 20; /* * Notificaties (opt-in, alleen in de Android-app). Vaste bezorgmomenten: rond deze uren — lokale * tijd van het toestel — vraagt het toestel zelf aan de server of er nieuwe post is en zet daar een * melding bij. Vaste momenten en niet één per brief, omdat een brief geen bericht is: wie per brief * gepiept wordt, gaat chatten. * * Zoveel momenten als er waarden in staan, dus twee; `null` is er een die uit staat. De tweede * begint uit, want één keer per dag is genoeg tot iemand zelf vindt van niet — een tweede bezorging * hoort iets te zijn dat je aanzet, niet iets dat je moet wegklikken. */ export const POST_HOURS = [8, null]; /* Account: minimale wachtwoordlengte, maximale naamlengte */ export const PASSWORD_MIN = 8; export const NAME_MAX = 40; /* * En een bovengrens aan het wachtwoord, want die ontbrak. Niet omdat lange wachtwoorden slecht zijn * — het omgekeerde — maar omdat de server elke poging door scrypt haalt: 16 MB geheugen en een * tiende seconde per keer, en bij het inloggen met opzet óók als het e-mailadres niet bestaat (dan * verraadt de responstijd niets). Zonder grens is één verzoek met een wachtwoord van een megabyte * dus een goedkope manier om die kosten op te drijven. 128 tekens is ruim boven wat een mens of een * wachtwoordkluis maakt, en de grens geldt overal waar een wachtwoord gezet of gecontroleerd wordt. */ export const PASSWORD_MAX = 128; /* * De profieltekst. Hier stond geen enkele grens op — niet in de app, niet in de API, niet in het * schema — terwijl dit de tekst is die geïndexeerd wordt (bio_tsv) en in elk zoekresultaat meekomt. * Eén profiel van een paar megabyte zou dus in ieders Zoeken belanden. * * Het getal is een productkeuze en geen techniek: dit is een app waarin je op je wóórden gevonden * wordt, dus de tekst mag lang zijn. Tweeduizend tekens is een pagina of drie alinea's — genoeg om * iets over jezelf te zeggen, en niet genoeg om een boek in een zoekresultaat te plakken. */ export const BIO_MAX = 2000; /* * Bewaartermijnen (AVG art. 5 lid 1e) — de server handhaaft ze (dagelijkse opschoning), de * privacyverklaring noemt ze. Sessie: verlopen na zoveel dagen zonder gebruik. Account: gewist * na zoveel dagen zonder enige activiteit (geen sessie gebruikt, geen profiel/brief). */ export const SESSION_TTL_DAYS = 90; export const INACTIVE_ACCOUNT_DAYS = 730; /* Locatie wordt afgerond opgeslagen: 2 decimalen ≈ 1 km — genoeg voor afstanden, niet voor een adres */ export const LOCATION_DECIMALS = 2; /* * Verwerkingsverantwoordelijke en contact voor privacyvragen (zie src/content/privacy.js). * Het adres hoort bij het domein van de app zelf en wordt apart bezorgd — met opzet geen * persoonlijk adres, want dit staat in de app, op de publieke pagina en in de winkelvermelding. */ export const PRIVACY_CONTROLLER = "Bram Luiken"; export const PRIVACY_VERSION = "20 augustus 2026"; /* * Waar de app voor de buitenwereld staat. Hieruit bouwt de server de herstel-link van "wachtwoord * vergeten" (server/wachtwoord.js); PUBLIC_BASE_URL in de omgeving wint, zodat een andere * installatie of een lokale proef zichzelf kan aanwijzen. * * Dit stond eerder niet vast maar kwam uit de Host-header van het verzoek — een adres dat de * bezoeker zelf opschrijft, in een link die naar iemand ánders gemaild wordt. Wie `/auth/forgot` * aanriep met het e-mailadres van een vreemde en een eigen Host-header, liet ons dus een geldig * herstel-token naar dat slachtoffer sturen met een link naar zijn eigen site. Het domein is * hetzelfde als dat van de adressen hieronder en staat al in .env.production; het hoort dus gewoon * hier te staan en niet uit het verzoek te komen. */ export const PUBLIC_URL = "https://liefdesbrieven.bramluiken.org"; /* * Vijf adressen, want het is vijf soorten post — en post die op één stapel belandt is post die * blijft liggen. De localparts zijn niet vrij te kiezen: de mailbox sorteert * `@liefdesbrieven.bramluiken.org` op een vaste lijst rollen naar Platforms/Liefdesbrieven/, * en wat daar niet in staat belandt in Spam. Liefdesbrieven staat als Nederlands platform * geregistreerd, dus alleen de lijst `localparts_nl` telt hier. * Zie bramluiken.nl-setup: ansible/roles/mail/files/sieve-rules.yml (localparts_nl). * * Kies op sóórt post, niet op wat er toevallig al geïmporteerd is: privacy@ is voor AVG-verzoeken * en niet het algemene adres. Gewone post — een vraag, een suggestie, de brief van een vertrekker — * gaat naar contact@. */ export const GENERAL_CONTACT = "contact@liefdesbrieven.bramluiken.org"; // → Algemeen export const PRIVACY_CONTACT = "privacy@liefdesbrieven.bramluiken.org"; // → Privacy export const MODERATION_CONTACT = "moderatie@liefdesbrieven.bramluiken.org"; // → Moderatie export const COMPLAINTS_CONTACT = "klachten@liefdesbrieven.bramluiken.org"; // → Klachten export const CHILD_SAFETY_CONTACT = "kinderveiligheid@liefdesbrieven.bramluiken.org"; // → Kinderveiligheid /* Genderlabels zoals getoond in de UI */ export const GENDER_LABEL = { v: "vrouw", m: "man", x: "non-binair" }; /* Volledige genderweergave: 'trans' is een onafhankelijke toggle bovenop het gender → "trans man" */ export const genderLabel = ({ gender, trans }) => (trans ? "trans " : "") + GENDER_LABEL[gender]; /* Relatiestatus: heeft iemand al een romantische relatie? Eigen status + filter, wederzijds net als gender */ export const RELATIONSHIP_LABEL = { vrijgezel: "vrijgezel", relatie: "in een relatie" }; /* * Eén foto per brief. De app verkleint de foto tot PHOTO_MAX_PX op de langste zijde en codeert hem * opnieuw als JPEG vóór verzenden: dat wist alle EXIF-gegevens (waaronder de gps-coördinaten van waar * de foto genomen is) nog op het toestel, en houdt hem onder PHOTO_MAX_BYTES. De server accepteert * alleen PHOTO_MIMES en controleert dat op de magische bytes (server/media.js). */ export const PHOTO_MAX_PX = 1600; export const PHOTO_MAX_BYTES = 1024 * 1024; export const PHOTO_MIMES = ["image/jpeg", "image/png", "image/webp"]; /* Beschrijving van de foto: verplicht bij het toevoegen, en zowel het bijschrift eronder als de alt-tekst */ export const PHOTO_ALT_MAX = 200; /* * Melden. Blokkeren doe je voor jezelf (verbergen), melden doe je voor de beheerder: die kan iets * aan misbruik doen, jij niet. Beide zijn nodig — Google Play eist voor apps met gebruikerscontent * dat je een gebruiker én een stuk content kunt melden, en zonder melden komt de app niet door de * review. De sleutels staan zo in de database (check-constraint in db/schema.sql), de labels hier. */ export const REPORT_REASONS = { minderjarig: "Deze persoon lijkt minderjarig", intimidatie: "Bedreiging, intimidatie of haat", seksueel: "Ongewenst seksueel beeld of voorstel", nep: "Nepprofiel, of doet zich voor als iemand anders", oplichting: "Oplichting of vraag om geld", anders: "Iets anders", }; /* Toelichting bij een melding: optioneel, maar het is meestal het enige dat de beheerder verder helpt */ export const REPORT_DETAILS_MAX = 1000; /* * Bewaartermijn van een melding (AVG art. 5 lid 1e). Lang genoeg om een patroon te zien — één * melding zegt weinig, drie meldingen over dezelfde persoon zeggen alles — en niet langer. */ export const REPORT_RETENTION_DAYS = 365; /* * De brief die je bij het verwijderen van je account aan de beheerder kunt achterlaten. Geen * minimum (anders dan bij een echte brief): "de zoekfunctie deugt niet" is een complete klacht. * Het maximum is ruim — wie vertrekt heeft soms veel te zeggen, en dit is de laatste keer. */ export const FAREWELL_MAX = 5000; /* * ---------- Wat één account kan (de eerste tranche) ---------- * * Melden is gemaakt voor mens-meldt-mens. De dreiging die daar per definitie doorheen glipt is de * verkoper die zich als dater voordoet: niet-vervelend zijn is zijn methode. Wat hij wél nodig * heeft is massa — veel brieven, veel profielen, en zo nodig een nieuw account. Deze grenzen maken * die massa duur, zonder ook maar één nieuw persoonsgegeven te vragen. * * Ze zijn ruim voor iemand die brieven schrijft (minimaal MIN_CHARS tekens per stuk, met de hand) * en krap voor een script. Ze straffen niemand en melden niets: wie erlangs komt hoort alleen dat * het even wachten is. */ /* Brieven per uur en per dag (het etmaal = de laatste 24 uur, geen kalenderdag) */ export const LETTERS_PER_HOUR = 8; export const LETTERS_PER_DAY = 24; /* * Startdrempel. Een nieuw account is het goedkoopste dat er is; dit maakt het jonge account het * minst waard, en zet het tempo van wie net geschorst is en opnieuw begint terug naar nul. Voor een * echte nieuwkomer is het geen hindernis: wie op dag één vier brieven van 300 tekens schrijft is al * uitzonderlijk ijverig. NEW_ACCOUNT_HOURS moet 24 of minder blijven — de server telt de brieven van * een jong account uit hetzelfde etmaalvenster als LETTERS_PER_DAY (zie server/queries.js). */ export const NEW_ACCOUNT_HOURS = 24; export const NEW_ACCOUNT_LETTERS = 4; /* * Zoeken. Twee grenzen, want ze doen iets anders: SEARCH_PER_WINDOW remt hoe vaak er gezocht wordt * (de app zoekt tijdens het typen, met 200 ms vertraging, dus dit is ruim boven wat handen halen), * en SEARCH_RESULTS_MAX begrenst wat één zoekopdracht prijsgeeft. Dat tweede is de eigenlijke * maatregel: zonder dak geeft elke lege zoekopdracht het complete ledenbestand terug, en dan is * één verzoek per minuut al genoeg om alles te oogsten. De app zegt het eerlijk als er meer is. */ export const SEARCH_WINDOW_MIN = 10; export const SEARCH_PER_WINDOW = 120; export const SEARCH_RESULTS_MAX = 100; /* * Zoeken binnen één correspondentie is iets anders dan zoeken in Zoeken: het gaat door je eigen * post, dus er valt niets prijs te geven en er is geen rem nodig. Alleen een dak op de trefferlijst, * zodat "de" niet driehonderd brieven ophaalt; de app zegt het als er meer waren. */ export const THREAD_HITS_MAX = 50; /* * ---------- Wanneer is het een correspondentie? ---------- * * Vier brieven, en van beide kanten minstens twee. Die tweede eis doet het werk: drie brieven van * de een en één van de ander is geen briefwisseling maar een beleefd einde, en zonder die eis telt * dat toch mee. Zo is het ook niet in je eentje te halen. * * Dit staat hier en niet bij de meting (scripts/health.js), omdat het een uitspraak over het * product is en geen instelling van een rapport: zou de app ooit zeggen "jullie zijn aan het * corresponderen", dan hoort dat dezelfde drempel te zijn als die het rapport telt. */ export const REAL_CORRESPONDENCE_LETTERS = 4; export const REAL_CORRESPONDENCE_EACH = 2; /* * ---------- Wat een account kost (de tweede tranche) ---------- * * De eerste tranche maakt één account minder waard. Die maakt niets uit voor wie er honderd neemt, * en dat is precies wat een verkoper doet zodra één account te krap wordt. Dit maakt het tweede * account duur: er zijn FREE_ACCOUNTS gratis plekken, en daarna kost registreren eenmalig geld. * * De bak is geen anti-sybilmaatregel maar een startkapitaal: een datingapp zonder leden heeft niets * te bieden, dus de eerste lichting moet er gratis in kunnen. Dat die bak in principe in één keer * leeg te trekken is, is een bewust aanvaard risico — in de koudestartperiode zijn het er weinig * genoeg om met de hand te modereren, en daarna is betalen de norm. * * De prijs is een drempel, en dat de opbrengst bij de maker terechtkomt hoeft niet weggemoffeld te * worden: het kost geld, het levert geld op, en dat is gewoon hoe het werkt. Wél is de hoogte een * knop met twee effecten tegelijk — hoger betekent minder aanmeldingen, en tegelijk aanmeldingen van * mensen die er iets voor over hadden. Wie de prijs verhoogt koopt dus deels kwaliteit; dat is een * afweging, geen bijverschijnsel. * * De teller is de knop: zolang er plekken zijn is registreren gratis, en zodra ze op zijn vraagt de * app om te betalen. Er is geen moment waarop er iets omgezet moet worden, en er is niets te tonen — * de gebruiker die te laat komt ziet geen gemiste teller, alleen de prijs. * * Uitgegeven plekken komen niet terug als het account verwijderd wordt (zie db/schema.sql): * anders is "aanmaken, wissen, opnieuw" de goedkoopste manier om de bak leeg te halen. */ export const FREE_ACCOUNTS = 1000; /* * Het artikel in de store waarmee een betaalde plek gekocht wordt (Play Console → In-app products, * en straks hetzelfde id in App Store Connect). Eenmalig, geen abonnement. De prijs staat hier * bewust níet: die komt uit de store, per land en per valuta, en zou hier alleen verouderen. * De app vraagt het id op bij de server (GET /api/auth/signup) en niet uit deze constante, zodat * een prijs- of productwijziging geen nieuwe app-versie vraagt. */ export const SIGNUP_PRODUCT_ID = "account";