halkyon/docs/adr/0011-profile-pages-and-verified-links.md
Tizian 「ティツィアーン」 e8ddb8b950
feat(profile): profile pages on the account domain, and links verified by rel="me"
A profile now has a page on the web, at the root of the account domain
(https://halkyon.eu/@raisondetredev where the operator set ACCOUNT_DOMAIN,
else on the instance's web server), so the address belongs to the
identity rather than to Learn and stays when other services share it
(ADR 0011). The page shows what the app's profile sheet shows, in English
or German, without JavaScript or cookies, asks search engines not to list
it, marks every link rel="me" so other sites can verify links to it, and
sends a visitor who asked on another host to its own address. Caddy
forwards /@… to the web server, and serves nothing else on the account
domain; compose now passes ACCOUNT_DOMAIN on, which it never did.

A link is verified as Mastodon verifies one: its page links back to the
profile's with rel="me". profile.checkLinks fetches each link's page, at
most ten times an hour, over HTTPS on port 443 and to public addresses
only, connecting to the address it checked and verifying the certificate
for the host, with three redirects, 1 MiB and ten seconds at most, and
keeps what links back on the profile (verifiedLinks, a column of a table
no device syncs). A changed link loses its check, a new handle all of
them. The app shows the page's address to copy, checks a link saved by
itself and on Check links, and marks verified links in the form and in
others' profiles; a check answered after a newer save only adds its
checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-17 19:46:29 +02:00

3.9 KiB

ADR 0011: Profile pages on the account domain, and links verified by rel="me"

  • Status: accepted
  • Date: 2026-10-15

Context

A public profile (ADR 0005, rule 2) holds up to four links: a person's site, their other accounts. Anyone can write any link, so a profile can claim a site that isn't the person's. Mastodon settles this with rel="me": a link is verified when the page it points to links back to the profile with rel="me". The same works the other way: Mastodon verifies a link to a profile page that links to the Mastodon account with rel="me". Both need a profile to have an address on the web, which until now it had not; profiles showed only inside the app.

The developer means to run more services than Learn, such as train.halkyon.eu, sharing one identity. Handles already belong to no single service: they are on the account domain (raisondetredev@halkyon.eu), not on learn.halkyon.eu.

Decision

  1. A profile's page is at the root of the account domain (https://halkyon.eu/@raisondetredev) where the operator set one (ACCOUNT_DOMAIN), else on the instance's web server, as it is reached (profilePageUrl). The address belongs to the identity, not to Learn: a shared identity service can serve it later without any link or verification breaking, and federation (ADR 0005, stage 6) can build on it. Learn serves it for now: Caddy forwards /@… on both its own domain and the account domain to the web server, and the account domain serves nothing else. Asked for on another host, a page sends the visitor to its own address (308).
  2. The page shows the profile, as the app's profile sheet does: avatar, name, address, bio, links. Server-rendered, English or German by the browser's language, no JavaScript, no cookies, nothing fetched but the avatar (its Content-Security-Policy says so), and noindex: people choose to be found in the app, not by search engines. Every link is marked rel="me", so other sites can verify it in turn.
  3. A link is verified when its page links back with an <a> or <link> whose rel holds me and whose address is the profile page's, compared without case, a trailing slash, a query or a fragment (linksBackTo, read with pub.dev's html, the Dart team's HTML5 parser, BSD-3). What is verified is kept on the profile row (verifiedLinks), which no device syncs.
  4. Checked when asked: the app asks after a save that brings a link not yet checked, and when the person taps Check links (profile.checkLinks), at most 10 times an hour, since each check fetches every page linked. A check stays until the link changes; a new handle moves the page, and drops every check.
  5. Fetching stays safe (SafeLinkFetcher): HTTPS on port 443 only; the host's addresses are looked up and every one must be public (not loopback, private, link-local, shared, unique local, multicast, documentation or reserved, nor such an IPv4 address inside an IPv6 one), and the connection goes to the address checked, the certificate verified for the host, so no second lookup can send it into the instance's own networks; at most 3 redirects, each checked anew; at most 1 MiB of HTML within 10 seconds; no proxy, no cookies; a User-Agent naming the instance.

Consequences

  • learn.halkyon.eu needs ACCOUNT_DOMAIN=halkyon.eu in deploy/.env, DNS for halkyon.eu pointing at its server, and the deploy that brings this; until then its handles are on learn.halkyon.eu, the fallback. The account domain can still change now, as nobody follows a handle yet; studysets copied before keep the old domain in their location, which only names where they came from.
  • Profiles are public on the web, not only in the app. The privacy policy says so.
  • The sites people link see the instance fetch their page, from its address.
  • Sharing the account itself with another service (signing in to train.halkyon.eu as the same person) is a separate decision, not taken here.