Build on Anahita
The web shell
A link to a profile or a post, pasted into Mastodon, Bluesky, Slack or a
message, is shown with a title, a line of text and a picture. The site it
is pasted into gets those by fetching the page and reading the tags in its
<head>. It runs no JavaScript, and the web app is nothing but JavaScript:
every page of it is the same index.html, so every link looked the same.
web-shell is what sends the web app. For every page it sends that same
index.html with the page's own tags written in. Everybody gets the same
page: a browser starts the app from it, and a crawler reads the tags. Nobody
is told apart by what they say they are.
What it sends
| Asked for | Sent |
|---|---|
A file of the build (/static/…, /manifest.json, /favicon.ico, /statics/…) |
The file. Those under /static/ are named by their contents and may be kept for a year; the rest for an hour |
A file under /static/ that is not there |
404, and not the app: a missing script answered with a page fails in a way nobody can read |
/_shell/image/<name> |
A redirect to a page's picture; see Pictures |
| Anything else | index.html with the page's tags, kept by browsers for a minute at most |
The tags are <title>, description, og:title, og:description,
og:type, og:site_name, og:url, og:image, og:image:alt,
twitter:card and <link rel="canonical">. Whatever the build had of these
is taken out first, so each is said once. Everything written is escaped: a
name or a post is somebody's text.
The site's own name and description, used for every page that has nothing
of its own to say, are read from the build's index.html, where
REACT_APP_NAME and REACT_APP_DESCRIPTION put them. They are not set
twice.
Which pages are described
| Page | Title | Description | Picture |
|---|---|---|---|
/@<alias> and its tabs: a person, a group, an event or a website at its alias |
Its name | Its bio, or what it says of itself | Its avatar |
/people/<alias> and its tabs |
The person's name | Their bio | Their avatar |
/groups/<id>, /events/<id>, /websites/<id> and their tabs |
Its name | What it says of itself | Its avatar |
/photos/<id> |
The photo's title | Its text | The photo |
/videos/<id> |
The video's title | Its text | Its poster: for a GIF, its first picture |
/articles/<id>, /topics/<id> |
Its title | The start of its text | Its cover |
/notes/<id> |
Its writer's name | The start of the note | The picture of the link it carries a card for |
/hashtags/<tag>, and /tags/<tag>, the address other servers are given |
#tag |
The site's | The site's |
| Any other | The site's name | The site's | /statics/media/ogimage.jpg |
Text is made plain, with Markdown and HTML taken out, and cut to 200
characters. A profile's address in its tags (og:url, the canonical link)
is always its alias, /@ana, however its page was reached, so a link
shared from an old /people/ana or /groups/12-garden is taken by the
networks that read it as /@ana or /@garden.
What is never described
A tag is copied by every site the link is pasted into and cannot be taken back, so the rule is stricter than who may read the page.
- The page is read as somebody who is not signed in, through the same calls and access rules as the API.
- What comes back is described only if it says of itself that it is
public, and the profile it is on says the same, and it is not deleted, archived or disabled. A public note on a profile for registered people is not described. - A reply is described only if the post at the top of its thread passes the same test.
- Settings and notification pages are nobody's to describe.
- With
SITE_READ_ACCESS=registered, a site for members, no page is described and nothing is read at all.
Everything else is sent with what the site says of itself, exactly like a page that does not exist. The answer does not tell the two apart.
What a page says is read again after a minute, so something made private stops being described within that time. What another site already copied is that site's to forget.
Pictures
A post's picture is its photo, an article's cover, a video's poster (for a GIF made into a clip, its first picture), or the picture of the link card a note carries; failing those, the site's own.
Stored pictures are read through signed addresses that stop working after a
few minutes, and a crawler keeps a picture's address for days. So a page's
picture is given as an address on the shell that does not change,
/_shell/image/person-5 or /_shell/image/post-170203, which redirects to
a freshly signed one each time it is asked for.
The redirect is made by the same rules as the page: for what is not public
it answers 404. A picture is never easier to reach than the page it is
the picture of.
What other servers ask this host
Somebody here is known elsewhere as ana@example.org, so a server that
wants to find them asks example.org, the web app's host, where they are. The
shell knows nothing of people; the API does. So three addresses are answered
with a redirect to the same path on the API, with whatever was asked:
| Address | What it is for |
|---|---|
/.well-known/webfinger |
Finding somebody by their handle |
/.well-known/host-meta |
The older way of asking where WebFinger is |
/.well-known/nodeinfo |
What software this is and how many use it |
The API's address is PUBLIC_API_URL, or https://api.DOMAIN_NAME when
that is not set. activitypub-service answers WebFinger there.
Running it
It needs the web app's build, the build/ folder that yarn build makes
in anahita-web, and reaches graph-grpc-service and file-grpc-service.
It has no database access and takes no session: it reads as a visitor.
The web app and the API have different hosts (www.example.org and
api.example.org), because their paths overlap: /people/alice is a page
on one and a call on the other. The shell answers on the web app's host,
behind whatever ends TLS for it, and the gateway goes on answering on the
API's.
docker-compose. It is not started by up, having nothing to send
without a build:
make web_shell WEB_BUILD=/path/to/anahita-web/build
It listens on port 8102. WEB_BUILD copies the build into
web-shell/web/. To have it read anahita-web's folder in place, so a new
build of the app is picked up by a restart, set WEB_BUILD_PATH in .env
to that folder and run make web_shell.
Kubernetes. Build the image with the app in it, make build_web_shell WEB_BUILD=<folder> then docker build -f dockerfiles/web-shell.dockerfile,
add k8s/base/services/web-shell.yml to the overlay's resources, and send
the web app's host to the web-shell Service in the Ingress.
index.html is read once, when it starts. Start it again after a new build.
Settings
| Variable | Value |
|---|---|
PUBLIC_WEB_URL |
The address people reach the web app at, https://www.example.org. Written into og:url, the canonical link and the address of each picture. Left out, the host each request came to is used, with https when X-Forwarded-Proto says so |
PUBLIC_API_URL, DOMAIN_NAME, ENV |
Where the API is, for what other servers ask this host. See Site identity |
WEB_BUILD_PATH |
docker-compose only: the folder mounted as the build. Defaults to web-shell/web |
WEB_BUILD_DIR |
Where the build is inside the container. Defaults to /app/web |
SITE_READ_ACCESS |
As for every service; see Configuration |
Not running it
The web app is a static site and can still be put on any static host, as
anahita-web's docs/deploying.md describes. It works the same; links to it
all show the site's own title and picture.
A site that federates and does not run the shell has to have its host do what the shell does for other servers: redirect the three addresses above to the same paths on the API. Without that, nobody here can be found by their handle.
Where the code is
| What | Where |
|---|---|
What it sends, and the /.well-known redirects |
web-shell/shell/server.go (ServeHTTP, file, page, image, wellKnown) |
| Which pages are described | web-shell/shell/pages.go (Resolve, ResolveImage) |
| What is never described | web-shell/shell/pages.go (IsPublic), with anahita-libs/policy/policy.go (MembersOnly) |
Writing the tags into index.html |
web-shell/shell/template.go (NewTemplate, Render) |
| Reading as a visitor, and signing picture addresses | web-shell/shell/graph.go |
| Settings | web-shell/config/config.go (PUBLIC_WEB_URL, WEB_BUILD_DIR), anahita-libs/site/addresses.go (FromEnv) |
| Running it | web-shell/cmd/api/main.go, make web_shell and make build_web_shell in Makefile, dockerfiles/web-shell.dockerfile, k8s/base/services/web-shell.yml |
| Tests of the rules | web-shell/shell/shell_test.go (TestWhatIsNotPublicIsNotDescribed) |
What is not built
- A location's name on
/locations/<id>, and a custom feed's on/feeds/<id>. og:image:widthandheight.- The
alternatelink to a page's ActivityPub form.
From anahitasocial/anahita-services, 2026-10-27.
Written by Claude, reviewed by Rastin Mehr · About this