Elke semantische zoekopdracht in een van mijn projecten kost geld: een embedding, een reranking-aanroep en een tekstsynthese. Centen per request. Vermenigvuldigd met een bot in een loop zijn het geen centen meer. De eerste versie vroeg nergens om een account, want een account vragen om anime te zoeken leek absurd. Dat lijkt het nog steeds. Ik doe het toch.
De reden is niet marketing of een mailinglijst. Het is dat er zonder identiteit niets te tellen valt.
Wat het kost om mensen ongeïdentificeerd binnen te laten
Ik bouwde een fake-door-validatieharnas met vijf landingspagina's tegelijk om interesse te meten voordat ik iets bouwde. Het enige dat beschermd moest worden was het adminpaneel, dus zette ik een IP-ban na meerdere mislukte logins. Genoeg, dacht ik.
Dat was het niet. Het IP van de client kwam uit x-forwarded-for, en die header is een lijst client, proxy1, proxy2… waarin elke proxy rechts toevoegt maar niemand opruimt wat er al stond. De eerste waarde nemen is nemen wat de client maar wil schrijven. Iedereen kan x-forwarded-for: 1.2.3.4 sturen, zijn eigen ban omzeilen, en meteen iemand anders bannen door diens IP op te schrijven.
Ik repareerde het door hops te tellen in plaats van links te vertrouwen:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map((s) => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
Met één proxy ervoor is de echte client de laatste waarde: die de proxy schrijft en die de client niet kan aanraken. Het werkt. Maar kijk wat ik zojuist heb gedaan: ik heb een omgevingsvariabele geïntroduceerd die afhangt van de netwerktopologie van de deploy, om iemand bij benadering te kunnen identificeren. Zet ik morgen een CDN ervoor, dan verandert het getal en vergist het bansysteem zich in stilte. Het faalt niet: het vergist zich.
Waarom het IP niet deugt als identiteit
Behalve dat het achter de verkeerde proxy vervalsbaar is, is het IP een slechte sleutel om redenen die niet te repareren zijn. Vier mensen op hetzelfde kantoor delen er één. Iemand op zijn mobiel wisselt van IP zodra hij de deur uit loopt. Mobiele netwerken doen NAT over enorme blokken: één provider-IP bannen is een hele wijk bannen.
Wat je van een identifier wilt is dat hij stabiel is voor dezelfde persoon en verschillend voor verschillende personen. Het IP voldoet aan geen van beide. Een user_id voldoet er per constructie aan beide.
Wat je alleen kunt doen als je weet wie wie is
In de zoekmachine eindigde ik met drie niveaus en een eindige limiet op alle drie:
visitor: { semanticSearchLimit: 10, historyLimit: 0 }
registered: { semanticSearchLimit: 30, historyLimit: 10 }
premium: { semanticSearchLimit: 200, historyLimit: null }
Het commentaar dat ik bij het type achterliet is het hele oordeel: altijd eindig; geen enkel niveau is vrijgesteld van het zoekquotum. Premium heeft het hoogste getal, niet oneindig. historyLimit accepteert wél null voor "onbeperkt". Die van zoeken niet, want elke zoekopdracht is een rekening.
De bezoeker bestaat nog steeds, met een cookie die hem een anonieme sleutel geeft. Maar het verlengingsvenster verschilt per persoon: de geregistreerde verlengt per kalendermaand in UTC, de premium op de verjaardag van zijn aanmelding. Dat vereist het opslaan van een premiumAnchorAt, en iets opslaan vereist een rij met een eigenaar. Zonder account is er geen verjaardag om aan te verankeren.
Het geval van apps met kosten per request
Als elke actie echt geld kost, is het quotum geen verdediging meer maar boekhouding. En boekhouding gaat op saaie manieren mis.
In de zoekmachine werden twee identieke requests dubbel geteld. De oplossing was een reservering met een hash van de inhoud van het request, niet van de tekst: de genormaliseerde query, de limiet, de expliciet-vlag en de gesorteerde filters. Het commentaar bij het type zegt waarom de volgorde uitmaakt — dezelfde set in een andere volgorde gekozen is dezelfde zoekopdracht — en waarom de filters erin zitten: dezelfde tekst met een ander genre is een ander request, en die samenvoegen zou verkeerde resultaten teruggeven. De reservering geeft reserved, duplicate, in_progress of exhausted terug. Alle vier de statussen bestaan omdat alle vier zijn voorgekomen.
In een transcriptie-API begon de afrekening per karakter als transcriptie plus verwerkte tekst gedeeld door tien. Bij het meten van de echte kosten werd de regel dit:
transcriptionText.length + Math.floor(processedText.length * 0.25)
Van /10 naar * 0.25: tweeënhalf keer zo duur. De prijs zat er een factor 2,5 naast en maandenlang betaalde ik het verschil zonder het te weten, omdat ik de meting niet had om het te merken.
In een SaaS voor beeldgeneratie was de bug omgekeerd: de dagelijkse credits bestonden, maar de voorafgaande check keek alleen naar subCredits. Gebruikers met dagcredit kregen een 402 terwijl er credit op hun account stond. Een dailyCredits + subCredits loste het op. Dit zijn allemaal kolommen van een gebruikersrij. Zonder rij is er geen bug om te repareren, want er valt niets te tellen.
Wanneer identiteit ook niet genoeg is
Een account hebben is niet hetzelfde als geldige identiteit hebben. In een API met keys overleefde de rij van de key de status van het account van de eigenaar: bij offboarding werd het facturatierecord opgeruimd zonder dat iemand de isActive van de key aanraakte. De key bleef authenticeren. De fix was om in authenticateApiKey zelf te hervalideren dat de eigenaar nog een facturatierecord heeft, in plaats van op de vlag te vertrouwen:
if (!ownerBilling) {
return { authenticated: false, error: "API key owner is no longer active" };
}
Dat hoort daar en niet in elk endpoint, juist zodat geen enkel endpoint eraan hoeft te denken.
Wanneer ik mensen wél zonder account binnenlaat
Als de actie niets kost en geen state opbouwt: een fiche lezen, een catalogus bekijken, een landingspagina laden. En een bezoekersniveau met een kort quotum, zodat iemand kan proberen voordat hij beslist. Tien zoekopdrachten vóór registratie is eerlijke frictie; nul is een openstaande rekening.
De vraag is niet of login irritant is. Dat is het. De vraag is wat je in de WHERE gaat schrijven als iemand over de schreef gaat.
