Systeemarchitectuur Jottem

A Collection of Interesting Ideas,

This version:
https://github.com/inside-out-time-machines/design
Editor:
Bob Coret (Gouda Tijdmachine)

Dit document beschrijft een op Docker gebaseerde systeemarchitectuur voor het Jottem-platform: een participatief digitaal erfgoedplatform waarin gebruikers media (jottems) uploaden, moderatoren deze beoordelen en publiceren, en annoteerders verrijkingen toevoegen. Het geheel wordt via subdomeinen onder iotm.nl ontsloten en implementeert de in het designdocument genoemde standaarden: IIIF Image API 3.0, IIIF Presentation API 3.0, IIIF Change Discovery, W3C Web Annotations, schema.org AP NDE, ARK en RSS.

1. Uitgangspunten

2. Contextdiagram

Het platform staat tussen vijf rollen (bezoeker, gebruiker/uploader/annoteerder, moderator, beheerder, API-gebruiker) en een aantal externe systemen: social login providers, het Internet Archive voor duurzame opslag (latere fase), het NDE Datasetregister, tijdmachines als kaart-/tijdinterface, een SMTP-provider en het NDE Termennetwerk (GraphQL) als toegang tot terminologiebronnen (Cultuurhistorische Thesaurus, Wikidata, GTAA, ...) voor URI’s in metadata en annotaties.

Contextdiagram

3. Componentenoverzicht en subdomeinen

Containerdiagram

Subdomein Component Open-source suggestie Alternatieven
www.iotm.nl Webfrontend Next.js of SvelteKit + Mirador/Tify (IIIF-viewer), Annotorious (annoteren), MapLibre GL (kaart) Nuxt, Universal Viewer, OpenSeadragon + eigen UI
api.iotm.nl Backend-API (REST, IIIF Presentation, Change Discovery, RSS, zoeken) FastAPI (Python) met iiif-prezi3 NestJS, Laravel, Django REST Framework
auth.iotm.nl Identity provider (OIDC, social login, 2FA/passkeys) Authentik (gekozen) Keycloak, Ory Kratos/Hydra
iiif.iotm.nl IIIF Image API 3.0 + HTTP-cache Cantaloupe achter Varnish iipsrv, SIPI, go-iiif; nginx proxy_cache
anno.iotm.nl W3C Web Annotation server miiify (expliciet genoemd in het designdocument) SimpleAnnotationServer, Annotot
ark.iotm.nl ARK-resolver en -minter Arklet (Internet Archive) eigen microservice + noid-bibliotheek (pynoid)
data.iotm.nl RDF-publicatie (schema.org AP NDE), SPARQL, datadumps, datasetbeschrijvingen Apache Jena Fuseki Oxigraph, QLever, Virtuoso OS
(intern) Zoekmachine Elasticsearch (genoemd in requirements) OpenSearch
(intern) Relationele database PostgreSQL MariaDB
(intern) Cache en taakwachtrij Valkey (gekozen; Redis-compatibel) Redis, RabbitMQ (queue)
(extern) Mediaopslag (S3) Externe Object Storage-dienst (gekozen) MinIO (zelf gehost)
(intern) Detectie herkenbare personen Herkenbaar API (eigen dienst: FastAPI + YOLO-pose) cloud vision API’s (vallen af: beelden moeten on-premise blijven)
(intern) Async workers Celery (bij FastAPI) of BullMQ (bij NestJS) RQ, Sidekiq
(edge) Reverse proxy, TLS Traefik Caddy, nginx-proxy + acme-companion

De volgende paragrafen lichten de belangrijkste componenten toe.

3.1. Reverse proxy (Traefik)

Traefik termineert TLS (automatisch via Let’s Encrypt), routeert op subdomein naar de juiste container via Docker-labels, en voegt security headers en rate limiting toe. Alle publieke componenten hangen aan het edge-netwerk; datastores zijn uitsluitend intern bereikbaar.

De rate limiting is per IP-adres met burst, het strengst op de endpoints die dure backends raken. Indicatieve startwaarden (bij te stellen op basis van monitoring): zoek-API en SPARQL 5 req/s (burst 15), beheer-API en meldingen/rapporteren 2 req/s (burst 5), annotatie- en metadata-reads 30 req/s, IIIF-tiles en thumbnails ruim (die komen vrijwel altijd uit de Varnish-cache). Overschrijding levert 429 Too Many Requests met Retry-After; bulk-afnemers worden naar de datadumps en feeds verwezen.

3.2. Authenticatie en autorisatie (Authentik)

Authentik fungeert als centrale OIDC-provider op auth.iotm.nl en doet uitsluitend authenticatie: registratie voor bezoekers, social login (Google, Facebook, Apple, Microsoft) via federatie, wachtwoord-login, TOTP-2FA en passkeys (WebAuthn). Voor accounts die via een beheeruitnodiging binnenkomen dwingt de enrollment-flow het instellen van een wachtwoord én een tweede factor (TOTP of passkey) af.

Autorisatie: de database is leidend. Rollen staan niet in de IdP maar in GebruikerRol (per organisatie, meerdere rollen per gebruiker - zie het ERD in de data-architectuur). De backend koppelt het access-token via de sub-claim aan een Gebruiker-rij en bepaalt daarna zelf de rechten (bijv. "moderator van organisatie X mag alleen jottems van X beoordelen"). Zo blijven de uitnodigings- en beheerflows in de applicatie-UI, is er geen groepensynchronisatie met de IdP nodig en is er één bron van waarheid voor rollen.

De werking in de praktijk: een FastAPI-dependency (auth-middleware) valideert per request het access-token (handtekening, expiry, amr), zoekt via de sub-claim de gebruiker en diens GebruikerRol-rijen op en hangt die als principal aan de request. De rollen-lookup wordt per sub kort gecachet in Valkey (TTL orde van een minuut); bij elke rolwijziging via de beheer-API wordt die cache-entry direct geïnvalideerd, zodat intrekking vrijwel meteen effect heeft zonder dat elke request de database raakt.

Het JWT-claims-contract dat de backend van Authentik nodig heeft, is daarmee klein:

Claim Gebruik
sub stabiele koppeling token → Gebruiker (bij eerste login wordt de rij aangemaakt of aan de uitgenodigde rij gekoppeld)
email, email_verified e-mailadres (uitnodigingen, notificaties)
name / preferred_username weergavenaam (zie Gebruiker.naamPubliek voor publicatie)
amr (auth-methode) de backend eist een sterke factor (TOTP of passkey) voor endpoints die een beheer- of moderatierol vragen

De uitnodigingsflow: de beheer-API verstuurt de uitnodigingsmail met bevestigingslink en zet de GebruikerRol klaar; de link leidt naar een Authentik-enrollment-flow (wachtwoord + verplichte tweede factor: TOTP of passkey); bij de eerste login koppelt de backend de sub aan de uitgenodigde Gebruiker-rij.

Fijnmazige autorisatie op objectniveau (eigen afgekeurde jottems verwijderen, goedgekeurde niet) hoort in de backend thuis; wie dit declaratief wil, kan een policy engine als Casbin of OpenFGA toevoegen, maar voor deze schaal volstaat autorisatielogica in de API-laag.

3.3. Backend-API en frontend

De backend-API op api.iotm.nl is het maatwerkhart van het platform. Hij implementeert het datamodel uit het ERD (organisatie, project, gebruiker, media, metadata, favoriet) op PostgreSQL en biedt:

De frontend op www.iotm.nl is een aparte container (Next.js of SvelteKit) met de publieke site, de HTML-weergave per jottem (IIIF-viewer + metadata + annotaties), de upload- en moderatie-omgeving, en de interactieve kaart. Voor het tekenen van vlakken op afbeeldingen en het vastleggen van geo-annotaties (standpunt fotograaf, zichtveld als WKT/GeoJSON) is Annotorious in combinatie met OpenSeadragon/Mirador en MapLibre een beproefde keuze; de kaartweergave kan via IIIF en de API gekoppeld worden aan tijdmachines (zoals de Gouda Tijdmachine).

3.4. IIIF Image API (Cantaloupe + Varnish)

Cantaloupe serveert op iiif.iotm.nl de Image API 3.0 (info.json, tiles, regio’s, formaten) en leest bronbestanden rechtstreeks uit de S3-opslag (S3Source). Bij goedkeuring van een jottem maakt een worker een pyramidal TIFF of JPEG2000 als afgeleide, wat tiling zonder zware rekenlast mogelijk maakt. Varnish (of nginx proxy_cache) staat ervoor en cachet tiles en info.json agressief; gepubliceerde beelden zijn immutable, dus lange TTL’s zijn veilig. Zo blijft de image server ook bij piekverkeer (bijv. na een oproep in lokale media) responsief.

3.5. Annotaties (miiify)

miiify, de annotatieserver van The National Archives (UK) die ook in het designdocument wordt genoemd, implementeert het W3C Web Annotation Protocol en slaat annotaties versiebeheerd op (git-gebaseerde backend), wat goed past bij de wens om herinneringen, aanvullingen en correcties herleidbaar te houden. De frontend schrijft annotaties (vlak-identificaties van personen/gebouwen/bedrijven met naam + URI, plaatsnamen, gebeurtenissen, archiefbronkoppelingen, vrije tekst en reacties) via de backend-API naar miiify; de API voegt daarbij de geauthenticeerde gebruiker als creator toe en bewaakt autorisatie (bewerken/verwijderen van eigen annotaties). Lezen kan publiek en standaardconform via anno.iotm.nl, zodat externe partijen annotaties per jottem kunnen ophalen en doorzoeken.

3.6. Detectie herkenbare personen (Herkenbaar API)

De Herkenbaar API is een eigen dienst (FastAPI met een YOLO-pose-model via ultralytics) die bepaalt of op een afbeelding herkenbare personen staan, ten behoeve van het portretrecht en de privacy-eisen uit het designdocument. De dienst draait als interne container op het app-netwerk en is niet publiek bereikbaar.

De backend-API roept de dienst synchroon aan bij het aanmaken van een jottem: na de upload (via presigned URL naar de mediaopslag) haalt de backend het detectieresultaat op en slaat dit op bij de media (herkenbaar ja/nee plus betrouwbaarheid). Bij "herkenbaar: ja" vraagt de frontend de uploader direct om een toestemmingsverklaring van de afgebeelde personen; de moderator ziet het detectiesignaal en de verklaring bij de kwaliteitscontrole. De detectie is een hulpmiddel - de moderator beslist.

Privacy-uitgangspunt: de inferentie draait volledig op de eigen server; beelden verlaten het platform niet en er worden geen gezichtsherkennings- of identificatietechnieken gebruikt, uitsluitend detectie van de aanwezigheid van herkenbare personen.

3.7. RDF-publicatie en datasetbeschrijvingen (Fuseki)

Elke publicatie of wijziging triggert een worker die de jottem-metadata mapt naar RDF conform het schema.org-profiel van NDE (schema.org AP NDE) en deze upsert in Apache Jena Fuseki op data.iotm.nl. Daarmee ontstaat:

De mapping zelf kan in applicatiecode (rdflib) of declaratief met RML/CARML; voor een overzichtelijk model als dit is applicatiecode het eenvoudigst te onderhouden.

3.8. ARK-resolving (latere fase)

*In de MVP is de duurzame link een platform-URL met content negotiation (zie de URI-strategie in de data-architectuur); ARK-minting en -resolving zijn uitgesteld naar een latere fase (zie het keuzedocument). Onderstaande beschrijving legt vast hoe die fase eruitziet, zodat URL’s nu al ARK-klaar gekozen worden.*

Bij goedkeuring mint de backend een objectnaam (NOID via pynoid, of UUID) onder de NAAN en shoulder van de organisatie en registreert deze in de resolver op ark.iotm.nl. Arklet van het Internet Archive is hiervoor een lichtgewicht, S3- en Postgres-vriendelijke keuze; een eigen microservice van enkele tientallen regels is een reeel alternatief omdat de resolvelogica beperkt is. De resolver ondersteunt content negotiation en ARK-conventies zoals ?info voor metadata over het object zelf:

ARK-resolving met content negotiation

Belangrijk duurzaamheidsprincipe: de ARK-registry (koppeling ARK naar actuele locaties) staat in PostgreSQL en wordt meegenomen in de backup en in de metadata die naar het Internet Archive gaat, zodat resolving ook bij migratie van het platform herstelbaar blijft.

3.9. Dataopslag, caching en mediaopslag

PostgreSQL is de primaire bron van waarheid voor het volledige ERD en de moderatiestatussen (een ARK-registry volgt pas in de latere ARK-fase). Valkey (Redis-compatibel) dient drie doelen: sessie-/API-cache, rate limiting en de taakwachtrij voor de workers. De mediaopslag is een externe Object Storage-dienst (S3) - geen eigen opslagbeheer en de capaciteit groeit mee zonder serverwijziging - met gescheiden buckets: originals (niet publiek, alleen via API en workers), derivatives (pyramidal TIFF’s voor Cantaloupe), thumbs (publiek, lange cache-headers), audio voor audio-jottems zoals interviews (fase 2, zie de uploadscope in de requirements) en exports (e-depot-exportpakketten, beperkte bewaartermijn). Alle componenten spreken uitsluitend het S3-protocol, dus een zelf-gehost alternatief (MinIO) blijft transparant inwisselbaar; de leverancierskeuze is een exploitatiebesluit.

Caching gebeurt op drie niveaus: Varnish voor IIIF-tiles en manifests, Valkey voor API-responses (zoekresultaten, collections, statistieken) en HTTP-cache-headers plus ETags op alle publieke endpoints zodat ook harvesters efficient werken.

3.10. Async workers en Internet Archive

Een workerpool (Celery met Valkey als broker) handelt alles af wat niet in de request-cyclus hoort: genereren van derivatives en thumbnails, EXIF-extractie, e-mailnotificaties (goedkeuring, afkeuring met reden, uitnodigingen), Elasticsearch-indexering, RDF-synchronisatie naar Fuseki en e-depot-exports per project (BagIt + RO-Crate, zie de data-architectuur). In fase 2 komen daar de conversiepipelines voor PDF (pagina’s naar afgeleiden voor Cantaloupe, multi-canvas Manifest) en audio (transcodering plus duration voor het IIIF-audio-canvas) bij. De Internet Archive-doorzetting van goedgekeurde media (via de internetarchive Python-client, met de IA-itemidentifier terug in de database en de RDF, bijv. als schema:archivedAt) behoort niet tot de MVP en volgt in een latere fase (zie het keuzedocument).

Betrouwbare doorwerking van mutaties (outbox). Het Gebeurtenislog uit het ERD fungeert als transactional outbox: elke mutatie die outputs raakt (goedkeuring, metadata-wijziging, annotatiemutatie, depublicatie) schrijft binnen dezelfde databasetransactie een logregel. De workers verwerken die regels asynchroon en idempotent naar Elasticsearch, de Fuseki named graph en een Varnish-purge/ban van de geraakte URL’s; pas na succes wordt de regel als verwerkt gemarkeerd, en bij falen wordt opnieuw geprobeerd met backoff. Een nachtelijke reconciliatiejob vergelijkt PostgreSQL met de zoekindex en de triplestore en repareert verschillen, zodat de outputs ook na een crash of storing aantoonbaar bijlopen.

4. Deployment met Docker

Deploymentdiagram

De stack draait met docker compose op een enkele host, met drie netwerken: edge (publiek via Traefik), app (interne servicecommunicatie) en data (datastores, niet publiek routeerbaar). Een indicatieve docker-compose.yml:

services:
  traefik:
    image: traefik:v3
    ports: ["80:80", "443:443"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik_acme:/acme
    networks: [edge]

  frontend:
    build: ./frontend
    labels:
      - "traefik.http.routers.www.rule=Host(`www.iotm.nl`)"
    networks: [edge, app]

  api:
    build: ./api
    environment:
      DATABASE_URL: postgres://jottem@postgres/jottem
      REDIS_URL: redis://valkey:6379
      S3_ENDPOINT: https://s3.example-objectstorage.eu   # externe Object Storage-dienst
      OIDC_ISSUER: https://auth.iotm.nl/application/o/jottem/
      ES_URL: http://elasticsearch:9200
    labels:
      - "traefik.http.routers.api.rule=Host(`api.iotm.nl`)"
    networks: [edge, app, data]

  worker:
    build: ./api
    command: celery -A jottem worker
    deploy: { replicas: 2 }
    networks: [app, data]

  authentik:
    image: ghcr.io/goauthentik/server:latest
    command: server
    labels:
      - "traefik.http.routers.auth.rule=Host(`auth.iotm.nl`)"
    networks: [edge, data]

  cantaloupe:
    image: uclalibrary/cantaloupe:latest
    environment:
      SOURCE_STATIC: S3Source
    networks: [app, data]

  varnish:
    image: varnish:stable
    labels:
      - "traefik.http.routers.iiif.rule=Host(`iiif.iotm.nl`)"
    networks: [edge, app]

  miiify:
    image: nationalarchives/miiify:latest
    labels:
      - "traefik.http.routers.anno.rule=Host(`anno.iotm.nl`)"
    volumes: [miiify_data:/data]
    networks: [edge, app]

  ark:
    image: internetarchive/arklet:latest
    labels:
      - "traefik.http.routers.ark.rule=Host(`ark.iotm.nl`)"
    networks: [edge, data]

  fuseki:
    image: stain/jena-fuseki:latest
    labels:
      - "traefik.http.routers.data.rule=Host(`data.iotm.nl`)"
    volumes: [fuseki_tdb2:/fuseki]
    networks: [edge, app]

  postgres:
    image: postgres:16
    volumes: [pg_data:/var/lib/postgresql/data]
    networks: [data]

  valkey:
    image: valkey/valkey:8
    networks: [data]

  elasticsearch:
    image: elasticsearch:8.14.0
    environment: { discovery.type: single-node }
    volumes: [es_data:/usr/share/elasticsearch/data]
    networks: [data]

  herkenbaar:
    build: ./herkenbaar-api
    networks: [app]

  prometheus:
    image: prom/prometheus:latest
    volumes: [prometheus_data:/prometheus]
    networks: [app, data]

  alertmanager:
    image: prom/alertmanager:latest
    networks: [app]

  grafana:
    image: grafana/grafana:latest
    environment:
      GF_AUTH_GENERIC_OAUTH_ENABLED: "true"   # SSO via Authentik
    labels:
      - "traefik.http.routers.status.rule=Host(`status.iotm.nl`)"
    volumes: [grafana_data:/var/lib/grafana]
    networks: [edge, app]

  loki:
    image: grafana/loki:latest
    volumes: [loki_data:/loki]
    networks: [app]

  promtail:
    image: grafana/promtail:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /var/lib/docker/containers:/var/lib/docker/containers:ro
    networks: [app]

  blackbox:
    image: prom/blackbox-exporter:latest   # uptime/TLS/inhoud van publieke endpoints
    networks: [app, edge]

  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    volumes: [/:/rootfs:ro, /var/run:/var/run:ro, /sys:/sys:ro]
    networks: [app]

  node-exporter:
    image: prom/node-exporter:latest
    networks: [app]

volumes:
  traefik_acme: {}
  pg_data: {}
  es_data: {}
  fuseki_tdb2: {}
  miiify_data: {}
  prometheus_data: {}
  grafana_data: {}
  loki_data: {}

networks:
  edge: {}
  app: { internal: true }
  data: { internal: true }

Aanvullend op de kernstack: Prometheus + Grafana + Loki + Alertmanager voor monitoring, logging en alerting (met exporters per datastore en een blackbox-exporter voor de publieke endpoints), pgBackRest voor database-backups en restic voor nachtelijke offsite backups van alle volumes naar een tweede opslaglocatie; de externe mediabuckets worden separaat geback-upt (bijv. met rclone naar diezelfde backup-opslag). De Internet Archive-kopie van gepubliceerd materiaal (latere fase) voegt daar te zijner tijd een tweede duurzaamheidslaag aan toe. De inrichting van testen en monitoring is uitgewerkt in paragraaf 5.

5. Doorsnijdende aspecten

Privacy en auteursrecht. Persoonsgegevens staan uitsluitend in PostgreSQL en Authentik; verwijderingsverzoeken worden ondersteund doordat originelen alleen via de API bereikbaar zijn en gepubliceerde jottems een depubliceer-workflow kennen (verwijderen uit index, cache-purge in Varnish, tombstone op de duurzame URL). Toestemming voor herkenbare personen en het onderscheid herinnering versus verifieerbaar feit zijn metadatavelden in de moderatieworkflow, geen infrastructuurcomponent.

Multi-tenancy. Organisatiejottems delen dezelfde stack; scheiding gebeurt via organisatie-id in de database, named graphs in Fuseki, prefixen in S3 en Elasticsearch, identiteiten in Authentik (rollen in de database) en shoulders binnen de ARK NAAN. Huisstijl (logo, kleurenpalet, favicon) is configuratie in de database die de frontend per organisatie-slug toepast.

Geautomatiseerd testen. Het testen volgt de testpiramide en draait volledig in CI (GitHub Actions of Forgejo Actions), met een gate op elke pull request en een nachtelijke volledige run:

Monitoring en observability. De observability-stack (Prometheus, Grafana, Loki, Alertmanager) uit paragraaf 4 wordt als volgt ingericht:

Schaalpad. De eerste jaren volstaat een enkele VPS (8 vCPU, 16-32 GB RAM). Groeit het platform, dan schalen frontend, API, workers en Cantaloupe horizontaal het eenvoudigst; PostgreSQL en Elasticsearch verhuizen dan naar managed diensten of eigen nodes. Omdat alle koppelingen via standaardprotocollen lopen (HTTP, S3, OIDC, SPARQL) is die migratie een deployment-vraagstuk, geen architectuurwijziging.

VPS/dedicated server Bij Hetzner (DE) biedt de CCX33 voor €139 per maand (€1.668 per jaar) een VPS met gestelde specificaties. Een dedicated server met betere specs heb je via Hetzner’s "server auction" overigens al voor de helft van dat bedrag.

Zoals eerder gezegd, doordat alle koppelingen standaardprotocollen zijn (HTTP, S3, OIDC, SPARQL) en alles in Docker draait, blijft de overstap naar grotere of meerdere dedicated servers een deployment-vraagstuk, geen architectuurwijziging. Een dedicated server sluit het groeipad dus niet af, hij maakt de eerste stap alleen minder elastisch. Er is wel een kanttekening te maken ten aanzien van beschikbaarheid: bij één dedicated box betekent hardwarefalen downtime tot restore; een cloud-VM kan de provider naar gezonde hardware verplaatsen. Je backups (restic/pgBackRest/IA) dekken dataduurzaamheid, niet uptime. Server-auction-machines zijn bovendien gebruikte hardware.

Voor Jottem is een dedicated server een prima, kostenefficiënte keuze met veel headroom.

6. Samenvatting

Requirement uit het designdocument Invulling
Laagdrempelig inloggen incl. social login Authentik met social-login-federatie
2FA/passkeys voor beheerders Authentik TOTP-/WebAuthn-enrollment + amr-controle in de backend
Duurzame ARK-links met NOID/UUID Arklet + pynoid, registry in PostgreSQL
HTML- en RDF-weergave via content negotiation ARK-resolver met 303-redirects naar frontend resp. Fuseki
IIIF Image API (info.json) Cantaloupe + Varnish op iiif.iotm.nl
IIIF Presentation API (manifest/collection) Backend-API met iiif-prezi3
IIIF Change Discovery ActivityStreams-feed in backend-API
W3C Web Annotations / Annotation Protocol miiify op anno.iotm.nl
Elasticsearch-zoekfunctie Elasticsearch, geindexeerd door workers
RSS per organisatie Backend-API
schema.org AP NDE + datasetbeschrijvingen Fuseki op data.iotm.nl + NDE Datasetregister
Internet Archive integratie Workerpool met internetarchive-client
Mediaopslag (S3) externe Object Storage-dienst (S3); MinIO als zelf-gehost alternatief
Geo-annotaties (WKT/GeoJSON) Annotorious + MapLibre, opslag in miiify/metadata
Termen en vocabulaires (CHT, term-URI’s) NDE Termennetwerk GraphQL API, via backend-proxy met bronnenselectie per project
Detectie herkenbare personen (portretrecht) Herkenbaar API, synchroon aangeroepen bij upload
Geautomatiseerd testen CI-pipeline: unit/integratie (pytest, Vitest), contracttests op IIIF/W3C/SHACL/SPARQL, e2e met Playwright
Monitoring, logging en alerting Prometheus + exporters, Grafana, Loki/Promtail, Alertmanager, blackbox-/synthetische checks
Subdomeinen Traefik-routing: www, api, iiif, ark, anno, auth, data, status