docs: add wiki-directory early plan draft

pull/8104/head
NGPixel 2 days ago
parent 2cf5b2569e
commit 45980cd286
No known key found for this signature in database

@ -0,0 +1,682 @@
# `wiki.directory` — connecting wikis to each other
**Status:** a proposal. Nothing here is implemented. The building blocks have been chosen —
[§9](#9-decisions-taken) — but every name, path and payload shape below is still illustrative.
**Covers:** what a small shared service at `wiki.directory` could do for Wiki.js instances, what each
wiki would have to expose for it to work, and which existing standards it is built from.
`wiki.directory` is a domain intended to make wikis discoverable to each other and to make linking
between them dependable. An administrator opts a site in, and in return links to it from other wikis
survive the site moving domain, its pages moving path, and the other wiki never having heard of it
before.
The hard constraint is that **`wiki.directory` must stay light**: no content database, no crawler, no
relay that traffic has to pass through. Every feature below is judged against that.
**Standards first.** Where an existing protocol does the job, it is used as written — AT Protocol's
handle pattern, WebFinger, Webmention, OpenSearch, Robust Links and Memento. What is left over and
genuinely new is small: a site identity that outlives its domain, and three page-level endpoints that
turn a page UUID into where the page is now. [§8](#8-prior-art-and-what-was-not-chosen) covers what
was considered and not chosen.
---
## 1. Goals and non-goals
**Goals**
- **Links between wikis that do not rot.** A reference from wiki A to a page on wiki B keeps working
when B changes domain or the page is moved — and when B is gone for good, still leads somewhere.
- **Discovery.** Somebody can find wikis about a subject, and a wiki can find its neighbours.
- **Connections that are cheap to make.** Previews, backlinks, shared search — each a small exchange
between two wikis rather than a service somebody has to run.
- **Works with one participant.** Anything that is only valuable once hundreds of wikis have joined
will never get them. The first feature shipped should pay for itself on day one
([§4.4](#44-a-shared-interwiki-map) is the candidate). Using standards helps here too: a Webmention
from a blog or an OpenSearch query from a browser is useful whether or not anybody else runs Wiki.js.
**Non-goals**
- **A central index of content.** The directory never holds a page, a search index or a copy of
anything a wiki publishes.
- **A relay.** Wikis talk to each other directly. The directory is asked "where is this wiki and what
is its key", and nothing else is routed through it.
- **ActivityPub-scale federation.** Following, timelines and replication of edits are a different and
much heavier project. Nothing here precludes it; nothing here needs it.
- **Non-public content.** Everything exchanged between wikis is what the public may already read on
the wiki answering. See [§3.1](#31-the-public-scoping-rule).
---
## 2. Architecture: a phone book, not a hub
The directory holds one small **signed record per participating site**. Everything else — content,
search, permissions, backlinks — stays on the wikis themselves.
### 2.1 Identity is a key, not a domain
A site that opts in ([§3.3](#33-nothing-happens-until-a-site-opts-in)) generates an Ed25519 keypair ([RFC 8032](https://www.rfc-editor.org/rfc/rfc8032)).
The **key is the site's identity**; the domain is merely where it currently lives. Changing domain is
publishing a new record signed by the same key, and nothing that refers to the site by its handle or
key has to change.
**Per site, not per instance.** One Wiki.js instance hosts several sites on different hostnames, and
they are unrelated to the outside world. Each site has its own key and its own record. The private key
is a secret of the site and is stored like any other `sensitive` value — never served, masked at the
API boundary.
### 2.2 The record
```json
{
"handle": "example",
"key": "ed25519:MCowBQYDK2VwAyEA…",
"baseUrl": "https://wiki.example.org",
"manifest": "https://wiki.example.org/.well-known/wiki",
"updatedAt": "2026-09-26T12:00:00.000Z",
"sig": "…"
}
```
- **`handle`** is the short human name, unique across the directory and used in links
(`example:Some Page`). It is a pointer to the key, not the identity itself.
- **`sig`** covers the canonical form of every other field. [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785)
(JSON Canonicalization Scheme) is the obvious candidate for "canonical".
- **`updatedAt`** is what makes a replayed old record lose to a newer one.
### 2.3 Why signed records
Because the directory cannot forge a record, it only has to **store and serve** them, and that is
what keeps it light:
- It can be a Cloudflare Worker over KV, static JSON files behind a CDN, or a git repository that
sites register in by pull request (the model the Public Suffix List and Homebrew taps use). Any of
these is a few kilobytes per site.
- **Anyone can mirror it**, and a mirror is as trustworthy as the original, because a wiki verifies
the signature and not the server it got the record from.
- **Wikis cache records for days.** The directory being down stalls new lookups; it does not break a
single link that has been resolved before.
### 2.4 Handles: the AT Protocol pattern
A site has two names, and they are verified the way AT Protocol (Bluesky) verifies a handle against an
identity: **in both directions, or not at all**.
- **Its domain**, `wiki.example.org`. The domain names the key, through either
- a DNS TXT record at `_wikidirectory.wiki.example.org` reading `key=ed25519:…` — AT's
`_atproto.<handle>` record, or
- the key published in `https://wiki.example.org/.well-known/wiki`
([RFC 8615](https://www.rfc-editor.org/rfc/rfc8615)) — AT's `/.well-known/atproto-did`.
- **Its short handle**, `example`, which is also the host `example.wiki.directory` — the equivalent of a
provider-hosted handle like `alice.bsky.social`. It is allocated by the directory and names the key
through the signed record.
**A binding counts only when both sides agree.** The record, signed by the key, says the site lives at
`wiki.example.org`; the domain, independently, says its key is that key. A record alone could claim
anybody's domain, and a domain alone could point at anybody's key, so a resolver that sees only one
half treats the binding as absent. This is the AT rule, and it is what lets the directory accept
registrations without judging them.
The well-known file is the default because the wiki can serve it itself with no DNS access; the TXT
record exists for the case where the wiki cannot answer yet — a migration in progress.
Because the domain is verified directly, **the directory is optional for anybody who already has the
domain**: `wiki.example.org` resolves to a key with no lookup at `wiki.directory` at all. The directory
adds the short handle, and the ability to find a site again after its domain has changed.
### 2.5 Rotation and recovery
- **Rotation:** the old key signs a record naming the new one. The chain is kept, so a verifier that
cached the old key can follow it.
- **Recovery** from a lost key: re-prove control of the domain currently in the record, after a
waiting period during which the old key may object. This is the one path where the directory
exercises judgement, and so the one that most needs a written policy.
- An alternative worth weighing is did:plc's arrangement: a second **recovery key**, kept offline by
the administrator, that can override anything the working key signed within a fixed window
(72 hours in did:plc). Recovery then needs no judgement from the directory, at the price of an
administrator having to keep a key somewhere safe for years.
### 2.6 The manifest
`/.well-known/wiki` describes the site and names the endpoints that are specific to this proposal:
```json
{
"handle": "example",
"key": "ed25519:…",
"name": "Example Wiki",
"description": "…",
"locales": ["en"],
"license": "CC-BY-4.0",
"logo": "https://wiki.example.org/_site/…/logo",
"software": { "name": "wikijs", "version": "3.0.0" },
"endpoints": {
"resolve": "/_api/federation/resolve",
"status": "/_api/federation/pages/status",
"describe": "/_api/federation/pages/{id}"
}
}
```
**Where a standard defines its own discovery, the manifest does not repeat it.** WebFinger lives at
`/.well-known/webfinger`; a Webmention endpoint is announced by a `rel="webmention"` link on the page
it is for; an OpenSearch description by `rel="search"` in the document head; a Memento TimeGate by
`rel="timegate"`. Listing them here as well would give a client two answers to disagree between, and
a non-Wiki.js client knows the standard mechanism and not this file.
The directory caches the manifest; the listing in [§4.5](#45-discovery) is built from those copies.
---
## 3. What a participating wiki exposes
**Specific to this proposal** — named in the manifest:
| Endpoint | Purpose | Used by |
| ---------- | ---------------------------------------------------------------- | ----------------------- |
| `resolve` | path or alias → page UUID | authoring a link, §4.1 |
| `status` | batch of UUIDs → `ok` / `moved` / `gone`, current path and title | link revalidation, §4.1 |
| `describe` | one UUID → title, description, icon, locale, last edited | previews, §4.2 |
**Standard** — discovered the standard's own way:
| Standard | What the wiki serves | Used by |
| -------- | -------------------- | ------- |
| [WebFinger](https://www.rfc-editor.org/rfc/rfc7033) (RFC 7033) | `/.well-known/webfinger?resource=acct:…` for users with a handle | mentions, §4.8 |
| [Webmention](https://www.w3.org/TR/webmention/) (W3C) | an endpoint receiving `source`/`target` notifications | backlinks, §4.3; mentions, §4.8 |
| [OpenSearch 1.1](https://github.com/dewitt/opensearch) | a description document and a results feed | federated search, §4.5 |
| [Memento](https://www.rfc-editor.org/rfc/rfc7089) (RFC 7089) | a TimeGate and a TimeMap per page, over the versions `/_version/<id>` already serves | durable links, §4.1; upstream tracking, §4.6 |
All of them are read-only except the Webmention endpoint, all are open to anonymous callers, and all
need CORS so that a reader's browser can call them directly where that is the design (previews and
embedding, §4.2; the reader's link menu, §4.1).
### 3.1 The public scoping rule
**Every endpoint answers as the public.** A remote wiki is an anonymous client, so what it learns
about a page is exactly what the guests group may read — through `groups.actorForPublic()`, the actor
the sitemap and the crawler half of the app shell are already built from, and
`pages.describePageForPublic`, which already produces the description `describe` needs. Search results,
TimeMaps and mementos are cut the same way, and history is its own permission: a version is served
only where the guests group holds `read:history` on the page as well as `read:pages`, the same terms
`/_version/<id>` already applies to anybody else. Otherwise it does not exist, as far as a remote
caller can tell.
A page the public may not read, an unpublished page and a page that does not exist are **one answer**,
for the same reason the app shell gives them one 404: the difference is not something to tell whoever
is asking. So a link to a private page on another wiki is indistinguishable from a broken one, and
that is correct.
### 3.2 Abuse
These endpoints are public and some are batch-shaped, so each needs a rate limit and a batch-size cap
— the postgres-backed counter `helpers/rateLimit.ts` already provides. The Webmention endpoint also
makes the wiki fetch a URL somebody else chose (to verify the source), so it gets the protections any
outbound fetch of a user-supplied URL needs: no private addresses, a size cap, a timeout, and
verification in a job rather than in the request. Each site keeps a block list of handles and of
source domains.
### 3.3 Nothing happens until a site opts in
Participation is **off by default and switched on per site**, from the **Discovery** card under
**Administration → General**: the "Make Discoverable in the Wiki Directory" toggle. It already exists —
the site config key `discoverable`, `false` by default (`models/sites.ts`, `api/sites.ts`,
`AdminGeneral.vue`) — and nothing reads it yet. General is behind `manage:sites`, which is the
permission registering needs anyway (§6).
**Off**, which is how every site starts, nothing in this document exists for that site. No key is
generated and no record is published; `/.well-known/wiki` and every endpoint in the tables above
answer 404; no `rel` links or `Link` headers are added to its documents; and it makes no request to
`wiki.directory` or to any other wiki.
**On**, the site:
- generates its key if it has none, and publishes its record;
- serves the manifest and the endpoints above, subject to the finer controls below;
- appears in the browsable listing (§4.5), which is what the toggle's own label promises.
**Turning it off again** publishes a signed deactivation of the record rather than simply deleting
it, so the directory, its mirrors and every wiki holding a cached copy learn the handle is withdrawn
instead of waiting for the cache to run out. It then stops serving everything above. The key is kept,
so switching back on is the same site under the same handle. Links other wikis already hold keep
working as ordinary links, because their `href` is direct (§4.1); what stops is their ability to
follow the site after a move.
**Finer controls, in the same card**, shown only while the toggle is on. The controls and their
defaults are a proposal:
| Control | Default when on | What turning it off withholds |
| ------- | --------------- | ----------------------------- |
| Show in the public listing | on | the §4.5 listing only — wikis that already know the handle still resolve it |
| Accept backlinks | from registered wikis | the Webmention endpoint (§4.3); the choices are off, registered wikis only, or everyone |
| Allow search from other wikis | on | the OpenSearch description and results (§4.5) |
| Allow previews and embedding | on | `describe`, and the CORS on it (§4.2) |
| Allow mentions of users | off | WebFinger (§4.8) |
| Link to other wikis | on | the outgoing half: resolving other sites' handles, fetching the interwiki map, sending Webmentions |
`resolve` and `status` have no control of their own: answering for its own pages is what
participating *is*, so they follow the toggle itself.
**Deliberately not in the card: whether versions are public.** That is the guests group's
`read:history` (§6) — a permission, set where permissions are set, and one this feature must not
change on the site's behalf.
**The instance's `offline` setting overrides all of it.** An offline instance makes no outbound
request for icons today, and it makes none for this either: it neither publishes a record nor
resolves one, whatever the card says.
---
## 4. Features
### 4.1 Durable cross-wiki links
The headline feature. Pages have stable UUIDs, and `/i/:pageId` already resolves one to wherever the
page currently lives (`helpers/pageLinks.ts`, `kind: 'pageId'`). So a durable reference is
**handle + page UUID**, resolved in two steps:
1. **handle → current `baseUrl`**, from the directory record (cached). This absorbs a domain move.
2. **`baseUrl/i/<uuid>` → current path**, by wiki B's own redirect. This absorbs a page move.
A third case — **B is gone for good** — is what Robust Links and Memento are for, below.
**Authors write names; the wiki stores identities.** The author writes
`[[example:Guides/Getting Started]]` — the wikilink syntax with an interwiki prefix. At save time wiki A
calls B's `resolve` endpoint for that path and keeps the UUID alongside the name: the name so the
source stays readable, the UUID as the real target.
#### The stored render: a Robust Link
A render is produced once, in the editor's browser, so whatever the link needs later has to be in the
markup from the start. [Robust Links](https://mementoweb.org/robustlinks/spec/) is the existing
convention for exactly that — an ordinary `<a>` that also records *when* it was made and *where a
copy from that time is*, so that a reader can get to what the author cited after the live page has
changed or gone:
```html
<a href="https://wiki.example.org/guides/getting-started"
data-versiondate="2026-09-26"
data-versionurl="https://wiki.example.org/_version/9f1c…"
data-wiki-ref="example/5b0c7e2a-…">Getting started guide</a>
```
- **`href`** is B's direct URL as `resolve` reported it at save time. A click goes straight to B and
never touches the directory.
- **`data-versiondate`** is the Robust Links date of linking. On its own it is enough to ask any
Memento TimeGate — B's own or a web archive's — for the version nearest to it, years later.
- **`data-versionurl`** is a memento of the page as it was when linked: B's own `/_version/<id>` URL
where the public may read B's history (below), a web archive snapshot where one was made, or
absent.
- **`data-wiki-ref`** is the one attribute this proposal adds: the durable reference, from which the
current location can always be worked out again.
Robust Links' rule is that when `href` is the live page, the version attributes are what is recorded,
and `data-originalurl` is used only when `href` points at the memento. The live page is the right
`href` for a wiki, which is linking to a living document rather than citing a fixed text.
The sanitiser in `models/rendering.ts` has to allow these four attributes on `<a>`, if it does not
already.
#### Keeping the `href` right
`pageLinks` gains a `remote` kind, so that the link table — which today deliberately records nothing
leaving the wiki — tracks these. A scheduled task posts batches of UUIDs to each linked site's
`status` endpoint:
- `moved` updates the stored `href`, or — more conservatively — reports "N links point to moved
pages" for an editor to accept (open question, [§10](#10-open-questions)).
- `gone` flags the link as broken on the page and in a link report. `gone` is more useful than a bare
404: B can answer with a tombstone carrying the title the page had and, where it was merged, where it
went.
- A site whose handle has been deactivated, or that has not answered for long enough, is marked as
**gone as a whole**, and its links switch to their archived form (below).
#### What a reader sees
A link carrying `data-wiki-ref` gets a small menu in the reader's browser, in the manner of the
reference `robustlinks.js`, but drawn by the app:
- **Current page** — `href`, or `https://example.wiki.directory/i/<uuid>` when revalidation has said the
`href` is stale and it has not been rewritten yet.
- **As it was on 26 September 2026** — `data-versionurl` when present, otherwise a TimeGate asked for
`data-versiondate`.
- **Copy durable link** — the redirector form, which is what a link pasted into an email should be,
since there is no wiki behind that email to revalidate it.
#### Memento: the version as linked
[Memento](https://www.rfc-editor.org/rfc/rfc7089) (RFC 7089) is the HTTP-level half of Robust Links:
a **TimeGate** takes an original URL and an `Accept-Datetime` and redirects to the **memento** — the
version current at that time — and a **TimeMap** lists every memento there is. A wiki with page
history is a natural Memento origin server; MediaWiki has had an extension doing exactly this for a
decade.
- **B serves its own mementos.** Every version already has a URL of its own, `/_version/<id>`, which
is the memento. What Memento adds around it: each page document carries
`Link: <…/timegate>; rel="timegate", <…/timemap>; rel="timemap"`; the TimeGate maps a datetime to the
`pageHistory` version current then and redirects to its `/_version/<id>`; and that document is served
with `Memento-Datetime` and a `rel="original"` link back to the page. This is what `data-versionurl`
points at, and it means "as it was when linked" works with no third party involved — where the
public may read the page's history (§3.1, §6).
- **Web archives cover B being gone.** When B no longer answers, the same `href` and
`data-versiondate` go to a public TimeGate instead — the Internet Archive's, or an aggregator such as
Memento Time Travel that asks several archives at once. That only finds something if an archive
captured the page, which is why A may *ask* for a capture at link time (open question, §10).
- **The TimeMap doubles as the history endpoint** for upstream tracking ([§4.6](#46-reusing-content-across-wikis)),
so there is no separate `history` endpoint to specify.
A reader's browser cannot set `Accept-Datetime` on a plain navigation, so the "as it was" item asks the
TimeGate with `fetch` and then navigates to the `Location` it answers with. B's TimeGate therefore
needs CORS that allows the `Accept-Datetime` header and exposes `Location`.
#### The redirector
`https://example.wiki.directory/i/<uuid>` costs almost nothing to run: wildcard DNS on
`*.wiki.directory` and a Worker that reads the handle's record and answers 302 to
`<baseUrl>/i/<uuid>`. Its job is the **current** location and nothing more. It cannot do the archival
fallback — it knows a UUID, not what URL the page had or when it was linked — which is precisely why
that information lives in the Robust Links attributes of the render rather than anywhere central.
### 4.2 Previews and embedding
Once A can address `example/<uuid>`, it can ask B to `describe` it:
- **Hover cards** on cross-wiki links — title, summary, icon, last edited — in the way Wikipedia's
page previews work. Fetched in the reader's browser, cached briefly, never stored by A.
- **Embedding a section** as a block: `<block-remote-page ref="example/<uuid>#section">`, a read-only
excerpt with an attribution line. Fetched by the reader's browser over CORS, so A holds nothing.
The manifest's `license` decides whether the block agrees to render at all, and the excerpt goes
through the same sanitiser rules as any render.
### 4.3 Backlinks across wikis: Webmention
"Referenced by 3 other wikis", on B's page. This is [Webmention](https://www.w3.org/TR/webmention/)
as written, with a signature layered on top for wikis in the directory.
**The standard part:**
1. B announces its endpoint on every page document: `Link: <https://wiki.example.org/_webmention>;
rel="webmention"`. The app shell (`renderAppShell`) is where that header goes.
2. When A saves a page that links to a page on B, A fetches the target, discovers the endpoint, and
POSTs `source=<A's page URL>&target=<B's page URL>` as `application/x-www-form-urlencoded`.
3. B answers `202 Accepted` and verifies in a job: it fetches `source` and checks that it really links
to `target`. The crawler half of A's app shell already puts the page's render in the document, so
the link is there to find with no JavaScript run — and fetching it anonymously is also what proves
the source page is public.
4. When A removes the link, or deletes the page, it sends the same notification again. B re-verifies,
finds the link gone or the page answering 404/410, and drops the backlink.
**The addition:** a Webmention from a wiki in the directory is signed with that site's key using
[HTTP Message Signatures](https://www.rfc-editor.org/rfc/rfc9421) (RFC 9421), with the handle as the
key id. B checks it against the directory record.
- **Signed** Webmentions from registered sites are accepted once verified.
- **Unsigned** ones — any blog, any IndieWeb site, any other wiki engine that speaks Webmention — are a
per-site setting: refused, accepted, or held for moderation. Holding needs a moderation queue that
does not exist yet.
The directory sees none of this. Because the transport is standard, a Wiki.js site gets backlinks from
the whole Webmention-speaking web on day one, whether or not a single other wiki has joined.
The same endpoint carries **cross-wiki mentions** (§4.8): a mention is a Webmention whose target is the
mentioned user's profile URL.
### 4.4 A shared interwiki map
The cheapest feature and the one that is useful with a single participant. MediaWiki has had
interwiki prefixes for twenty years, but each installation keeps its own table by hand.
`wiki.directory/interwiki.json` would be one shared map:
- **Registered sites** resolve durably, through §4.1.
- **Everything else** is a URL template — `wikipedia:` → `https://en.wikipedia.org/wiki/$1`,
`rfc:` → `https://www.rfc-editor.org/rfc/rfc$1`, `arch:`, `mdn:` and so on — with no durability
and no claims beyond "this is where that prefix points". Wikimedia's global interwiki map on Meta is
the obvious seed, rather than starting empty.
Each site can add prefixes of its own on top, and override shared ones. The map is static JSON, so it
is served from a CDN and cached by every wiki.
### 4.5 Discovery
- **A browsable listing** at `wiki.directory`, built from the cached manifests of discoverable sites
that have not turned off **Show in the public listing** (§3.3) — a site may want durable links
without appearing in a directory. Topics, languages, license, size, recent activity. Static, regenerated
on a schedule.
- **Federated search over OpenSearch, run by the wiki and not the directory.** Described below.
- **Collections.** A signed list of handles — "every team wiki of one organisation". Members get a shared
navigation strip, prefix resolution scoped to the collection, and search across the collection by
default. This suits organisations that run many small wikis more than any single feature above.
#### OpenSearch
Each site publishes an [OpenSearch 1.1](https://github.com/dewitt/opensearch) description document
and announces it in the document head, which the app shell writes:
```html
<link rel="search" type="application/opensearchdescription+xml"
title="Example Wiki" href="/_site/opensearch.xml">
```
Under `/_site`, the per-site resource controller, rather than at the root: a root file name has to be
added to `RESERVED_ROOT_FILES` and taken away from pages, and nothing about OpenSearch needs the root.
The description declares URL templates by result type:
- **`text/html`** → the wiki's own search page. This alone makes browsers offer the wiki as a search
engine, which is worth having independently of anything else here.
- **`application/atom+xml`** → results as an Atom feed with the `opensearch:` elements
(`totalResults`, `startIndex`, `itemsPerPage`). This is the interoperable form, and what A reads
from a peer that is not Wiki.js.
- **`application/json`** → the same results with what a Wiki.js peer can show and Atom has no place
for: locale, icon, tags, the page UUID.
Federated search is then: an administrator picks peer sites; the search page offers "also search in
…"; A fetches each peer's description once, fills in the template, and merges the results. There is
no central index, and the directory's only part was telling A where the peers are — and for a peer
named by its domain, not even that.
### 4.6 Reusing content across wikis
- **Fork with an upstream.** Import a page from B into A and record `derivedFrom: example/<uuid>` with the
memento it was copied from. A later look at B's TimeMap shows whether newer mementos exist —
"upstream has changed since this was copied" — and the two mementos give the diff. Shared
procedures, style guides and templates are the use case. The copy's attribution line is itself a
link to B, so B learns about the fork through an ordinary Webmention.
- **Translations across wikis.** A page's translation group (`localeGroupId`) currently links pages of
one site. A group with a remote member would let a community run a French wiki that is formally a
translation of an English one, and send the locale switcher across.
### 4.7 A block registry
`.wkblock` ([wkblock.md](./wkblock.md)) already makes a block a single, checksummed file installed by
upload. `wiki.directory` is the natural catalogue for them:
- Authors sign packages with a key of their own, registered the same way a site's is.
- **Administration → Content Blocks** gains a browse view that lists community blocks and installs one
by URL, with the signature checked before the importer's own checks run.
- The directory holds metadata, the checksum and the signature; the package itself can stay on the
author's release page.
This answers the first of `wkblock.md`'s known gaps — that a signature proves nothing useful without
a notion of publishers — by providing the publishers. The same shape fits the YAML-only module types
(analytics and comment providers) and icon sets, all of which are data rather than server code.
### 4.8 Identity across wikis
The heaviest idea, and the one to leave until the rest has proven itself.
#### Mentions: WebFinger, then Webmention
`@alice@example` in a page on A names Alice on B. Handles are already unique per wiki (`users.handle`),
so the pair is unique across the directory.
1. **`example` → `wiki.example.org`**, through the directory. A mention written with the domain,
`@alice@wiki.example.org`, skips this step — the same form the fediverse uses.
2. **WebFinger** ([RFC 7033](https://www.rfc-editor.org/rfc/rfc7033)):
`GET https://wiki.example.org/.well-known/webfinger?resource=acct:alice@wiki.example.org` answers a JRD
whose `http://webfinger.net/rel/profile-page` link is Alice's profile, `/_user/<userId>`. Only an
account with a handle has an answer; one without is not mentionable, exactly as it is locally.
3. **Webmention** to B, with `target` set to that profile URL. B verifies it as any other (§4.3) and
notifies Alice.
**A caveat from verification:** Webmention requires the source document to contain the link, and the
crawler half of the app shell carries the page render only. Comments are rendered in the browser at
display time, so a mention inside a comment is not in any document B could fetch. Mentions are
therefore limited to page content, unless the crawler document grows the page's comments.
#### "Sign in with your wiki"
Each site can act as an OIDC or IndieAuth provider for its own users, with WebFinger and the
directory supplying discovery. Alice comments on C with her identity from B and no account on C.
The trust questions here — what C should believe about an identity B vouches for, and what happens
when B is hostile — are real and are not answered by anything in this document.
---
## 5. What already exists
| Piece | Where | Relevant to |
| ----- | ----- | ----------- |
| Page UUIDs and `/i/:pageId` | `db/schema.ts` `pages.id`, `frontend/src/router/routes.js` | §4.1 |
| Link table with kinds | `db/schema.ts` `pageLinks`, `helpers/pageLinks.ts` | §4.1 revalidation |
| Wikilink syntax | `frontend/src/renderers/modules/markdown-it-wikilinks.js` | §4.1, §4.4 prefixes |
| Page history | `pageHistory` | §4.1 mementos, §4.6 |
| A URL per version | `/_version/:versionId`, `GET /sites/:siteId/versions/:versionId` | §4.1 mementos |
| The app shell: head, headers, crawler body | `helpers/appShell.ts` | §4.1 `Link` headers, §4.3, §4.5 |
| The public as an actor | `groups.actorForPublic()` | §3.1 |
| A page described for the public | `pages.describePageForPublic` | `describe`, §4.2 |
| Per-site resource routes | `controllers/site.ts` (`/_site`) | §4.5 OpenSearch description |
| Sitemap with `hreflang` | `controllers/rootFiles.ts` | §4.5 |
| Translation groups | `pages.localeGroupId` | §4.6 |
| Unsigned block packages | `helpers/wkblock.ts`, `blocks/package.mjs` | §4.7 |
| User handles and profile pages | `users.handle`, `/_user/:userId` | §4.8 WebFinger |
| Postgres-backed rate limiter | `helpers/rateLimit.ts`, `models/rateLimits.ts` | §3.2 |
| The Discovery card and `discoverable` | `AdminGeneral.vue`, `models/sites.ts`, `api/sites.ts` | §3.3 |
None of WebFinger, Webmention, OpenSearch or Memento is implemented anywhere today.
---
## 6. Prerequisites in the wiki itself
**A page UUID has to be a permanent identity**, and today it is not quite one:
- **A storage import mints new ids.** `pageMeta` in `helpers/storageFiles.ts` writes no `id` into a
page file's front matter, so a site rebuilt from its disk or git target comes back with every page
under a new UUID, and every durable link pointing at it breaks. Writing the id, and honouring it on
import when it does not collide, closes that.
- **A restore into a different site re-keys everything** — by design, per
[wkbackup.md §4](./wkbackup.md#4-identity-derive-uuids-do-not-map-them), so a copy can sit beside
the original. That is right for a copy and wrong for a site that is *moving* to a new instance. A
move should either restore into the same site id, or the re-keying should be recorded so that the
old UUIDs keep redirecting.
**Mementos are only as public as history is.** Every version already has a URL of its own —
`/_version/<id>` — so nothing new is needed to address one. But reading it takes `read:history` on the
page as well as the right to read the page, and nothing is granted by default, so on most wikis the
public holds no `read:history` anywhere. There, the "as it was when linked" link would answer 404 to
every reader on another wiki, and `data-versionurl` should not be written at all. A site that wants
the version as linked to work grants guests `read:history` where it wants that — this proposal must
not loosen it on the site's behalf. `data-versiondate` still leads to a web archive either way.
What Memento adds on top of the existing route is headers — `Memento-Datetime` and `rel="original"`
on the `/_version/<id>` document the app shell serves — plus the TimeGate and TimeMap.
**The site's key needs a home**: a site-level secret, masked like other `sensitive` values, included in
a backup (or a restored site cannot prove it is the same site) and excluded from anything served.
**Naming.** The Webmention endpoint is not called an "inbox" anywhere in the product: `/_inbox` is
already the user's own messages and review queue.
**Registering is the Discovery card on Administration → General** (§3.3), and `manage:sites` — the
permission General already requires — is the one that covers what it touches. No new global permission is proposed here; if one turns out to be needed,
that is the maintainer's call.
---
## 7. Suggested order
1. **Identity:** keys per site, signed records, the two-way handle check, `/.well-known/wiki`, the
directory as a store of records — all switched by the existing `discoverable` toggle, with the
finer controls of §3.3 added as each feature they govern arrives. Everything else depends on it.
2. **The interwiki map** (§4.4) and the **OpenSearch description** (§4.5). Both small, and both
valuable before anyone else has joined.
3. **Durable links** (§4.1) as Robust Links, with revalidation and the redirector, then **previews**
(§4.2). The headline feature, and the page-identity prerequisites in §6 come with it.
4. **Memento** on B (headers on `/_version/<id>`, TimeGate, TimeMap) and **Webmention** (§4.3), in
either order.
Both are standard on the wire, so both are useful with peers that are not Wiki.js.
5. The **block registry** (§4.7), then everything else as demand shows up.
At the end of step 4 the directory is still a key-value store of a few kilobytes per site, a static
JSON map and a static listing — which is the constraint this document started from.
---
## 8. Prior art, and what was not chosen
| Considered | What it is | Why not |
| ---------- | ---------- | ------- |
| **did:webvh** | A DID method: a hash-chained, signed log hosted on the site itself, with a self-certifying id that survives a domain move | Closest fit for identity, and needs no registry. Not chosen: a log, hash chain, multikey encoding and Data Integrity proofs are a good deal more machinery than one signed record, and it still cannot find a site again after a move — a lookup from its id to the current domain is needed either way, which is the directory. |
| **did:plc** | Bluesky's DID method: a central directory of signed operation logs | The design in §2 is essentially this, minus the DID document format. Its recovery-key window is kept as an option in §2.5. |
| **did:web** | A DID resolved from `/.well-known/did.json` on a domain | The identity *is* the domain, so it cannot move — the problem this proposal exists to solve. |
| **DOI / Handle System, ARK, PURL, w3id.org** | Persistent identifier registries that redirect | Durable per *resource*: every page that moves needs its registry entry changed, which is the heavy central database this avoids. Here the directory knows sites, and each wiki answers for its own pages. |
| **ActivityPub** (e.g. Ibis) | Server-to-server federation; Ibis mirrors articles and edits across instances | Replication, following and timelines are the non-goal in §1. |
| **Federated Wiki** | Ward Cunningham's wiki, where pages are forked between sites and search covers a "neighbourhood" | The conceptual ancestor of §4.5–4.6, with its own protocol and a small community. Inspiration, not a dependency. |
| **Linked Data Notifications** | A W3C inbox protocol | Webmention does the same job with less, and far more of the web already speaks it. |
| **NodeInfo** | The fediverse's `/.well-known/nodeinfo` software and usage metadata | Overlaps the manifest's `software` block. Could be served alongside it later; nothing depends on it. |
---
## 9. Decisions taken
Taken on 2026-09-26.
- **Participation is opt-in, per site**, from the Discovery card on **Administration → General** —
the existing "Make Discoverable in the Wiki Directory" toggle, with finer controls in the same card
(§3.3). Off, a site publishes nothing and contacts nobody.
- **Identity and handles are the design in §2**: a per-site Ed25519 key, a signed record, and handles
verified in both directions after the AT Protocol pattern. did:webvh was considered and not adopted
(§8).
- **WebFinger** resolves users for mentions (§4.8).
- **Webmention** is the transport for backlinks and mentions, with RFC 9421 signatures added for
registered sites (§4.3).
- **OpenSearch** describes each site's search, for browsers and for federated search (§4.5).
- **Robust Links** is the form of a cross-wiki link in the stored render, and **Memento** (RFC 7089)
is how the version as linked is reached — from B's own history while B exists, from web archives
after (§4.1). This replaces the "direct vs redirector" choice this document used to leave open: the
`href` is direct, and the redirector is the current-location fallback and the copyable form.
---
## 10. Open questions
- **Handle policy.** First come, first served? Reserved names? What happens to a handle whose domain
has lapsed and been bought by somebody else — who wins, the key or the domain?
- **Governance.** Recovery (§2.5) and disputes need somebody to decide them. Who, under what written
rules, and how does a site appeal? Or does a did:plc-style recovery key remove the need?
- **Rewriting stored renders.** When revalidation finds a moved page, should the `href` in the render
be rewritten — given that a render is otherwise only ever produced by an editor's browser — or
should it only be reported for an editor to accept?
- **Capturing at link time.** Should A ask a web archive to capture B's page when the link is saved,
so that `data-versionurl` has something outside B to point at? It makes the gone-for-good case work,
and it also tells a third party what the wiki links to.
- **Privacy of the redirector.** Should it log anything at all?
- **Unsigned Webmentions.** The card offers the choice (§3.3). Should "everyone" hold them for
moderation rather than accept them — and holding needs a moderation queue that does not exist yet.
- **WebFinger exposure.** The site decides whether users can be mentioned at all (§3.3). Once it
has, answering for every account with a handle confirms to anybody that the account exists. Is
having a handle consent enough, or is being findable a separate per-user choice?
- **Non-Wiki.js wikis.** The three specific endpoints in §3 are small enough for a MediaWiki extension
or a DokuWiki plugin to implement, and everything else is already standard. Is that a goal? If so,
the protocol wants its own document separate from any one implementation — as `wkblock.md` is
separate from its writer and reader.
- **Licensing.** Embedding (§4.2) and forking (§4.6) copy content between sites. Is the manifest's
`license` field enough, or does a page need a license of its own?
- **Linking out without joining.** Every outgoing feature sits under the toggle, so a site that
wants `[[other:Page]]` links to resolve durably has to publish a record of its own first. Is a
consume-only mode worth having — resolving others' handles with nothing published — or is joining
a fair price for using the directory?
- **The defaults in the card** (§3.3) are a first guess, in particular "Show in the public listing"
being on: the toggle's label says *discoverable*, which argues for it.
Loading…
Cancel
Save