diff --git a/private/server.rkt b/private/server.rkt index e83771f..f913e7b 100644 --- a/private/server.rkt +++ b/private/server.rkt @@ -214,6 +214,13 @@ (regexp-match? #px"^/api/(?:auth|agent)(?:/|$)" (request-path request))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Add one HTTP header to an existing response. +; pre : Value is a response and extra-header is an HTTP header. +; post : The original response remains unchanged. +; result : A response with the same body and metadata and the additional +; header prepended to its header list. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (response-add-header value extra-header) (response (response-code value) (response-message value) @@ -222,6 +229,15 @@ (cons extra-header (response-headers value)) (response-output value))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Dispatch an API request and renew an eligible browser cookie. +; pre : Current-player and current-auth are initialized and request targets +; an API route. +; post : The selected handler has run. A due browser-session renewal is +; recorded and returned as Set-Cookie; agent requests never renew it. +; result : The HTTP response produced by the API handler, optionally extended +; with the renewed session cookie. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (dispatch-api request) (define value (api-dispatch request)) (define renewed-cookie diff --git a/private/users.rkt b/private/users.rkt index d0f7c86..14ea18b 100644 --- a/private/users.rkt +++ b/private/users.rkt @@ -26,6 +26,13 @@ (username [last-seen #:mutable] [last-cookie-renewal #:mutable]) #:transparent) (struct failures ([attempts #:mutable] [started #:mutable]) #:transparent) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Hold configured users, trusted proxies, and volatile auth state. +; pre : Constructor fields contain normalized and parsed internal values. +; post : Creating or recognizing a value does not change external state. +; result : auth-manager? recognizes values used by the authentication API. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (struct auth-manager (users trusted-proxies session-seconds sessions failed lock) #:transparent) @@ -45,6 +52,12 @@ (define failure-window-seconds 300) (define maximum-failures 5) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Create an Argon2id password hash for configuration storage. +; pre : Password is a string containing at least twelve characters. +; post : No module state is changed. +; result : A salted Argon2id hash encoded as a string. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (make-password-hash password) (unless (and (string? password) (>= (string-length password) 12)) @@ -56,6 +69,12 @@ (string->bytes/utf-8 password) password-parameters)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Verify a password against an encoded Argon2id hash. +; pre : Password and encoded are arbitrary values. +; post : No module state is changed. +; result : #t only when both values are strings and the password matches. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (password-hash-valid? password encoded) (and (string? password) (string? encoded) @@ -135,6 +154,12 @@ (string-trim (last (string-split forwarded ","))) peer)) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Report whether browser authentication is configured. +; pre : Manager is an auth-manager. +; post : Manager remains unchanged. +; result : #t when at least one configured user can log in, otherwise #f. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-enabled? manager) (positive? (hash-count (auth-manager-users manager)))) @@ -150,6 +175,14 @@ (auth-manager-session-seconds manager)) (hash-remove! (auth-manager-sessions manager) token))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Resolve the browser user represented by a request cookie. +; pre : Manager is an auth-manager and request is an HTTP request. +; post : Expired sessions are removed and a valid session's last-seen time +; is updated. +; result : "anonymous" when authentication is disabled, the normalized +; username for a valid session, or #f when login is required. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-request-user manager request) (cond ((not (auth-enabled? manager)) "anonymous") @@ -187,7 +220,16 @@ address (failures 1 now)))) -;; Returns a new token, #f for invalid credentials, or 'rate-limited. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Authenticate credentials and start a browser session. +; pre : Manager is an auth-manager, request is an HTTP request, and +; username and password are strings. +; post : A valid login creates a new session; a failed login updates the +; rate-limit state for the effective client address. +; result : A new opaque token, #f for invalid credentials, or 'rate-limited. +; internals: Unknown users follow the same Argon2id verification path as known +; users to reduce username-dependent timing differences. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-login! manager request username password) (define address (request-address manager request)) (define now (current-seconds)) @@ -217,6 +259,12 @@ (record-failure! manager address now) #f))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : End the browser session named by the request cookie. +; pre : Manager is an auth-manager and request is an HTTP request. +; post : The matching server-side session is removed when it exists. +; result : Void. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-logout! manager request) (let ((token (request-session-token request))) (when token @@ -225,6 +273,13 @@ (lambda () (hash-remove! (auth-manager-sessions manager) token)))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Encode an authenticated session token as a browser cookie. +; pre : Manager is an auth-manager and token is a session token string. +; post : Manager remains unchanged. +; result : A Secure, HttpOnly, SameSite=Strict Set-Cookie value whose Max-Age +; equals the configured session lifetime. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-session-cookie manager token) (string->bytes/utf-8 (format @@ -233,9 +288,17 @@ token (auth-manager-session-seconds manager)))) -;; Return a refreshed cookie at most once per half session lifetime. The -;; server-side inactivity timer is updated on every authenticated request, but -;; limiting Set-Cookie avoids rewriting it for every one-second player poll. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Renew an actively used browser cookie at a bounded frequency. +; pre : Manager is an auth-manager and request is an HTTP request. +; post : Expired sessions are removed. When renewal is due, the session's +; last-cookie-renewal time is advanced. +; result : A fresh Set-Cookie value after half the configured lifetime has +; elapsed, otherwise #f. +; internals: The server idle timer moves on every authenticated request, while +; this half-life threshold prevents the one-second player poll from +; returning Set-Cookie every second. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-renewal-cookie manager request) (and (auth-enabled? manager) (let ((token (request-session-token request)) @@ -243,7 +306,7 @@ (and token (call-with-semaphore (auth-manager-lock manager) - (lambda () + (λ () (prune-sessions! manager now) (define value (hash-ref (auth-manager-sessions manager) token #f)) @@ -257,12 +320,27 @@ (set-session-last-cookie-renewal! value now) (auth-session-cookie manager token))))))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Encode deletion of the browser session cookie. +; pre : None. +; post : No module state is changed. +; result : A Secure, HttpOnly, SameSite=Strict Set-Cookie value with Max-Age 0. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (auth-expired-cookie) (string->bytes/utf-8 (format "~a=; Path=/; Max-Age=0; Secure; HttpOnly; SameSite=Strict" session-cookie-name))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : Create the authentication and session manager used by the server. +; pre : User-pairs contains username and Argon2id-hash pairs, trusted proxy +; values are IP addresses or CIDR networks, and session-seconds is a +; positive exact integer. +; post : No external state is changed; session and rate-limit tables start +; empty. +; result : A new auth-manager with normalized usernames and parsed networks. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (make-auth-manager user-pairs #:trusted-proxies [trusted-proxy-values '("127.0.0.0/8" "::1/128")] diff --git a/skill/racket-skill.md b/skill/racket-skill.md new file mode 100644 index 0000000..a827883 --- /dev/null +++ b/skill/racket-skill.md @@ -0,0 +1,223 @@ +--- +name: racket-programmeer-skill +description: Hiermee wordt mijn voorkeur racket programmeerstijl aangegeven. +--- + +--- +name: racket-programmeerstijl +description: Gebruik deze skill wanneer je Racket-code voor Hans schrijft, wijzigt, refactort of beoordeelt. Pas de bestaande, eenvoudige en procedurele programmeerstijl toe; voorkom over-engineering en onnodige abstracties. Gebruik deze skill niet voor algemene uitleg over Racket waarbij geen code voor zijn projecten wordt gemaakt of aangepast. +--- + +# Racket-programmeerstijl + +Gebruik deze stijl wanneer je Racket-code voor Hans schrijft of aanpast. + +## Uitgangspunt + +Het *allerbelangrijkste* uitgangspunt is dat je de programmerstijl van aangeleverde code volgt. +Als je een zip met een package aangeleverd krijgt via de prompt dan volg je de programmeerstijl die je in de aangeleverde code vindt. +Wanneer bestaande broncode beschikbaar is, heeft de stijl van die broncode voorrang. Sluit daar zo nauw mogelijk op aan. + +Schrijf eenvoudige, directe en goed leesbare Racket-code. +Kies de kleinste oplossing die het huidige probleem netjes oplost. +Bouw geen abstraheringslaag voor mogelijk toekomstig gebruik. +En maak geen helpers die alleen maar in de weg staan. + +## Structuur + +- Houd modules klein en doelgericht. +- Splits functionaliteit alleen af naar een private module wanneer die een duidelijk eigen doel heeft. +- Gebruik voor duidelijke secties bij voorkeur commentaar in deze vorm: + +```racket +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Supporting functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +``` + +of, wanneer dat beter bij de module past: + +```racket +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Internal state / functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +``` + +Voor publieke functies: + +```racket +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Provided functions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +``` + +- Houd `provide` en `require` eenvoudig en overzichtelijk. +- Voeg geen extra framework, wrapperlaag of generieke infrastructuur toe zonder concrete noodzaak. + +## pre/postcondities + +Geëxporteerde functies/procedures/classes of functies/procedures/classes die daarvoor duidelijk in +aanmerking komen, d.w.z. die die provided zijn of naar verwachting zullen worden, moeten gedocumenteerd worden. +Zowel in een module scribble als in de code zelf. In het engels. + +In de code zelf: minimaal: + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : +; pre : +; post : +; [result:] + +Over het algemeen wil je de internals van een functie weten. Hoe werkt het en waarom werkt het zo. + +; [internals:] +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +Als een functie/procedure/class overduidelijk in aanmerking komt voor 'provide' en hij staat er nog niet in. +Verzamel dan de lijst en vraag of je ze moet toevoegen. + +## Procedures en control flow + +- Geef de voorkeur aan gewone procedures met een direct leesbare control flow. +- Gebruik `let`, `if`, `when`, `unless`, `begin` en `cond` expliciet wanneer dat de code duidelijker maakt. +- Maak niet voor iedere kleine stap een aparte helperprocedure. +- Introduceer geen hogere-orde of functionele constructies alleen omdat dat compacter kan. +- Gebruik recursie of een named `let` wanneer dat de meest directe oplossing is. +- Gebruik bij `cond` bij voorkeur deze vorm: + +```racket +(cond + ([condition] korte body) + ([other-condition] + langere body)) + (else ... alleen als het nodig is) +``` + +## Mate van abstractie vs leesbaarheid + +Liever concreet dan abstract. + +Geef expliciete, goed leesbare constructies de voorkeur boven compacte abstracte idiomen. + +Bijvoorbeeld combinaties als (filter values (list (and condition 'symbol) ...)). +Schrijf dan liever expliciet (filter (lambda (x) x) (list (if condition 'symbol #f) ...)). +Vermijd vooral het stapelen van meerdere impliciete idiomen wanneer dat de leesbaarheid vermindert. + +## Gebruik lambda. + +Geef de voorkeur aan λ boven lambda. + +## Waarden en state + +- Gebruik `#f` als normale waarde voor "niet gevonden", "niet beschikbaar" of "nog niet geïnitialiseerd" wanneer dat natuurlijk past. +- Expliciete vergelijkingen zoals `(eq? value #f)` zijn prima wanneer dat de bedoeling duidelijk maakt. +- Houd state eenvoudig. Een gewone modulevariabele zoals `cached-git-exe` is prima wanneer daarvoor geen zwaarder mechanisme nodig is. +- Gebruik geen parameters, structs, classes of objectlagen wanneer een gewone variabele of procedure voldoende is. + +## Publieke API + +- Gebruik `define/contract` voor publieke procedures wanneer een contract nuttige documentatie en controle geeft. +- Houd publieke procedures klein en voorspelbaar. +- Verander een bestaande publieke API niet zonder noodzaak. +- Voeg geen extra publieke functies toe voor hypothetische toekomstige behoeften. + +## Fouten en interactie + +- Geef duidelijke en concrete foutmeldingen. +- Los eenvoudige interactieve invoer lokaal en procedureel op. +- Maak foutafhandeling niet generieker dan nodig. +- Als een externe executable of voorziening ontbreekt, meld precies wat ontbreekt en wat de gebruiker kan doen. + +## Configuratie + +- Bewaar lokale configuratie in een kleine, afzonderlijke private module wanneer dat de hoofdmodule eenvoudiger maakt. +- Gebruik bestaande projectvoorzieningen, zoals `simple-ini`, rechtstreeks in plaats van er een extra abstractielaag omheen te bouwen. +- Dupliceer geen configuratie die al door een extern programma zelf wordt beheerd. + +## Naamgeving en leesbaarheid + +- Kies concrete, korte namen die passen bij de bestaande code. +- Gebruik Engels voor identifiers en technische namen wanneer de bestaande code dat doet. +- Schrijf comments alleen wanneer ze iets toevoegen dat niet al vanzelf uit de code blijkt. +- Geef de voorkeur aan een paar duidelijke regels boven een compacte maar moeilijker leesbare expressie. + +## Vermijd + +Vermijd zonder concrete noodzaak: + +- over-engineering; +- generieke wrappers; +- extra abstraheringslagen; +- dynamische `require`-constructies; +- classes wanneer procedures volstaan; +- structs wanneer een eenvoudige waarde volstaat; +- configuratie-objecten of dependency-injectionpatronen; +- veel kleine helperprocedures die de control flow versnipperen; +- refactors die alleen bedoeld zijn om code "slimmer" of abstracter te maken. + +## Werkwijze bij aanpassen van bestaande code + +1. Lees eerst de omliggende module(s). +2. Neem naamgeving, inspringing, control-flow-stijl en module-indeling over. +3. Wijzig alleen wat voor de gevraagde stap nodig is. +4. Houd bestaande werkende code intact als er geen reden is die te veranderen. +5. Voeg geen volgende architectuurstappen alvast toe. +6. Controleer of de oplossing eenvoudiger is dan het probleem; zo niet, vereenvoudig. + +## Referentiestijl + +Deze vorm is representatief: + +```racket +(define cached-value #f) + +(define/contract (get-value) + (-> (or/c path? #f)) + (if (eq? cached-value #f) + (let ((value (find-value))) + (set! cached-value value) + value) + cached-value)) +``` + +Een wat langere maar direct leesbare implementatie heeft de voorkeur boven een kortere oplossing met meerdere nieuwe abstracties. + +# Schrijven van testgevallen voor modules/packages + +Een test die alleen werkt vanuit de development directory, op het development-OS of met de lokale shell/environment is geen geldige package-test. + +## Racket Package Index / build-service tests + +Behandel de Racket Package Index/build service als een aparte, strikte +en onbekende testomgeving. + +Bij packagecode en tests gelden daarom altijd de volgende regels: + +- Maak nooit aannames over `current-directory` of de directory van waaruit + code of tests worden uitgevoerd. Bepaal testdata en paden expliciet en + relocatable, bijvoorbeeld met runtime paths en tijdelijke directories. + +- Maak nooit impliciete aannames over het besturingssysteem. Vermijd + OS-specifieke paden, shells, executables en gedrag, of handel verschillen + expliciet per platform af. + +- Maak tests onafhankelijk van lokale environment state. Benodigde + environment variables moeten expliciet en bij voorkeur geïsoleerd worden + ingesteld. + +- Een succesvolle test moet stil en ondubbelzinnig succesvol zijn. + Laat geen verwachte foutmeldingen naar de echte stdout/stderr lekken, + omdat `raco test --drdr` en de Package Index dergelijke output als een + mogelijke test failure kunnen classificeren. + +- Verwachte foutoutput moet worden gecaptureerd en geassert. + +- Tests moeten hun eigen tijdelijke state en testbestanden aanmaken en + mogen geen bestanden, processen, environment changes of andere state + achterlaten. + +- Test packagewijzigingen waar mogelijk ook in een omgeving die lijkt op: + `raco setup --check-pkg-deps` en + `raco test --drdr --package `. + +