Ga naar inhoud

Reading the console

A screen for looking at what the pipeline produced: the documents, the pseudonymised text with its tokens marked, and the combinations of PII types someone judged to identify together.

Running it

The console is mounted only when API-key authentication is configured. A screen that lists every document and the PII types found in it does not belong on an open port, and no screen beats a screen without a lock.

export WORDSWORTH_API_KEYS="mark:<key>"
uvicorn wordsworth.serve:app

Then open the hostname. Anything you type lands somewhere useful: / goes to the console, an unknown path goes to the console, and any page you are not logged in for goes to /console/login. Enter the key there; it is stored in an HttpOnly, SameSite=strict cookie for eight hours. /console/logout clears it.

A wrong key is refused at the form, not stored — otherwise a typo hands you a cookie that leads back to the same refusal, with the added confusion of having apparently logged in.

Only a path that matches no route is redirected. A 404 that a route itself answers — an unknown document, an unknown grant — stays a 404, also for a browser: it is an answer about what you asked for, and hiding it behind a redirect makes "does not exist" invisible and lets a monitoring check read the 303 as healthy.

Only a request that asks for HTML is redirected. An API client keeps getting the unchanged 401 with its JSON body: a program handed a 303 to an HTML form will try to parse the form.

The cookie is a second transport for the key, not a second check: the same middleware, the same key set, the same caller label. With an identity provider in front (see access identity), a presented key still wins — logging in here is how you choose the key route, and /console/logout hands the identity back. A browser cannot set X-API-Key on a plain navigation, and putting the key in a query string would leak it into logs, history and referrers.

/console/login is exempt from authentication — you cannot bring a key to the page that asks for one — and stays subject to rate limiting, because that is the one route where guessing pays.

Exempt from authentication, but not blind: it can still read the same verified assertion the middleware would use (see access identity). Someone the identity provider already knows is sent straight on to /console instead of seeing the key form; ?key=1 always shows the form anyway, so the key route stays reachable. With no identity provider configured, or no assertion on the request, the form shows as before.

What the pages show

/console lists documents by the name they arrived under, with their state and the PII types found, most recently touched first. A document ingested before names were recorded shows as naamloos (<first 8 of the hash>) — wordsworth-backfill-filenames can give those their names back where the files are still on disk. The full content hash is never shown as if it were a name. The types come from the pseudonyms the pipeline minted per document, not from re-running the detectors over the source text: that would answer "what would the detectors say today", which is a different question from "what did the pipeline do".

/console/documents/{id} shows the stored pseudonymised text with each token marked and labelled, plus which declared combinations the document carries in full and which it leaves untouched.

/console/combinations is where a combination is established. Record two or more types with a reason, and the page shows immediately in how many documents every one of those types occurs. Establishing which types identify together depends on the population and the context, so it is the judgement of whoever reads the documents — a rule that lives only in a file a developer edits is established by nobody.

A type no detector emits is marked "niet te zien" rather than counted as zero. That zero would read as "does not occur" when the answer is "cannot be established here".

Look and feel

The console carries the same design as the public demo (MWest2020/wordsworth-demo): the same palette in light and dark, the same families, the same token chips. Two different looks for one story is a missed opportunity, and it makes the working thing look less finished than the illustration of it.

The fonts are served from /console/static, not from a CDN. This is the screen where wordsworth's claim to sovereignty is demonstrated and also the screen people inspect; a page fetching its letters from Google refutes that claim in the network inspector, whatever the surrounding text says. Nine Latin faces, 224 KB.

That subtree is reachable without a key — the login page is too, and a login screen rendered without its letters is a broken door. The exemption covers that subtree only.

Searching

/console/search runs over the same index the API serves, within a dossier. Picking one is compulsory; "alle dossiers" is possible but has to be said. A forgotten scope must never mean the widest possible answer — that is the whole point of scoping.

It shows per hit its score and a fragment of the stored pseudonymised text. A fragment taken from a source document would look identical and prove the opposite of what this page is for.

The offered terms are examples, not a promise: whether one matches depends on the corpus that happens to be loaded. A term with no hits says so; an index that is down says that, rather than returning an empty list that reads as "nothing matched".

Revealing

The document page lists the active grants that apply to it — including grants issued to someone else. That is deliberate: the screen offers it, the door refuses it, and watching the refusal is the demonstration that the keys are role-bound.

Revoked grants are counted, not listed. One authorises nothing, and a row per piece of history buries the grant that can actually be used — six dead ones from a test in August did exactly that. The count stays, because "revocable" is half the claim and a screen showing no trace of it quietly drops that half.

Per grant there is a switch per PII type. These are not decoration: the reveal request carries the checked types and authorize() intersects them with the grant.

The page has no way out of its own. Revealing calls the existing POST /documents/{id}/reveal with the cookie you already hold — the same authorize(), the same recipient binding, the same audit record as for any other caller. There is no authorisation code, no key handling and no separate trail in the console. The rule that matters is not "the console must not reveal" but "the console must not have a second door".

The document's reveal history sits under the button. An audit trail nobody looks at is a promise, not a control; putting it on the same page is what makes it one. It separates resolved from requested: a token minted under a key this deployment no longer holds resolves to nothing, and one list would make that look identical to a reveal nobody asked anything of.

Marking which value was revealed

After a reveal the page marks which value replaced which token. It works that out by anchoring on the literal text around them, which does not change.

If there is more than one way to read the text, it marks nothing and shows the revealed text plain. That case is real: when a revealed value happens to contain the same literal that follows it, the old code took the first match and labelled Piet over 123456782 as a BSN. The reader is there to judge whether the pseudonymisation is right, so a wrong label is worse than none.

What the browser is allowed to do with it

The console sends Content-Security-Policy with frame-ancestors 'none', plus X-Frame-Options: DENY, X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer. Only on /console — the API has no browser and would get nothing from a CSP but surprises.

Framing is the one that matters. Without it, someone can put /console/documents/<id> in an invisible iframe and slide the reveal button under a cursor. They cannot read the answer — CORS is closed — but the reveal happens, and the audit trail names the victim. An append-only trail with a false name in it cannot be repaired.

A cross-site POST to /console is refused on its Origin. Logging in is what sets the caller label, so a page elsewhere could otherwise switch a visitor's label to a key the attacker knows: not a privilege gain — it is someone else's key — but false attribution, and Path=/ makes it apply to the whole API.

"Our own" means the Origin's hostname and port equal the Host header's, with a Host without a port taken to be on the default port of the Origin's scheme. Until 2026-09-24 it was a suffix match: a hostname that merely ended in ours passed, and an Origin with a port — local development on localhost:8000 — was always refused.

A request with no Origin at all is allowed. curl, the CLI and the tests send none, and locking them out would cost more than the hole. That is also the limit of this defence: it stops a browser, not a script — and a browser is exactly what the hole needed.

Two deliveries at once

Ingesting into a dossier that does not exist yet creates it. Two requests doing that at the same moment used to make one of them wait on the unique index for as long as the other took — and in /ingest that transaction spans the whole straat for a document — and then fail with a 500.

The insert now sits in a savepoint: if it collides, only that savepoint rolls back and the loser reads what the winner just wrote. The savepoint is not there to survive the collision but to save the rest of the session, which is carrying half a document's work.

What it deliberately cannot do

It reads the corpus through the same gate as /documents/{id}/anonymized and /export: a caller that WORDSWORTH_CORPUS_READ_LABELS refuses there is refused here. Until 2026-09-18 it was not, which made the console precisely the second door its own docstring forbids.

Een vraag stellen, niet een trefwoord typen

De zoekpagina embedt de vraag zelf en legt hem naast de lexicale treffers (hybrid_search: RRF voor de kandidaten, cosinus voor de volgorde). Dat is wat "stel een vraag" van "typ een trefwoord" onderscheidt — een vraag bevat zelden de woorden die in het antwoord staan. "Redenen tot afkeuring?" vindt een stuk waarin "afgewezen" staat; BM25 vindt daar niets.

Draait een instantie zonder embedder, dan blijft het BM25 en zegt het scherm dat. Stil terugvallen op iets zwakkers is erger dan het niet hebben: dan wijt een lezer de magere uitslag aan het corpus in plaats van aan de opstelling.

De dossiergrens en het onderwerp-filter gelden onverkort: een vraag stellen is geen andere zoekopdracht, alleen een andere manier om er een te formuleren.

Die lijst gaat over namen, en namen veranderen met de deur

WORDSWORTH_CORPUS_READ_LABELS bevat callerlabels, en wat een callerlabel ís hangt af van hoe er ingelogd wordt:

aanmelding callerlabel
WORDSWORTH_API_KEYS het label vóór de dubbele punt (mark:sk_live_… → mark)
Cloudflare Access het geverifieerde e-mailadres uit het JWT

Zet je de ene aanmelding om naar de andere, dan verandert elk label en weigert de lijst iedereen. Dat gebeurde op 2026-09-19: de lijst stond nog op console uit het sleuteltijdperk, terwijl de callers sinds de Access-uitrol e-mailadressen waren. Gevolg: de hele console gaf 403 aan iedereen, ook aan de beheerder.

Een 403 op een browserpad toont daarom nu een pagina die zegt ónder welke naam je binnenkwam. Zonder dat weet niemand — de gebruiker niet en de beheerder niet — welke naam er dan wél op de lijst moet. Welke namen er al op staan, zegt de pagina bewust niet.

It never reveals on its own authority. Re-identification has exactly one door: the grant-gated, audited reveal endpoint (see grants). An inspection screen that may also reveal is a second door with a friendlier name, and it is the one nobody audits.

It does not edit documents, and it cannot issue grants — that right belongs to the issuer labels. When a document has no grant, the page says so and says how to make one.

A caveat worth seeing on screen

If the deployment runs without the GLiNER/OpenAnonymiser layer, names stay in the clear in the stored text — and the console shows them, because it shows what the pipeline actually produced. That is the same finding as the PERSON leaks in the evaluation: a property of that deployment, not of the screen.