# Opdracht voor de ai.vpro.nl-repository

Over te nemen door Copilot in de Vercel-repo. Dit document staat op zichzelf —
alle context die nodig is staat erin.

## Wat er gebouwd moet worden

Een observatiepost die vastlegt wat geautomatiseerd verkeer over zichzelf
verklaart, en een publieke pagina op `/robots` die de stand toont.

Aanleiding: `robots.txt` kent één as — welke user-agent mag waar komen — en
heeft geen woorden voor *waarom* een client langskomt. Sinds Web Bot Auth
(IETF, gedragen door Cloudflare, Amazon, Akamai, OpenAI) kan een bot zich
ondertekenen. Deze pagina meet hoeveel bezoekers dat werkelijk doen.

Er draait een werkende PHP-versie van hetzelfde op sndrv.nl; deze opdracht is
de vertaling daarvan naar Vercel.

## Wat er per verzoek binnenkomt

Een ondertekende bot stuurt deze request-headers mee:

| header | inhoud |
|---|---|
| `Signature-Agent` | quoted string met een URL, bv. `"https://chatgpt.com"` — de sleuteldirectory van de ondertekenaar |
| `Signature` | RFC 9421 handtekening |
| `Signature-Input` | welke velden ondertekend zijn |

De agent card zelf staat **niet** in het verzoek; die staat op het domein uit
`Signature-Agent`, op `/.well-known/signature-agent-card` of
`/.well-known/http-message-signatures-directory`. Die kan later los opgehaald
worden — niet tijdens het afhandelen van een verzoek.

## Indeling van het verkeer

Drie categorieën, bepaald op het moment van loggen:

1. **ondertekend** — `Signature-Agent` of `Signature` aanwezig
2. **stille bot** — geen handtekening, maar de user-agent matcht:
   `/bot|crawl|spider|scrap|fetch|index|archiv|gpt|claude|anthropic|openai|perplexity|python-requests|curl|wget|headless|slurp|facebookexternalhit|bytespider/i`
3. **overig** — al het andere, vermoedelijk mensen met een browser

## Privacy — dit is een harde eis

Van categorie 3 wordt **niets** vastgelegd. Geen IP, geen user-agent, geen pad,
geen tijdstip per verzoek. Alleen een opgeteld aantal per dag.

Van categorie 1 en 2 worden alleen de headers uit de tabel hierboven bewaard,
plus user-agent, pad, methode en tijdstip. **Geen IP-adressen, ook niet
gehasht.** Dat is geen detail: het is wat deze pagina zonder verdere
afwegingen publiceerbaar maakt.

## Architectuur op Vercel

Drie onderdelen. De opsplitsing is niet vrijblijvend — zie de valkuilen.

### 1. `middleware.ts` — Edge runtime

Ziet elk verzoek. Bepaalt de categorie, en stuurt het resultaat met
`event.waitUntil()` naar de interne API-route hieronder. **Blokkeert het
antwoord niet.**

```ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const BOT = /bot|crawl|spider|scrap|fetch|index|archiv|gpt|claude|anthropic|openai|perplexity|python-requests|curl|wget|headless|slurp|facebookexternalhit|bytespider/i;

export const config = {
  // Alles behalve Vercel-interne paden en de logroute zelf.
  matcher: ['/((?!_next/static|_next/image|api/agentlog).*)'],
};

export function middleware(request: NextRequest) {
  const h = request.headers;
  const sigAgent = h.get('signature-agent');
  const ua = h.get('user-agent') ?? '';
  const ondertekend = Boolean(sigAgent || h.get('signature'));
  const geautomatiseerd = ondertekend || (ua !== '' && BOT.test(ua));

  const payload = geautomatiseerd
    ? {
        soort: 'bot',
        tijd: new Date().toISOString(),
        pad: request.nextUrl.pathname.slice(0, 500),
        methode: request.method,
        ondertekend,
        headers: {
          'signature-agent': sigAgent?.slice(0, 2000) ?? undefined,
          'signature-input': h.get('signature-input')?.slice(0, 2000) ?? undefined,
          'user-agent': ua.slice(0, 2000),
          from: h.get('from') ?? undefined,
          accept: h.get('accept')?.slice(0, 500) ?? undefined,
        },
      }
    : { soort: 'overig' };   // bewust geen enkel ander veld

  // Niet awaiten: het antwoord mag hier niet op wachten.
  const taak = fetch(new URL('/api/agentlog', request.url), {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'x-agentlog-sleutel': process.env.AGENTLOG_SLEUTEL! },
    body: JSON.stringify(payload),
  }).catch(() => {});

  // @ts-expect-error waitUntil bestaat op het edge-verzoek
  request.waitUntil?.(taak);

  return NextResponse.next();
}
```

### 2. `/api/agentlog` — Node runtime, niet publiek

Controleert `x-agentlog-sleutel` tegen `AGENTLOG_SLEUTEL` en weigert anders
met 404. Doet twee dingen in S3:

- bij `soort: 'bot'`: één object wegschrijven onder
  `agentlog/verzoeken/YYYY-MM-DD/<epoch-ms>-<random>.json`
- altijd: het aggregaat `agentlog/tellingen.json` bijwerken (lezen, ophogen,
  terugschrijven)

Vorm van `tellingen.json`:

```json
{
  "totaal": 0, "ondertekend": 0, "stilleBots": 0, "overig": 0,
  "agents":  { "https://chatgpt.com": 12 },
  "stillen": { "Mozilla/5.0 (compatible; Googlebot/2.1; ...)": 88 },
  "perDag":  { "2026-08-19": 143 },
  "eerste": "2026-08-12",
  "bijgewerkt": "2026-08-19T08:00:00Z"
}
```

Bewust geaccepteerd: bij gelijktijdige verzoeken kan een lees-wijzig-schrijf
een enkele telling verliezen. Voor dit doel is dat aanvaardbaar — het gaat om
verhoudingen, niet om exacte aantallen. Wordt dat later bezwaarlijk, dan
verhuizen de tellers naar Vercel KV, dat een atomaire `INCR` heeft.

### 3. `/robots` — de publieke pagina

Leest alléén `agentlog/tellingen.json` — één GET, geen S3-listing per
paginaweergave. Zet:

```
Cache-Control: public, s-maxage=300, stale-while-revalidate=600
```

Dan doet het CDN van Vercel het cachen en wordt de pagina hooguit eens per
vijf minuten opnieuw opgebouwd.

Inhoud, in deze volgorde:

1. Korte uitleg: robots.txt kent alleen "wie", niet "waarom".
2. Vier cijfers: totaal geteld, stelden zich voor, herkenbaar bot maar zweeg,
   de rest. Bij elk cijfer een regel toelichting; bij "de rest" expliciet
   *"alleen geteld, verder niets van bewaard"*.
3. De kernzin: van het verkeer dat als geautomatiseerd te herkennen was,
   verklaarde X% iets over zichzelf; de andere Y% kwam langs zonder een woord.
4. Tabel: wie zich voorstelden. Bij leegte: *"Nog niemand. Dat is voorlopig het
   meest sprekende resultaat op deze pagina."*
5. Tabel: wie langskwamen zonder iets te zeggen.
6. Blok "En u": toon de bezoeker zijn eigen `Signature-Agent`, of bij afwezigheid
   zijn user-agent met de opmerking dat daar niet in staat of hij langskwam
   omdat iemand erom vroeg dan wel omdat de pagina er nu eenmaal is. Deze
   pagina wordt zelf door agents opgehaald; dat is de bedoeling.
   Let op: dit blok mag **niet** meegecachet worden — render het client-side of
   zet het in een aparte, niet-gecachete fetch.
7. Slot: de verklaring is niet afdwingbaar, en dat is geen bezwaar. Een belofte
   verschuift de kwestie van technisch naar contractueel: wat is toegezegd ligt
   vast, en afwijking wordt aanwijsbaar. Zodra de meeste agents hun reden
   opgeven, wordt het weigeren daarvan zelf een signaal.

Alles in het Nederlands. Sober, serif, licht en donker via
`prefers-color-scheme`. Geen frameworks of externe afhankelijkheden voor de
opmaak.

## Ook nog

`https://ai.vpro.nl/robots.txt` geeft nu een 404. Voeg een `public/robots.txt`
toe die verwijst naar `/robots`, of serveer hem dynamisch zodat er kan staan
wat de bezoeker meestuurde.

## Valkuilen

- **S3 is geen append-store.** Je kunt niet aan een object toevoegen. Vandaar
  één object per verzoek plus een apart aggregaat.
- **Schrijf niet vanuit de Edge runtime naar S3.** De AWS SDK is daar
  bewerkelijk; doe het in de Node-route. Daarom de splitsing.
- **Laat het antwoord niet op de logging wachten.** `waitUntil`, niet `await`.
- **Laat de pagina nooit de hele bucket listen.** Alleen `tellingen.json`.
- **Sluit `/api/agentlog` uit in de middleware-matcher**, anders logt het
  loggen zichzelf en loopt het rond.
