--- name: webdesktop-document-registers description: Search the WebDesktop document registers of municipalities, universities and agencies. module: webdesktop.mjs execution: code --- # Public Document Registers (WebDesktop) Every Estonian public body must keep a public register of the documents it sends and receives, so a citizen's teabenõue, a planning objection or a council's correspondence about a road is findable there. Most bodies are on RIK's ADR platform (`ministry-document-registries`). **Twenty-two are on WebDesktop instead** — Kiili, Jõelähtme, Muhu and Kohtla-Järve, Tartu Ülikool, Tallinna Ülikool, Muinsuskaitseamet, Tervisekassa, ERR, the Presidential Office, Tervise Arengu Instituut, the Rahvusraamatukogu, and the Kultuuriministeerium family. Those 22 sit on 18 hosts running WebDesktop 6.0.5 to 7.18.2. Despite the spread, the contract below is the same wherever it works, and **this one cannot be done with single requests**: the search lives in server session state, so it needs a cookie and a CSRF token carried across three requests, which is why this recipe declares `execution: code` and why `webdesktop.mjs` exists. ## Access - Never guess a host. `authority(name).register` in `asutused.mjs` holds the URL, and `register.platform === "webdesktop"` is what says this guide applies. `wdTypes(name)` takes the same name and does the lookup itself. - **Start with `wdTypes(body)`, always.** The URL the table stores usually opens ONE document type, and often an empty one: Kohtla-Järve's stored `tid=10504392` finds nothing, while its `tid=4945` ("Kirjavahetus") holds 56,233 records. The tree is per instance and nothing about it is guessable. - A `tid` means nothing on another instance, and neither does a field id. "Pealkiri" is field `1357` on wd.kul.ee, `4957` on one Kohtla-Järve type, `6945` on another and `58526` on adr.ut.ee. **Never copy an id across instances or across types.** Raw contract, for reading rather than for writing by hand: 1. `GET /?page=pub_search_dynobj&tid=&desktop=` — sets the `Webdesktop` cookie and, on 7.8 and later, carries ``. 2. `POST /` form-urlencoded with `page=pub_list_dynobj`, `tid`, `search=Otsi`, `_csrf_token=`, and `field__search_type=CO` + `field__search_value=`. Without the token 7.8+ answers **HTTP 500 "Invalid CSRF token"**; 6.x, 7.1.1, 7.5.1 and 7.7.3 carry no token and accept the same POST without one. 3. Paging is a plain `GET /?page=pub_list_dynobj&tid=&offset=` — 20 rows a page, and the criteria are NOT resent because the server holds them in the session. ```probe-limit claim Without the token 7.8+ answers **HTTP 500 "Invalid CSRF token"** # Kiili is 7.8.2. Step 1 opens the search form, which is what the token check # needs: it declares the token AND sets the session the server checks it # against. Measured 2026-08-02 — a POST sent with NO session cookie at all is # accepted (HTTP 200, chip and all), so the refusal belongs to step 2 of the # contract above, not to the URL. Do not read it as "a token is optional": # nothing can reach a search without step 1, because the tid and the field ids # only exist there. GET https://wd.kiilivald.ee/?page=pub_search_dynobj&tid=1099&desktop=1017 expect-status 200 expect /id="csrf-token"/ # The same search POST as the ```probe below, with `_csrf_token` left out. POST https://wd.kiilivald.ee/ body page=pub_list_dynobj&tid=1099&search=Otsi&field_1110_search_type=CO&field_1110_search_value=teaben%C3%B5ue expect-status 500 expect /Invalid CSRF token/ # The control is the paging GET from step 3 of the contract, which carries no # token and must answer 200 with rows. It says the 500 is the missing token and # not the instance, the type or the register having gone. (That the POST WITH a # token still works is the ```probe below, in the same weekly run.) control GET https://wd.kiilivald.ee/?page=pub_list_dynobj&tid=1099&offset=0 ``` ## Retrieve ```js const t = await wdTypes("Kiili Vallavalitsus"); // 52 types, with their tids const r = await wdSearch("Kiili Vallavalitsus", { type: 1099, // a tid from wdTypes title: "teabenõue", // the Pealkiri field from: "01.01.2025", to: "31.12.2025", // dd.mm.yyyy }); show(r.rows); console.log(r.totals, r.applied); const d = await wdDocument("Kiili Vallavalitsus", r.rows[0].id, { type: 1099 }); ``` - `filters` searches any other field by the label the register itself shows: `filters: { "Saatja/saaja asutus": "Riigi Kinnisvara" }`. **A name the form does not declare is refused**, because the platform ignores it and answers with the type's newest 20 records — which reads as a result. `wdFields(body, type)` lists the declared names. - `from`/`to` bind to the type's registration-date field. A type without one (Teaduste Akadeemia's "Leping") refuses them by name. - `page: 1` is the second page of 20. `totals.total` is the whole match, and `totals.shown` is only this page. ## Return `wdSearch` rows carry `ref`, `date`, `title`, `url` and `cells` — the whole row under the register's own column headings, because those differ per document type: a Kirjavahetus row has a sender where a Käskkiri row has a signatory. `wdDocument` returns the card's fields under the register's own Estonian labels (Reg nr, Saatja asutus, Dokumendi liik, Pealkiri, Täitja, **Täitmise tähtaeg** — the body's own answering deadline, worth telling a citizen who is waiting) plus `files`, the attachments. A `.asice` is a container and `fileText(url)` reads the documents inside it. Cite `source` (the search form) and each row's own `url`. Never compose a document URL from a reference number. ## Limits - **The page states no total.** WebDesktop prints 20 rows and a "Viimane" link and no record count anywhere, so `totals.total` is read off the register's last page by the module. Never state the number of rows you can see as the number of documents. - **A search that ran and matched nothing is not the end of the trail.** The ministry above a body registers the same correspondence, so when `parentRegister(name)` from `asutused.mjs` names one — 5 of these 18 have a searchable ministry — search that register before saying the documents are not public or drafting a teabenõue. An empty result raises this as a printed warning; a thin one does not, and the rule is the same. - **Five of the 22 cannot be searched, and this is not a bug to route around.** `dhs.eamt.ee` (Muusika- ja Teatriakadeemia) and `dhs.narva.ee` (Narva) serve a Smart-ID / ID-kaart / Mobiil-ID login page, so we have no way in — which is not the same as the body having no public documents, and must not be reported as if it were. `adr.112.ee` (Häirekeskus) is behind a bot check and is not WebDesktop at all; **do not attempt to get past it** — hand the citizen the link. `dhsavalik.agri.ee` (6.17.0) renders an empty result normally and a NON-empty one as its own "Unexpected error: No rows found" page, so it can only ever tell you that your search matched nothing; the module reports that as a failed retrieval, because reading it as "no such document" inverts the truth. - **wd.kul.ee is one register shared by 30 bodies** (Kultuuriministeerium and its museums). A search there is scoped to the body's own unit when its name matches one; when it does not, every count covers all thirty and the module says so out loud. - Restricted (AK) rows publish their registration data only — the title, the date and the legal basis are public, the content is not. - Kirjavahetus is the type with the correspondence a citizen usually wants; Saabunud dokument and Väljasaadetav dokument split the same thing on some instances. ## Verify A search only succeeded if the response carries the filter chip — `class="searchfilter"` — **and** its `searchfilter_text` span echoes back the criteria the server applied (`Pealkiri (sisaldab): 'teabenõue'`). - Chip present, zero rows → the search ran and matched nothing. Say that. - **Chip absent → the request was never accepted as a search.** The register answers HTTP 200 with the type's newest 20 records, which looks exactly like a result: on wd.kul.ee a misspelt field id returns 20 rows and so does a real search. Row counts cannot tell them apart. `ran: false` is this, and it must never be reported as "no such document". - Chip present but the echo does not name what you sent → that filter was dropped and the rest applied. This is real: a date range was silently ignored while the title was honoured, and only the echo said so. Known-good acceptance check, run weekly against the live register by `scripts/check-sources.mjs` (issue #82): ```probe # Step 1 opens the Kirjavahetus search form of Kiili Vallavalitsus. It sets the # Webdesktop session cookie and carries the CSRF token step 2 must send back. GET https://wd.kiilivald.ee/?page=pub_search_dynobj&tid=1099&desktop=1017 capture token ]*id="csrf-token"[^>]*content="([^"]*)" POST https://wd.kiilivald.ee/ body page=pub_list_dynobj&tid=1099&search=Otsi&_csrf_token={{token}}&field_1110_search_type=CO&field_1110_search_value=teaben%C3%B5ue expect class="searchfilter" expect /Pealkiri \(sisaldab\): 'teaben/ expect /page=pub_view_dynobj&pid=\d+/ # 423 matches on 2026-08-02. The counter sends the same term under a field id # this type does not declare — the ONLY difference. It comes back HTTP 200 with # a full page of rows and no chip, which is what "the filter was ignored" looks # like here. If it ever satisfies the three expects above, the chip has stopped # telling a search that ran from one that was never accepted, and every count # this guide produces is the register's newest 20 records instead. counter POST https://wd.kiilivald.ee/ body page=pub_list_dynobj&tid=1099&search=Otsi&_csrf_token={{token}}&field_999999_search_type=CO&field_999999_search_value=teaben%C3%B5ue ``` ## Module API wdTypes(body) -> {source, page, agency, host, version, types, units, note} wdSearch(body, {type, title, from, to, filters, unit, page}) -> {source, page, agency, host, ran, applied, unit, columns, totals, rows, note} wdDocument(body, id, {type}) -> {source, page, agency, type, fields, files, note} wdFields(body, type) -> {source, agency, type, label, fields, units}