{"version":1,"site":"https://www.anahita.io","api":"https://www.anahita.io/api/anahita.json","sources":[{"key":"services","title":"Anahita Services","side":"Server","repo":"anahitasocial/anahita-services"},{"key":"web","title":"Anahita Web","side":"Apps","repo":"anahitasocial/anahita-web"}],"pages":[{"source":"services","slug":"README","title":"Documentation","group":null,"url":"https://www.anahita.io/docs/services/","sections":[{"id":"","heading":null,"level":1,"markdown":"Documentation for anahita-services, the Go microservices behind Anahita."},{"id":"where-to-begin","heading":"Where to begin","level":2,"markdown":"Ways in, by what you are doing. Each is a short page of links, in the\norder to read them.\n\n| To | Read |\n| --- | --- |\n| Understand the ideas behind it | [Why Anahita](https://www.anahita.io/docs/services/why-anahita/) |\n| Put an instance on the web | [Setting up](https://www.anahita.io/docs/services/setting-up/) |\n| Change how it looks, what it says and what it allows | [Customising](https://www.anahita.io/docs/services/customising/) |\n| Run it from day to day: upgrades, storage, accounts, reports | [Operating](https://www.anahita.io/docs/services/operating/) |\n| Write an app or a service against it | [Developing](https://www.anahita.io/docs/services/developing/) |\n| Ask an AI agent about Anahita, from the docs | [Anahita MCP](https://www.anahita.io/docs/services/ai-agents/) |\n| Call the services from your own, over gRPC | [Services over gRPC](https://www.anahita.io/docs/services/grpc/) |\n| Know what the licences allow | [Licensing](https://www.anahita.io/docs/services/licensing/) |\n\nChanging Anahita's own code? Begin with [AGENTS.md](https://github.com/anahitasocial/anahita-services/blob/main/AGENTS.md)."}]},{"source":"services","slug":"getting-started","title":"Getting started","group":"Start here","url":"https://www.anahita.io/docs/services/getting-started/","sections":[{"id":"","heading":null,"level":1,"markdown":"Running the whole stack locally with Docker Compose."},{"id":"requirements","heading":"Requirements","level":2,"markdown":"- [GNU make](https://www.gnu.org/software/make/)\n- A recent [Docker](https://www.docker.com/products/docker-desktop)\n\nThe web app is a separate repository, `anahita-web`, with its own README."},{"id":"configure","heading":"Configure","level":2,"markdown":"Create a `.env` file at the project root. Every variable is described in [Configuration](https://www.anahita.io/docs/services/configuration/)."},{"id":"storage-for-uploads","heading":"Storage for uploads","level":3,"markdown":"docker-compose runs [Garage](https://garagehq.deuxfleurs.fr/), a small\nS3-compatible object store, so no cloud account is needed. Add these lines to\n`.env` to use it:\n\n~~~sh\nSTORAGE_ENDPOINT=http://garage:3900\nSTORAGE_PUBLIC_ENDPOINT=http://localhost:3900\nSTORAGE_REGION=garage\nSTORAGE_BUCKET=anahita\nSTORAGE_ACCESS_KEY=GKa0b1c2d3e4f5a6b7c8d9e0f1\nSTORAGE_SECRET_KEY=a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\nSTORAGE_PATH_STYLE=true\n~~~\n\n`make up` and `make up_build` prepare the store on first start (the bucket and\nthat access key) by running `scripts/storage-init.sh`, which is safe to repeat.\nIts files are kept in `db-data/garage/`.\n\nThe key is published here and in the script, and Garage's own configuration\n(`garage/garage.toml`) holds published secrets too. All of it is for local\ndevelopment only.\n\nTo use AWS S3 or another provider instead, see\n[Configuration › Storage](https://www.anahita.io/docs/services/configuration/#storage). Moving files from one\nstore to another is a job for [rclone](https://rclone.org/): `rclone sync`\nbetween two S3 remotes copies a bucket as it is."},{"id":"starting","heading":"Starting","level":2,"markdown":"From the root of the project:\n\n~~~sh\nmake up_build\n~~~\n\nIf the code has not changed, subsequent runs can just be `make up`. To stop\neverything:\n\n~~~sh\nmake down\n~~~\n\nThe API is then at `http://localhost:80`. The front end lives in a separate\nrepository (`anahita-web`) and runs at `http://localhost:3000`, which is\nthe origin nginx allows for CORS.\n\nStart with `make`, not with `docker-compose up` directly. The services talk\nto each other over mutual TLS, and `make up` and `make up_build` first create\nthe development certificates in `./grpc-certs` (`scripts/grpc-certs-init.sh`).\nWithout them the services stop at start-up and say so. See\n[Internal calls](https://www.anahita.io/docs/services/configuration/#internal-calls)."},{"id":"first-run","heading":"First run","level":2,"markdown":"On a fresh database:\n\n1. **Set `SUPER_ADMIN_EMAIL`** before starting. auth-service creates the\n   account with no usable password and prints a set-password link, valid for\n   48 hours, to its log. It also emails it, but the log works before SMTP is\n   configured:\n\n   ~~~sh\n   docker compose logs auth-service | grep -i \"super admin\"\n   ~~~\n\n2. **Choose a `REGISTRATION_MODE`.** Unset, the installation is closed and\n   nobody can sign up or accept an invitation.\n3. **Replace the placeholder Terms and Privacy Policy** in the web app before\n   anyone joins — every member is asked to accept whatever is there."},{"id":"developing","heading":"Developing","level":2,"markdown":"Rebuild a single service while working on it:\n\n~~~sh\nmake auth\n~~~\n\nwhich stops that service, removes its image, rebuilds the binary, and\nstarts it again. Available service targets:\n\n`abuse_report`, `activitypub`, `atproto`, `actor`, `auth`, `export`, `feed`, `file`, `graph`,\n`hashtag`, `identity_image`, `like`, `link_preview`, `location`, `mail`,\n`nginx`, `notification`, `photo`, `recommendation`, `search`, `socialgraph`,\n`text`, `video`, `web_shell`\n\nEach has a matching `build_*` target that only compiles the Linux binary,\nand `make migration` runs the migration job on its own. How the job makes\nthe database, and how to change the schema, are in\n[Architecture](https://www.anahita.io/docs/services/architecture/#the-database).\n\n- **`video` and `export` each start two containers,** the service and its\n  worker. The video worker's image has ffmpeg in it, so nothing is installed\n  on your machine. Videos are off until `VIDEO_UPLOADS=on`: see\n  [Videos](https://www.anahita.io/docs/services/videos/).\n- **`web_shell` is not started by `make up`.** It sends the web app's build,\n  so it has to be given one: `make web_shell WEB_BUILD=<the build folder>`.\n  Developing the web app does not need it; see [The web shell](https://www.anahita.io/docs/services/web-shell/).\n- **`make fedi_lab` starts the federation lab,** which is not part of `make up`\n  either: see [The federation lab](https://www.anahita.io/docs/services/federation-lab/).\n- **After a change to `anahita-libs` or to a `.proto` file,** rebuild\n  everything with `make up_build`: every service is built with the library."},{"id":"tests","heading":"Tests","level":2,"markdown":"~~~sh\nmake test\n~~~\n\nThat runs every module's tests, the check of `legacy/upgrade.sql`,\n`make docs_check`, which fails on a link between the docs that leads nowhere\n(a page that is not there, or a `#heading` it does not have), and\n`make schema_test`, which needs Docker: it builds a database from\n`schema/init.sql` and one from the PHP schema through `legacy/upgrade.sql`,\nand fails on any column or index where the two differ. Among\nthem are the API reference's checks: a route registered without a\ndescription fails by name, a reference in `docs/api/` that is not what the\ncode describes now fails, and so does a handler test that gets an answer the\nreference does not describe: a status it does not list, or a field it does\nnot name. After changing a route, or a struct it takes or sends, run\n\n~~~sh\nmake api_docs\n~~~\n\nand commit what it writes with the change ([The API](https://www.anahita.io/docs/services/api/#describing-a-route)).\nTo read the reference, open `docs/api/index.html` in a browser.\n\nTests that need a database or a store are skipped unless they are told where\none is, so that nothing is ever run against the database you develop on. Give\nthem a throwaway MySQL with the schema loaded, and take it away afterwards:\n\n~~~sh\ndocker run -d --name anahita-test -e MYSQL_ALLOW_EMPTY_PASSWORD=1 \\\n  -e MYSQL_DATABASE=fresh -p 13309:3306 mysql:9.6\n# once it answers:\ndocker exec -i anahita-test mysql fresh < migration-job/schema/init.sql\n\ncd graph-grpc-service && \\\n  ACCESS_TEST_DSN='root@tcp(127.0.0.1:13309)/fresh?parseTime=true' go test -p 1 ./...\n\ndocker rm -fv anahita-test\n~~~\n\n| Variable | Tests it turns on |\n| --- | --- |\n| `ACCESS_TEST_DSN` | `graph-grpc-service`: who can see what, purging, relationships. Run with `-p 1`, and without `-v` |\n| `AUTH_TEST_DSN` | `auth-service`: codes |\n| `ABUSE_TEST_DSN` | `abuse-report-service`: cases |\n| `RECOMMENDATION_TEST_DSN` | `recommendation-service`: the signals |\n| `VIDEO_TEST_DSN` | `video-service`: uploads |\n| `EXPORT_TEST_DSN` | `export-service`: exports |\n| `ACTIVITYPUB_TEST_DSN` | `activitypub-service`: what the sweep finds that the bus lost |\n| `FEDERATION_TEST_DSN` | `anahita-libs/federation`: the queue of what is sent to other servers. It makes and drops a table of its own |\n| `STORAGE_TEST_ENDPOINT`, `STORAGE_TEST_PUBLIC_ENDPOINT`, `STORAGE_TEST_REGION`, `STORAGE_TEST_BUCKET`, `STORAGE_TEST_ACCESS_KEY`, `STORAGE_TEST_SECRET_KEY` | `file-grpc-service` and `scripts/s3-migration` against a real bucket: see [Tested stores](https://www.anahita.io/docs/services/configuration/#tested-stores) |"},{"id":"other-targets","heading":"Other targets","level":2,"markdown":"Run `make help` for the full list, which also covers building and pushing\nthe images by hand (`build_dockerfiles`, `push_dockerfiles`; a tagged\nversion is published by a workflow: [Images](https://www.anahita.io/docs/services/deploying/#images)), Swarm (`swarm_up`,\n`swarm_down`), and local Kubernetes clusters: minikube (`k8s-start`,\n`k8s-build`, `k8s-deploy`, `k8s-cleanup`) and k3d (`k3d-start`, `k3d-build`,\n`k3d-deploy`, `k3d-cleanup`). The manifests are in `k8s/`, and\n[Deploying](https://www.anahita.io/docs/services/deploying/) covers them, for live sites and for trying them\nlocally."},{"id":"with-an-ai-agent","heading":"With an AI agent","level":2,"markdown":"Anahita's MCP server puts these docs and the API reference in the AI agent\nyou use, so that it answers from them, not from memory. In Claude Code,\neither:\n\n```sh\nclaude mcp add --transport http anahita https://mcp.anahita.io/mcp   # hosted\nclaude mcp add anahita -- npx -y anahita-mcp                          # on your computer\n```\n\nClaude Desktop, Gemini, Cursor, VS Code and the rest: [Anahita MCP](https://www.anahita.io/docs/services/ai-agents/)."}]},{"source":"services","slug":"setting-up","title":"Setting up","group":"Start here","url":"https://www.anahita.io/docs/services/setting-up/","sections":[{"id":"","heading":null,"level":1,"markdown":"The pages to read, in order, to put an Anahita site on the web: for a school,\na newsroom, a library, a research group, a non-profit or any community of\nyour own. The web app is a separate repository, anahita-web; its pages are\nlinked where they come in.\n\n1. [Getting started](https://www.anahita.io/docs/services/getting-started/): run the whole stack on your own\n   machine first, and sign in to it. Then the web app's\n   [Getting started](https://www.anahita.io/docs/web/getting-started/).\n2. [Configuration](https://www.anahita.io/docs/services/configuration/): every setting. The ones to decide\n   before anybody signs up are the site's name\n   ([Site identity](https://www.anahita.io/docs/services/configuration/#site-identity)), who may join\n   ([Registration](https://www.anahita.io/docs/services/configuration/#registration)), what visitors may read\n   ([Privacy of the installation](https://www.anahita.io/docs/services/configuration/#privacy-of-the-installation)),\n   and the first super administrator\n   ([First super administrator](https://www.anahita.io/docs/services/configuration/#first-super-administrator)).\n3. [Registration and agreements](https://www.anahita.io/docs/services/registration/): how people join, the\n   approval queue, invitations, and your Terms of Service and Privacy Policy.\n4. [Privacy](https://www.anahita.io/docs/services/privacy/): who can see a post, a profile and the site, and the\n   two settings that decide it for the whole installation.\n5. [Deploying](https://www.anahita.io/docs/services/deploying/): a live site on Kubernetes, on any provider.\n   Hostnames, certificates, object storage, and the database. The web app is\n   built and hosted on its own:\n   [Deploying the web app](https://www.anahita.io/docs/web/deploying/).\n6. [Federation](https://www.anahita.io/docs/services/federation/#setting-it-up): joining the fediverse and\n   Bluesky. It is off until you turn it on, and each profile then chooses for\n   itself. Choose the names first: they are for good.\n7. The services a site may run or leave out. Each page says what happens\n   without it:\n   - [Videos](https://www.anahita.io/docs/services/videos/#running-it-and-not-running-it)\n   - [Link previews](https://www.anahita.io/docs/services/link-previews/#running-it-and-not-running-it)\n   - [Recommendations](https://www.anahita.io/docs/services/recommendations/#running-it-and-not-running-it)\n   - [Download your data](https://www.anahita.io/docs/services/data-export/#running-it-and-not-running-it)\n   - [The web shell](https://www.anahita.io/docs/services/web-shell/#running-it), which gives each shared link\n     its own title and picture\n   - [Websites](https://www.anahita.io/docs/services/websites/): a website's feed as a profile to follow\n\nNext: [Customising](https://www.anahita.io/docs/services/customising/), then\n[Operating](https://www.anahita.io/docs/services/operating/)."}]},{"source":"services","slug":"configuration","title":"Configuration","group":"Start here","url":"https://www.anahita.io/docs/services/configuration/","sections":[{"id":"","heading":null,"level":1,"markdown":"Create a `.env` file at the project root before the first run. It is not\ncommitted, and neither are the signing-key and TLS `.pem` files it points at.\n`docker-compose.yml` is the reference for which service reads which variable.\n\nOn Kubernetes the same variables go in an overlay's `config.env` and\n`secrets.env` instead: see [Deploying](https://www.anahita.io/docs/services/deploying/)."},{"id":"shared","heading":"Shared","level":2,"markdown":"| Variable | Used by | Value |\n| --- | --- | --- |\n| `ENV` | all | `dev`, `staging` or `prod`. Decides the default public URLs below, and in `dev` all mail goes to `DEVELOPER_EMAIL` |\n| `DSN` | services with a database | MySQL DSN, e.g. `root:root@(mysql:3306)/anahita?parseTime=true` |\n| `REDIS_ADDR`, `REDIS_PASSWORD` | HTTP services | Redis for sessions, e.g. `redis:6379` |\n| `REDIS_DB` | HTTP services | Optional. The Redis database number, `0` when left out. Useful when several installations share one Redis |\n| `REDIS_TLS` | HTTP services | `true` to connect to Redis over TLS, which most managed Redis and Valkey services require. The certificate is checked against the host in `REDIS_ADDR`. Default `false` |\n| `SECURE_COOKIE_KEY` | HTTP services | Key that signs session cookies. auth-service also derives from it the key that sign-in codes are hashed with, so changing it invalidates the codes outstanding at that moment |\n| `AMQP_ADDR` | publishers and consumers | RabbitMQ, e.g. `amqp://rabbitmq:5672` |\n| `OAUTH_ISSUER` | all HTTP services | Issuer URL that tokens are signed with and checked against |\n| `OAUTH_JWKS_URL` | all HTTP services | Where public signing keys are fetched. Defaults to auth-service inside the network |\n| `WEB_ORIGIN` | nginx, actor-service | The web app's origin, e.g. `https://example.org`. For nginx, the one origin allowed to call the API with the session cookie. For actor-service, where the link in an event's calendar file leads; without it the link is left out. Default `http://localhost:3000` |"},{"id":"client-addresses","heading":"Client addresses","level":3,"markdown":"auth-service takes a request's address from `X-Forwarded-For`, reading from the\nright and skipping proxies, so an entry a client typed is never used. It treats\nloopback, link-local and private addresses as proxies. That fits nginx in\ndocker-compose and an ingress in a cluster. A proxy on a public address in\nfront of auth-service would be recorded as the client.\n\nEach proxy has to append to the header, which nginx does with\n`$proxy_add_x_forwarded_for`. The address keys the per-address rate limits and\nis recorded in the sign-in log.\n\nA sign-in's city and country are looked up at ipapi.co, with a 3 second limit,\nwhich means that third party is sent the address of everybody who signs in.\nSet `IP_LOCATION_URL=off` to stop the lookup; the sign-in log then shows no\ncity or country. Set it to another address to use a service of your own that\nanswers the same way (`GET <url>/<ip>/json/` returning `city` and\n`country_name`).\n\nPrivate addresses are never sent, and are recorded as `localhost`,\n`development`. Seeing that in a deployed sign-in log means the address reaching\nauth-service is a proxy's, not the client's."},{"id":"internal-calls","heading":"Internal calls","level":3,"markdown":"The HTTP services call graph-grpc-service, file-grpc-service and\nmail-grpc-service over gRPC. Those calls use mutual TLS: they are encrypted,\nand each side presents a certificate signed by the installation's own\ncertificate authority. A caller without one is refused.\n\n| Variable | Used by | Value |\n| --- | --- | --- |\n| `GRPC_TLS` | all | `on` or `off`. Unset means `on`, except with `ENV=dev`. `docker-compose.yml` sets `on` |\n| `GRPC_TLS_DIR` | all | The directory holding `tls.crt`, `tls.key` and `ca.crt`. Default `/run/secrets/grpc` |\n\n- **In docker-compose** `scripts/grpc-certs-init.sh` makes a development\n  authority and one certificate in `./grpc-certs`, which every service\n  mounts. `make up` and `make up_build` run it.\n- **On Kubernetes** cert-manager issues a certificate per gRPC service and\n  one for the callers: see [Deploying](https://www.anahita.io/docs/services/deploying/#certificates-between-the-services).\n- **Renewal needs no restart.** A service looks at the files at most once a\n  minute and uses a replaced certificate or authority from then on.\n- **With `on` and no certificates,** a gRPC service refuses to start and a\n  caller refuses to call. Nothing falls back to plain text.\n- **`off`** is for an installation where a service mesh such as Linkerd\n  encrypts and authenticates these calls instead.\n\nThe server's certificate must name the host that was dialled, so the names in\n`FILE_SERVICE_ADDR`, `GRAPH_SERVICE_ADDR` and `MAIL_SERVICE_ADDR` have to be\non the certificates.\n\nA service written in another language joins in by loading the same three\nfiles in its gRPC library: present `tls.crt` and `tls.key`, trust `ca.crt`,\nand, for a server, require a client certificate."},{"id":"site-identity","heading":"Site identity","level":2,"markdown":"Read by auth-service, and mail-grpc-service for the branding. `PUBLIC_DOMAIN`\nand `PUBLIC_API_URL` are for what speaks to other servers\n(`anahita-libs/site`): `activitypub-service`, whose other settings and the\norder to do things in are in [Federation](https://www.anahita.io/docs/services/federation/#setting-it-up).\n`atproto-service` reads and writes Bluesky and needs none of them; its\nsettings are under [Federation](#federation).\n\n| Variable | Value |\n| --- | --- |\n| `DOMAIN_NAME` | The installation's domain. Public URLs are derived from it on staging and production |\n| `PUBLIC_DOMAIN` | Optional. The domain in a person's handle, `ana@example.org`, as other servers will know them. Left out, it is `DOMAIN_NAME` on a live site, `staging.DOMAIN_NAME` on a staging one, or the host of `PUBLIC_WEB_URL` when that is set. **Not to be changed once anybody here has been spoken of to another server:** they would become somebody else there |\n| `FEDERATION_PROTOCOLS` | Optional, read by auth-service. The other networks the installation takes part in, with commas between: `activitypub` when `activitypub-service` is run for real, `atproto` when `atproto-service` is. NodeInfo's `protocols` claims the first; both are in `metadata.networks`, which the web app's front page reads to say that the site is part of the fediverse, with Mastodon and Bluesky. Left out, nothing is claimed. See [Federation](https://www.anahita.io/docs/services/federation/#setting-it-up) |\n| `PUBLIC_API_URL` | Optional. The address other servers reach the API at. Left out, `https://api.DOMAIN_NAME` on a live site and `https://api.staging.DOMAIN_NAME` on a staging one. The same warning applies: the address of every person and post, as told to other servers, begins with it |\n| `SITE_NAME` | Name shown on auth pages and in email |\n| `ANAHITA_INSTANCE_NAME` | How this installation introduces itself to other servers, published as `nodeName` in NodeInfo. Optional — falls back to `SITE_NAME`, then `DOMAIN_NAME` |\n| `ANAHITA_INSTANCE_DESCRIPTION` | A sentence about this installation, published as `nodeDescription`. Optional, and left empty rather than generated when unset |\n| `SITE_LOGO_URL` | The logo at the top of the sign-in pages and of every email. Optional: unset, the pages show Anahita's mark (`auth-service/web/static/images/logo.svg`), and emails its PNG, which auth-service serves at `<OAUTH_ISSUER>/static/images/logo.png`. An absolute URL to a PNG, since mail clients drop SVG |\n| `SYSTEM_EMAIL` | The From address |\n| `SUPPORT_EMAIL`, `SUPPORT_PHONE`, `SUPPORT_WEBSITE` | Contact details on the web app's public `/support` page, published through NodeInfo. Phone and website are optional |\n| `TOTP_ISSUER` | Optional. The label authenticator apps show; derived from `DOMAIN_NAME` when unset |\n| `FRONTEND_URL` | Optional, not in `docker-compose.yml`. The web app's URL used in links. Defaults to `http://localhost:3000` in dev and `https://DOMAIN_NAME` in production |\n| `AUTH_URL` | Optional, not in `docker-compose.yml`. auth-service's public URL. Defaults to `http://localhost` in dev and `https://auth.DOMAIN_NAME` in production |"},{"id":"rules-and-promises","heading":"Rules and promises","level":2,"markdown":"What the site says about itself to the people who use it (roadmap row 102):\nits **rules**, which everybody agrees to by ticking each one; its\n**promises**, on its About page under \"Our promises to you\"; and the\n**rule suggestions** a new group's administrators may start from. They are\nthe operator's, in a JSON file read by auth-service and served on\n`GET /site` (package `auth-service/about`).\n\n| Variable | Value |\n| --- | --- |\n| `SITE_RULES` | `on` unless `off`. On, everybody ticks the site's rules before using it, and again when their version is raised; off, nobody is asked. Whoever installs Anahita makes its creator's pledge either way ([Registration](https://www.anahita.io/docs/services/registration/#the-sites-rules)). Read by auth-service and published as `metadata.siteRules` in NodeInfo; see [Registration](https://www.anahita.io/docs/services/registration/#the-sites-rules) |\n| `SITE_DEFAULTS_PATH` | Where the file is. `/app/defaults/site.json` in the image, which holds `auth-defaults/site.json`; docker-compose mounts the working tree's copy there. On Kubernetes, mount a ConfigMap over that path to change it |\n\n```json\n{\n  \"rules\": {\n    \"version\": \"1\",\n    \"items\": [\n      { \"title\": { \"en-CA\": \"No hate.\", \"fr-FR\": \"Pas de haine.\" },\n        \"body\": { \"en-CA\": \"Don't attack or put down people for …\" } }\n    ]\n  },\n  \"promises\": [\n    { \"title\": { \"en-CA\": \"Changed your mind?\" },\n      \"body\": { \"en-CA\": \"You have {{days}} days to undo deleting your account. Just sign in again.\" } }\n  ],\n  \"rule_suggestions\": [\n    { \"title\": { \"en-CA\": \"Be kind and respectful\" } }\n  ]\n}\n```\n\n- **Every text carries its own translations,** by the web app's language\n  names. A language left out falls back to another of its own (`fr-CA` to\n  `fr-FR`), then to English, so a file may be written in one language.\n- **Raising the rules' `version` asks everybody to agree again.** A\n  correction that leaves it as it is asks nobody. A version is digits and\n  dots, up to three parts.\n- **`{{days}}`** is filled in with the days a deleted account can be brought\n  back (30), so a promise never says what the site does not do.\n- **At most ten rules and ten promises;** `\"items\": []` is a site with no\n  rules.\n- **It is read again when it changes,** without a restart. What it leaves\n  out is as it comes, and a file that cannot be understood is not taken:\n  the built-in one is used, and auth-service says so in its log. With\n  docker-compose, change the file in place: an editor that writes a new\n  file in its place is not seen through a mount of one file until the\n  container is started again.\n- The built-in file is `auth-service/about/default.json`, and\n  `auth-defaults/site.json` is the same; a test keeps them so."},{"id":"registration","heading":"Registration","level":2,"markdown":"Read by auth-service.\n\n| Variable | Value |\n| --- | --- |\n| `REGISTRATION_MODE` | `open`, `approval`, `invite` or `closed`. **Unset means `closed`**: no self-serve sign-up, and outstanding invitations stop working |\n| `INVITES_FROM` | `registered`, `administrators` (default) or `super-administrators` — the minimum role that may invite. Each admits that role and every role above it. In `approval` mode, an invitation from somebody who is not an administrator goes to the approval queue |\n| `GROUPS_FROM` | `registered`, `administrators` (default) or `super-administrators` — the minimum role that may make a group. Read by actor-service, which enforces it, and by auth-service, which publishes it in NodeInfo as `groupsFrom`; give both the same value |\n| `WEBSITES_FROM` | `registered`, `administrators` (default) or `super-administrators` — the minimum role that may connect a website. Read by syndication-service, which enforces it, and by auth-service, which publishes it in NodeInfo as `websitesFrom`; give both the same value. See [Websites](https://www.anahita.io/docs/services/websites/#settings), with `WEBSITE_HOURLY_CAP` and `WEBSITE_READ_EVERY` |\n| `LOCATIONS_FROM` | `registered`, `administrators` (default) or `super-administrators` — the minimum role that may add a location. Read by location-service, which enforces it, and by auth-service, which publishes it in NodeInfo as `locationsFrom`; give both the same value. Editing and deleting a location stay with administrators. See [Hashtags and locations](https://www.anahita.io/docs/services/hashtags/#who-may-add-a-location) |\n| `EVENTS_FROM` | `registered` (default), `administrators` or `super-administrators` — the minimum role that may make an event of their own. Read by actor-service, which enforces it, and by auth-service, which publishes it in NodeInfo as `eventsFrom`; give both the same value. An event made for a group is for that group's administrators whatever this says |\n| `INVITE_PENDING_MAX` | Outstanding invitations one person may have at once. Default `5` |\n| `INVITE_PENDING_MAX_ADMIN` | The same for administrators. Default `100` |\n| `TOS_URL`, `PRIVACY_URL` | Where the Terms and Privacy Policy are read: a path on the web app or a full URL. Default `/legal/tos` and `/legal/privacy`. Their versions are held by the web app, not here |"},{"id":"privacy-of-the-installation","heading":"Privacy of the installation","level":2,"markdown":"Read by every service, the same way, so they are set once: in `.env` for\ndocker-compose, in the shared `config.env` for Kubernetes. They take effect on\na restart.\n\n| Variable | Value |\n| --- | --- |\n| `SITE_READ_ACCESS` | What somebody who is **not signed in** may read: `public` (the default), everything that is public; `preview`, only the start of it; `registered`, nothing, a members-only site |\n| `MIN_CONTENT_ACCESS` | The widest audience anything **new** may be given: `public` (the default) or `registered` |\n\nA value that is not understood is treated as `registered`, the strictest, and\nthe log says so. A typo must not be what makes everything public again.\n\nWhat each one does, how they combine, and how to check them is in\n[Privacy](https://www.anahita.io/docs/services/privacy/#what-the-installation-allows)."},{"id":"first-super-administrator","heading":"First super administrator","level":2,"markdown":"Read by auth-service.\n\n| Variable | Value |\n| --- | --- |\n| `SUPER_ADMIN_EMAIL` | Seeds the first super administrator at boot, only when none exists — safe to leave set |\n| `SUPER_ADMIN_USERNAME` | Optional. Derived from the email when unset |\n| `SUPER_ADMIN_NAME` | Optional display name |"},{"id":"oauth-signing-and-encryption","heading":"OAuth signing and encryption","level":2,"markdown":"Read by auth-service.\n\n| Variable | Value |\n| --- | --- |\n| `OAUTH_SIGNER_BACKEND` | `local` (default) or `kms` |\n| `OAUTH_PRIVATE_KEY_PATH` | Signing key for `local`, mounted from `./oauth-signing.pem` |\n| `OAUTH_KMS_KEY_ALIAS` | Signing key for `kms` |\n| `OAUTH_SEED_CLIENT_PATH` | First-party clients seeded at first boot. See [Seeding clients](https://www.anahita.io/docs/services/oauth/#seeding-clients) |\n| `OAUTH_KEY_LIFETIME_DAYS` | Optional, not in `docker-compose.yml`. Signing key rotation, default `90` |\n| `CRYPT_BACKEND` | Encryption of secrets at rest: `local` (default) or `kms` |\n| `CRYPT_LOCAL_KEY` | Key for `local`: exactly 32 characters. Keep it out of the database and its backups. activitypub-service keeps every person's signing key under it too, and atproto-service the Bluesky tokens of connected accounts, so both must be given the same value: see [Federation](https://www.anahita.io/docs/services/federation/#signing-keys-and-signed-requests) |\n| `AWS_KMS_KEY_ID` | Key for `kms` |\n| `CRYPT_KMS_REGION` | Region of that KMS key. Optional: defaults to the AWS region in the environment (`AWS_DEFAULT_REGION`) |\n| `MAX_DB_CONNECTIONS` | Optional database pool size |\n\n**What is encrypted at rest.** Two-factor (TOTP) secrets, and the private half\nof a locally stored token signing key. Both use `CRYPT_BACKEND`. With the\nsigning key sealed, reading the database is not enough to mint tokens: the\nreader also needs `CRYPT_LOCAL_KEY` (or access to the KMS key).\n\nA signing key written by a version that stored it in the clear is sealed the\nfirst time auth-service starts, and the log says how many were.\n\n**If `CRYPT_LOCAL_KEY` changes or is lost,** auth-service cannot open the\nsigning key and refuses to start, and existing TOTP secrets become unreadable.\nTo recover sign-in, delete the rows in `oauth_signing_keys` and restart: a new\nkey is created, and everybody signs in again. People with two-factor turned on\nneed it reset by an administrator."},{"id":"storage","heading":"Storage","level":2,"markdown":"Read by file-grpc-service. Uploaded files (avatars, covers, photos) go to an\nobject store that speaks the S3 API: AWS S3, another provider's, or one you\nrun yourself such as [Garage](https://garagehq.deuxfleurs.fr/). There is no\noption to store files on the local disk.\n\n| Variable | Value |\n| --- | --- |\n| `STORAGE_ENDPOINT` | Where file-grpc-service reaches the store, e.g. `https://s3.gra.io.cloud.ovh.net` or `http://garage:3900`. **Leave empty for AWS S3**, whose address follows from the region |\n| `STORAGE_PUBLIC_ENDPOINT` | The address written into the links browsers load files from. Set it when it differs from `STORAGE_ENDPOINT`, which it does whenever the store is reached by an internal name. Defaults to `STORAGE_ENDPOINT` |\n| `STORAGE_REGION` | The region requests are signed for; `AWS_REGION` or `AWS_DEFAULT_REGION` is read when it is left out. A store without regions still expects one: use whatever it is configured with (Garage: `garage`) |\n| `STORAGE_BUCKET` | The bucket. It must exist and be private |\n| `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY` | Credentials. Leave both empty on AWS to use a role or profile |\n| `STORAGE_PATH_STYLE` | `true` to address files as `endpoint/bucket/key` rather than `bucket.endpoint/key`. Self-hosted stores usually need it. Default `false` |\n| `STORAGE_SSE` | `AES256` to ask the store to encrypt each file at rest, or `none`. Default `AES256` on AWS, `none` when `STORAGE_ENDPOINT` is set |\n| `STORAGE_SEND_ACL` | `true` to mark each upload private with an ACL header. Default `true` on AWS, `false` when `STORAGE_ENDPOINT` is set. Set `false` for an AWS bucket with ACLs disabled, the default for new buckets: see [A bucket on AWS S3](https://www.anahita.io/docs/services/deploying/#a-bucket-on-aws-s3) |\n| `STORAGE_ALLOWED_TYPES` | Comma-separated content types that may be stored. Default `image/jpeg,image/png` |\n\nThe two headers default to off for other stores because a store that does not\nimplement one refuses the upload. Turn them on if yours supports them.\n\n**What may be stored.** file-grpc-service looks at the bytes of every upload,\nignores the type the caller declared, and stores only what is on the allowed\nlist, under its real type. An HTML page or an SVG named `photo.png` is refused.\nA store serves a file back with the type it was given, and a browser acts on\nthat type, so this is what keeps an upload from running script.\n\n**Links expire.** Files are private. Each link handed to a browser is signed\nand valid for 15 minutes."},{"id":"earlier-aws-names","heading":"Earlier AWS names","level":3,"markdown":"Before the `STORAGE_*` names existed, the same settings were read from\n`AWS_DEFAULT_REGION`, `AWS_FILES_S3_BUCKET`, `AWS_ACCESS_KEY_ID` and\n`AWS_SECRET_ACCESS_KEY`. They still work when the `STORAGE_*` one is unset, so\nan installation on AWS S3 needs no change. `AWS_FILES_S3_ASSETS_DIR` was\nrequired but never used, and is no longer read.\n\n`AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` and\n`AWS_DEFAULT_REGION` are also read by auth-service when `CRYPT_BACKEND` or\n`OAUTH_SIGNER_BACKEND` is `kms`."},{"id":"tested-stores","heading":"Tested stores","level":3,"markdown":"| Store | Settings beyond bucket and keys | Result |\n| --- | --- | --- |\n| AWS S3 | none (`STORAGE_ENDPOINT` empty) | In use by the reference installation |\n| Garage 2.1 | `STORAGE_ENDPOINT`, `STORAGE_REGION=garage`, `STORAGE_PATH_STYLE=true` | Passes the storage test. Also accepts uploads sent with `STORAGE_SSE=AES256` and `STORAGE_SEND_ACL=true` |\n\nTo check another store, point the `STORAGE_TEST_*` variables at a bucket on it\nand run the test in `file-grpc-service/data/services`\n(see `storage_integration_test.go` for the variables):\n\n~~~sh\ncd file-grpc-service && STORAGE_TEST_ENDPOINT=https://… go test ./data/services/ -run Real -v\n~~~"},{"id":"locations","heading":"Locations","level":2,"markdown":"Read by location-service, and by actor-service for an event whose maker asked\nfor a map of its address. An address is turned into coordinates by Photon\nfirst, then Nominatim, both open-source geocoders built on OpenStreetMap.\n\n| Variable | Value |\n| --- | --- |\n| `GEOCODER_PHOTON_URL` | Where Photon is reached. Default: the public `https://photon.komoot.io`. `off` leaves it out |\n| `GEOCODER_NOMINATIM_URL` | Where Nominatim is reached. Default: the public `https://nominatim.openstreetmap.org`. `off` leaves it out |\n| `GEOCODER_CONTACT` | An email address or URL for whoever runs this installation, sent to the geocoders in the `User-Agent`. The public Nominatim's usage policy asks for one |\n\nThe public instances are shared services with strict limits, and they see every\naddress a member types. Both can be self-hosted; point the variables at your\nown. At least one has to stay on for location-service, because a location cannot\nbe saved without coordinates. actor-service starts without either, and events\nare then saved with their address and no map. An event's address is sent to\na geocoder only when \"Show a map\" is ticked on its form."},{"id":"notifications","heading":"Notifications","level":2,"markdown":"Read by notification-service. See [Notifications](https://www.anahita.io/docs/services/notifications/#how-long-they-are-kept).\n\n| Variable | Value |\n| --- | --- |\n| `NOTIFICATIONS_KEEP_DAYS` | Optional. How many days a notification is kept after it was read. `90` when unset; `0` keeps them for good. An unread one is kept until it is read |"},{"id":"trending","heading":"Trending","level":2,"markdown":"Read by hashtag-service and location-service: over what span of time the\nhashtags and locations trending are counted. A hashtag or a location trends by\nhow many profiles used it in that time, each once: the writers of public\nposts carrying it, by when the posts were written, and the public profiles\ntagged with it, by when they were tagged. See\n[Hashtags and locations](https://www.anahita.io/docs/services/hashtags/#trending).\n\n| Variable | Value |\n| --- | --- |\n| `TRENDING_WINDOW` | Optional. `daily`, `weekly`, `monthly` or `yearly`. Left out, `daily` when `FEDERATION_PROTOCOLS` names a network, where the fediverse brings enough every day, and `monthly` on an installation only its own members post to |\n\nWhatever it starts from, a window that holds fewer than ten is widened to\nthe next, a week, a month, then a year, so the list is not empty on a quiet\nday; the answer says which window it is of, and the web app shows it. A\nclient may still ask for a window (`time_window`). See [Hashtags and\nlocations](https://www.anahita.io/docs/services/hashtags/)."},{"id":"mail","heading":"Mail","level":2,"markdown":"Read by mail-grpc-service.\n\n| Variable | Value |\n| --- | --- |\n| `SMTP_HOST`, `SMTP_PORT` | The SMTP server |\n| `SMTP_USER`, `SMTP_PASSWORD` | Optional. Leave both empty for a relay that needs no authentication |\n| `SMTP_SECURE` | `true` for implicit TLS (port 465); `false` (default) for STARTTLS (port 587) |\n| `DEVELOPER_EMAIL` | Where every message goes when `ENV=dev`. Required in development — the service refuses to start without it |\n\nMail goes out over SMTP and nothing else, so any provider works — Mailgun's or\nSES's relay, Postmark, an internal MTA, or a local sink such as\n[mailpit](https://mailpit.axllent.org/). Message templates live in\n`mail-grpc-service/templates/`."},{"id":"abuse-reports","heading":"Abuse reports","level":2,"markdown":"Read by abuse-report-service.\n\n| Variable | Value |\n| --- | --- |\n| `REASONS_PATH` | An installation's own list of reasons people can give when they report something. Optional: unset, the list built into the service is used. See [Using your own reasons](https://www.anahita.io/docs/services/abuse-reports/#using-your-own-reasons) |\n| `SPAM_REMOVAL_MAX_DAYS` | How many days after it was made an account may still be removed as spam. 30 unless said. Read by graph-grpc-service, which refuses an older one, and auth-service, which publishes it as `metadata.spamRemovalMaxDays`. See [Removing as spam](https://www.anahita.io/docs/services/account-lifecycle/#removing-as-spam) |\n\nSomebody may file at most 20 reports an hour. That limit, like the ones on\nsigning in, is counted in Redis by the shared limiter in\n`anahita-libs/ratelimit`, and is not enforced while Redis cannot be reached."},{"id":"account-cleanup","heading":"Account cleanup","level":2,"markdown":"Read by auth-service, for the [accounts list](https://www.anahita.io/docs/services/account-lifecycle/#the-accounts-list)\na super administrator uses to find accounts nobody uses.\n\n| Variable | Value |\n| --- | --- |\n| `ACCOUNT_DORMANT_DAYS` | How many days without activity make an account `dormant` in that list. Optional: unset, 730 (two years). It only sorts accounts into tiers; nothing is removed because of it |\n| `ACCOUNT_GRACE_DAYS` | How many days a deleted profile can be brought back before it is purged: a person by signing in again, a group or an event by an administrator. 30 unless said. Read by auth-service (the dates it tells people, `metadata.accountGraceDays` in NodeInfo, the site's promise), graph-grpc-service and the account sweeper (when it purges); all three must have the same. Not kept per profile: changing it moves the date of every profile already waiting |\n\nTwo things about purging are fixed in the code and are not settings: one purge request names\nat most 500 accounts, and a purge works through an account 500 posts at a\ntime. How often the sweeper runs is its CronJob's schedule, in\n`k8s/base/jobs/sweeper-cronjob.yml`.\n\ngraph-grpc-service and the sweeper need `FILE_SERVICE_ADDR` to remove a purged\naccount's uploaded files. Both have it in `docker-compose.yml` and in the\nKubernetes manifests.\n\nService addresses (`MAIL_SERVICE_ADDR`, `FILE_SERVICE_ADDR`,\n`GRAPH_SERVICE_ADDR`) and the defaults directories (`DEFAULTS_PATH`,\n`ACTOR_FEATURES_PATH`, `SEARCH_RANKING_PATH`) are set in\n`docker-compose.yml` itself and need no entry in `.env`. They default to\n`/app/defaults`, `/app/defaults/actor_features` and\n`/app/defaults/search_ranking.json`, where the images keep their copies;\ndocker-compose points them at the working tree so an edit shows without a\nrebuild. The last is the weights search orders \"Most relevant\" by; see\n[Search](https://www.anahita.io/docs/services/search/#tuning-it)."},{"id":"federation","heading":"Federation","level":2,"markdown":"Read by `activitypub-service` and `atproto-service`, the two services that\nspeak to other networks. How to set either up, in order, is in\n[Federation](https://www.anahita.io/docs/services/federation/#setting-it-up) and, for Bluesky,\n[Running it](https://www.anahita.io/docs/services/federation/#running-it).\n\n| Variable | Value |\n| --- | --- |\n| `FEDERATION_PROTOCOLS` | Read by auth-service: the networks to announce in NodeInfo, `activitypub`, `atproto`, or both with a comma. Left out, none |\n| `ACTIVITYPUB_LAB` | Development only, for [the federation lab](https://www.anahita.io/docs/services/federation-lab/): lets activitypub-service reach the lab's private addresses. Never on a live site |\n| `ATPROTO_APPVIEW_URL`, `ATPROTO_JETSTREAM_URL`, `ATPROTO_CDN_URL`, `ATPROTO_PLC_URL` | Where atproto-service reads Bluesky from; all optional, Bluesky's own when left out. See [Running it](https://www.anahita.io/docs/services/federation/#running-it) |\n| `FEDERATION_LOCATIONS` | Read by activitypub-service. Whether a post from another server is tagged with the places it names, Pixelfed's `location` above all: each matched to a location here, or made. `on` unless `off`. See [Federation](https://www.anahita.io/docs/services/federation/#posts-coming-in) |\n| `LINK_PREVIEW_URL` | Where `link-preview-service` is, for the card of a link in a post from elsewhere. Left out, such posts have no card |\n| `REMOTE_MEDIA_DAYS` | Optional, read by `activitypub-service` and `atproto-service`. How many days the pictures of a post from another server are kept in storage after the post was last touched; then they are removed, and copied again when somebody asks to see them. Thirty when left out; `0` keeps them for good. See [Pictures that are removed](https://www.anahita.io/docs/services/federation/#pictures-that-are-removed) |\n| `REMOTE_UNFOLLOWED_DAYS` | Optional, read by the same two. How many days the posts of somebody on another server are kept once nobody here follows them and the posts have been left alone; then they are removed, and after them the person, when nothing here refers to them. Thirty when left out; `0` keeps them for good. See [Posts and people that are removed](https://www.anahita.io/docs/services/federation/#posts-and-people-that-are-removed) |\n| `REMOTE_POSTS_DAYS` | Optional, read by the same two, and **off when left out**. Any post from another server that has been left alone this many days is removed, whoever wrote it and whoever follows them. What it removes is not got back by asking |\n| `REMOTE_POSTS_KEPT` | Optional, read by the same two, and **off when left out**. The most posts kept of one person on another server, the newest. What it removes is not got back by asking |\n\nWriting to Bluesky also needs `CRYPT_BACKEND` and `CRYPT_LOCAL_KEY` (or\n`AWS_KMS_KEY_ID`), the same as auth-service's, and a site open to read\n(`SITE_READ_ACCESS`)."},{"id":"the-web-shell","heading":"The web shell","level":2,"markdown":"Only for an installation that sends the web app through `web-shell`; see\n[The web shell](https://www.anahita.io/docs/services/web-shell/#settings).\n\n| Variable | Value |\n| --- | --- |\n| `PUBLIC_WEB_URL` | The address people reach the web app at, such as `https://www.example.org`. Left out, the host each request came to is used |\n| `WEB_BUILD_PATH` | docker-compose only: the folder on the host with the web app's build in it. Defaults to `web-shell/web` |\n| `WEB_BUILD_DIR` | Where web-shell looks for the build inside its container. Defaults to `/app/web`; change it only with a build put elsewhere |"},{"id":"photos","heading":"Photos","level":2,"markdown":"| Variable | Value |\n| --- | --- |\n| `PHOTO_MAX_FILES` | How many images one photo post may hold, from 1 to 20. 20 unless said. Read by auth-service (which tells the web app), photo-service and the two federation services (which keep no more of a post from elsewhere). Posts made before it was lowered keep their images. See [Photo posts](https://www.anahita.io/docs/services/photos/#one-post-several-images) |"},{"id":"video","heading":"Video","level":2,"markdown":"Only where videos may be posted. What each does, and how they go together,\nis in [Videos](https://www.anahita.io/docs/services/videos/#settings).\n\n| Variable | Value |\n| --- | --- |\n| `VIDEO_UPLOADS` | `on` to let people post videos. Off unless said |\n| `VIDEO_MAX_BYTES` | The largest file taken. 500 MB unless said |\n| `VIDEO_MAX_SECONDS` | The longest video taken. Ten minutes unless said |\n| `VIDEO_RENDITIONS` | The sizes made, by height: `1080,720,360` unless said, up to `2160` |\n| `VIDEO_DAILY_UPLOADS` | How many videos one person may send in a day. 20 unless said |\n| `ANIMATION_MAX_BYTES` | The largest GIF or animated PNG taken, posted as a video that loops. 25 MB unless said |\n| `ANIMATION_MAX_SECONDS` | The longest one. 30 seconds unless said |\n\nThe bucket needs a CORS rule letting the web app's address `PUT` to it:\nsee [Videos](https://www.anahita.io/docs/services/videos/#storage)."},{"id":"download-your-data","heading":"Download your data","level":2,"markdown":"Only where `export-service` and its worker run. See\n[Download your data](https://www.anahita.io/docs/services/data-export/#settings).\n\n| Variable | Value |\n| --- | --- |\n| `EXPORT_EVERY_DAYS` | Days between one export and the next. 7 unless said; 0 for no wait |\n| `EXPORT_KEEP_DAYS` | Days an archive is kept. 7 unless said |\n| `SITE_NAME` | The site's name, for the heading of an archive's page |"}]},{"source":"services","slug":"deploying","title":"Deploying","group":"Start here","url":"https://www.anahita.io/docs/services/deploying/","sections":[{"id":"","heading":null,"level":1,"markdown":"How to run a live site on Kubernetes, on any provider. For development on\nyour own machine, use docker-compose instead: see\n[Getting started](https://www.anahita.io/docs/services/getting-started/)."},{"id":"what-you-need","heading":"What you need","level":2,"markdown":"| What | Notes |\n| --- | --- |\n| A Kubernetes cluster | Any provider's, or your own. A default StorageClass, unless the database, Redis and RabbitMQ all run elsewhere |\n| An ingress controller | A maintained one. The base names the `nginx` class; the `traefik` component changes it to Traefik, which comes with k3s. ingress-nginx itself was retired in March 2026 |\n| [cert-manager](https://cert-manager.io/) | Gets and renews the Let's Encrypt certificate, and issues the certificates the services use between themselves |\n| Two DNS names | `api.` and `auth.` under your domain, both pointing at the ingress controller's address |\n| A bucket | On any S3-compatible object store. Private. See [Object storage](#object-storage) |\n| An SMTP account | Any provider |\n| The images | Published to GitHub's registry for each version: see [Images](#images). Or your own builds: `make build_dockerfiles` builds them all |\n\nNothing here depends on one cloud. AWS is one option among several, and its\noverlay only differs in how it signs in to S3 and where the database lives."},{"id":"how-the-manifests-are-laid-out","heading":"How the manifests are laid out","level":2,"markdown":"~~~\nk8s/\n  base/                 every Anahita service, and nothing a site decides\n    services/           one file per HTTP service\n    grpc/               file, graph and mail\n    jobs/               the migration job and the account sweeper\n    gateway.yml         nginx: routing, CORS, cookie-to-token\n    ingress.yml         the way in; sends everything to the gateway\n    grpc-tls.yml        certificates for the calls between the services\n    config.env          how the services find each other\n  components/           optional, each added by an overlay\n    mysql/ redis/ rabbitmq/   the backing services in the cluster\n    mysql-backup/       a nightly dump of that MySQL to the bucket\n    garage/             an S3-compatible store in the cluster\n    traefik/            the Ingresses through Traefik\n    mailpit/            catches every message, sends none on\n  overlays/\n    generic/            any cluster, any S3-compatible store\n    k3s/                one server running k3s, at any provider\n    aws/                EKS with S3, RDS and ElastiCache\n    local/              minikube, for trying the manifests\n    k3d/                k3d, for trying the k3s overlay's components\n  k3s/\n    traefik-config.yml  for a k3s cluster itself: keeps visitors' addresses\n~~~\n\nIt is built with [Kustomize](https://kustomize.io/), which is part of\n`kubectl`: `kubectl apply -k <overlay>`. The base is not applied on its own.\nAn overlay adds what a site decides: its hostnames, settings and secrets, and\nwhich backing services run inside the cluster.\n\nEach manifest gives its service the same variables `docker-compose.yml` does,\ntaken from two objects the overlay generates:\n\n| Object | From | Holds |\n| --- | --- | --- |\n| ConfigMap `anahita-config` | `config.env` | Settings that are not secret |\n| Secret `anahita-secrets` | `secrets.env` | Passwords and keys. Git ignores this file |\n\nEvery variable is described in [Configuration](https://www.anahita.io/docs/services/configuration/). The gateway\nis the same nginx, with the same `nginx/nginx.conf`, that docker-compose runs,\nso a path is routed the same way in both."},{"id":"hostnames","heading":"Hostnames","level":2,"markdown":"With `ENV=prod`, auth-service works out its addresses from `DOMAIN_NAME`:\n\n| Address | What is there |\n| --- | --- |\n| `https://DOMAIN_NAME` | The web app, hosted separately: see anahita-web's `docs/deploying.md`. Change it with `FRONTEND_URL` |\n| `https://auth.DOMAIN_NAME` | The sign-in pages, and the address tokens are issued under (`OAUTH_ISSUER`) |\n| `https://api.DOMAIN_NAME` | The API, which the web app is built to call (`REACT_APP_API_BASE_URL`) |\n\n`auth.` and `api.` reach the same gateway. Three settings have to agree with\nthese, or signing in fails:\n\n- `WEB_ORIGIN` in `config.env` is the web app's address. The gateway lets that\n  one origin call the API with the session cookie.\n- `OAUTH_ISSUER` in `config.env` is the `auth.` address.\n- `oauth_seed_clients.json` holds the web app's `/oauth/callback` address.\n  It is only read at first start. Afterwards, change it in the app under\n  **Settings › OAuth clients**."},{"id":"first-deployment","heading":"First deployment","level":2,"markdown":"1. Copy `k8s/overlays/generic` (or `aws`) somewhere of your own, such as a\n   private repository. If you move it, fix the `../../base` and\n   `../../components` paths in its `kustomization.yml`.\n2. Fill in `config.env`.\n3. Copy `secrets.env.example` to `secrets.env` and replace every `CHANGE_ME`.\n   `SECURE_COOKIE_KEY` and `CRYPT_LOCAL_KEY` are 32 characters each:\n   `openssl rand -hex 16` makes one. Keep a copy of `CRYPT_LOCAL_KEY`\n   somewhere safe. Without it nobody can sign in.\n4. Put your hostnames in `ingress.yml`, your address in `issuer.yml`, and the\n   web app's callback address in `oauth_seed_clients.json`.\n5. In `kustomization.yml`, set the image tag to the release you are deploying.\n6. Check what will be applied, then apply it:\n\n   ~~~sh\n   kubectl kustomize k8s/overlays/generic | less\n   kubectl apply -k k8s/overlays/generic\n   kubectl -n anahita get pods --watch\n   ~~~\n\nThe migration job creates the schema on an empty database. Services that\nstart before it finishes restart until it has, which is expected.\n\nauth-service creates the first super administrator from `SUPER_ADMIN_EMAIL`\nand mails a link to set a password. The link is also in its log, which is the\nway to it before mail works:\n\n~~~sh\nkubectl -n anahita logs deployment/auth-service | grep -i password\n~~~\n\nIf you keep secrets in a tool such as Sealed Secrets or External Secrets,\ndrop the `secretGenerator` from `kustomization.yml` and have the tool create\na Secret called `anahita-secrets` with the same keys."},{"id":"addresses-behind-a-load-balancer","heading":"Addresses behind a load balancer","level":3,"markdown":"auth-service reads a visitor's address from `X-Forwarded-For`, skipping\nprivate addresses. That works when the ingress controller sees the visitor's\nreal address. Behind a cloud load balancer it often sees the balancer's\ninstead: turn on PROXY protocol, or `use-forwarded-headers`, in ingress-nginx\nas your provider documents. The sign-in log showing `localhost`,\n`development` for everybody is the sign that it is not set up.\n\nOn k3s, its built-in load balancer gives every connection the node's own\naddress unless told otherwise. Apply `k8s/k3s/traefik-config.yml` to the\ncluster once, before the overlay:\n\n~~~sh\nkubectl apply -f k8s/k3s/traefik-config.yml\n~~~\n\nIt sets Traefik's Service to `externalTrafficPolicy: Local`, which keeps the\nvisitor's address. Traefik drops an `X-Forwarded-For` sent by a visitor, so\nit cannot be forged."},{"id":"certificates-between-the-services","heading":"Certificates between the services","level":2,"markdown":"The calls from the HTTP services to graph-grpc, file-grpc and mail-grpc use\nmutual TLS, described under\n[Internal calls](https://www.anahita.io/docs/services/configuration/#internal-calls). `k8s/base/grpc-tls.yml`\nsets it up, and there is nothing to configure:\n\n- cert-manager creates an authority for this installation alone, a\n  certificate for each gRPC service, and one that every caller presents.\n- Each is valid for 90 days and renewed 30 days before it ends. The services\n  pick up a renewed certificate within a minute or two, without a restart.\n- A pod waits to start until its certificate exists, which takes a few\n  seconds on the first deployment.\n\nTo see them: `kubectl -n anahita get certificates`.\n\nThe certificates name the services as `base/config.env` dials them, such as\n`graph-grpc-service`. If you change those addresses, change `dnsNames` in\n`grpc-tls.yml` to match.\n\nAny holder of a certificate from the authority may call any gRPC service.\nThere is no rule yet about which service may call which.\n\nIf a service mesh such as Linkerd already encrypts traffic between pods, set\n`GRPC_TLS=off` in `config.env` and leave this to the mesh."},{"id":"object-storage","heading":"Object storage","level":2,"markdown":"Uploads go to a bucket on any store that speaks the S3 API, set with the\n`STORAGE_*` variables. The settings are described under\n[Storage](https://www.anahita.io/docs/services/configuration/#storage).\n\n| Store | `STORAGE_ENDPOINT` | `STORAGE_REGION` | `STORAGE_PATH_STYLE` | Checked |\n| --- | --- | --- | --- | --- |\n| AWS S3 | leave unset | e.g. `ca-central-1` | `false` | In use by the reference installation |\n| Garage 2.1 | your Garage's S3 address | `garage`, or what `s3_region` says | `true` | Passes the storage test; the `garage` component, on k3d |\n| MinIO | your MinIO's address | `us-east-1` unless configured | `true` | Not tested |\n| OVHcloud | `https://s3.<region>.io.cloud.ovh.net` | e.g. `gra` | `false` | Not tested |\n| Scaleway | `https://s3.<region>.scw.cloud` | e.g. `fr-par` | `false` | Not tested |\n| Hetzner | `https://<location>.your-objectstorage.com` | e.g. `fsn1` | `false` | Not tested |\n\nThe rows marked not tested follow each provider's documentation. Before\nrelying on one, run the storage test against a bucket on it, as\n[Tested stores](https://www.anahita.io/docs/services/configuration/#tested-stores) shows. It uploads, reads,\nsigns a link and deletes, in under a minute.\n\nWhere the provider sells no store, the `garage` component runs Garage in the\ncluster, as the `k3s` overlay does. Its `garage-init` Job gives the node its\nrole, imports `STORAGE_ACCESS_KEY` and `STORAGE_SECRET_KEY`, makes\n`STORAGE_BUCKET`, and lets `WEB_ORIGIN` `PUT` to it. Browsers fetch uploads\nfrom it through signed links, so it has a public name of its own, `files.`,\nset as `STORAGE_PUBLIC_ENDPOINT`. Its data is on one volume: back up the\nserver's disk.\n\nSet `STORAGE_PUBLIC_ENDPOINT` when browsers reach the store at a different\naddress than the cluster does, which is the case for a store running inside\nthe cluster.\n\nTo move existing files from one store to another, copy the bucket with\n[rclone](https://rclone.org/): `rclone sync old:bucket new:bucket`. The paths\ninside the bucket stay the same, so nothing in the database changes."},{"id":"what-is-in-the-bucket","heading":"What is in the bucket","level":3,"markdown":"The bucket is private. Nothing in it is read except through an address the\nfile service signs for a few minutes.\n\nEverything is under `assets/v1/`, in a folder named for the service that\nlooks after it.\n\n| Folder | What | Written by |\n| --- | --- | --- |\n| `assets/v1/identity-image-service/avatars/` and `…/covers/` | Avatars and covers | `identity-image-service` |\n| `assets/v1/photo-service/` | The images of photo posts | `photo-service` |\n| `assets/v1/video-service/videos/` | Each video's sizes and poster | `video-worker` |\n| `assets/v1/video-service/uploads/` | Videos as they were uploaded, until the worker has made them. Never read by a viewer | Browsers, through signed addresses |\n| `assets/v1/export-service/` | People's copies of their own data, kept a week | `export-worker` |\n| `assets/v1/link-preview-service/` | The pictures of link cards | `link-preview-service` |\n\nTwo rules on the bucket itself are worth setting:\n\n- **CORS**, letting the web app's address `PUT`, where videos are posted:\n  see [Videos](https://www.anahita.io/docs/services/videos/#storage).\n- **A lifecycle rule that aborts unfinished multipart uploads** after a few\n  days, which catches any upload the video worker could not abort itself.\n\nOn AWS, every setting is in the next section."},{"id":"a-bucket-on-aws-s3","heading":"A bucket on AWS S3","level":3,"markdown":"In the S3 console, in the region the rest of the site runs in, choose\n**Create bucket**:\n\n| Field | Choose | Why |\n| --- | --- | --- |\n| Bucket type | General purpose | Directory buckets (S3 Express One Zone) keep files in one data centre, for speed |\n| Namespace and name | Global, a name with no dots, e.g. `example-org` | AWS's addresses for a bucket, and their certificates, do not take dots. The account regional namespace works too: its names end in the account ID and region, and no other account can ever take them |\n| Copy settings from an existing bucket | No | |\n| Object Ownership | ACLs disabled (recommended) | Then set `STORAGE_SEND_ACL=false`: a bucket with ACLs disabled accepts uploads that carry no ACL, and AWS documents the `private` ACL anahita-services sends by default on AWS as refused |\n| Block Public Access | All four on | The bucket is private; files are read only through addresses file-grpc-service signs for a few minutes |\n| Bucket Versioning | Enable | A file deleted or overwritten by mistake can be brought back, for as long as the lifecycle rule below keeps it |\n| Tags | Optional, e.g. `project` | The bucket's cost shows on its own on the bill |\n| Default encryption | SSE-S3 | file-grpc-service asks for it on every upload (`STORAGE_SSE`) |\n| Object Lock | Off | Deleting a post, and purging a profile, delete its files |\n\nThen, on the bucket:\n\n1. **Permissions → Cross-origin resource sharing (CORS):** `PUT` from the\n   web app's address, as [Videos](https://www.anahita.io/docs/services/videos/#storage) shows. List both\n   addresses if the app answers at `www.` and without it.\n2. **Management → Create lifecycle rule**, one rule for everything:\n   1. **Name** `tidy-up`; **scope** all objects in the bucket, with its\n      acknowledgement ticked; **object size** empty.\n   2. **Actions:** tick only *Permanently delete noncurrent versions of\n      objects* and *Delete expired object delete markers or incomplete\n      multipart uploads*. Nothing under Transition or *Expire current\n      versions*, which would delete the live files.\n   3. **Noncurrent versions:** `30` days after objects become noncurrent;\n      newer versions to retain empty. A file deleted or replaced can be\n      brought back for 30 days, then it is gone; without this, everything a\n      purge deletes would stay in the bucket as old versions.\n   4. **Delete expired object delete markers** ticked; **delete incomplete\n      multipart uploads** ticked, after `7` days.\n\nNo other rule is needed: the export worker deletes each archive itself after\n`EXPORT_KEEP_DAYS`. No keys are needed on AWS either: see\n[On AWS](#on-aws) for the role. Before the first deployment, run the storage\ntest against the new bucket ([Tested stores](https://www.anahita.io/docs/services/configuration/#tested-stores))."},{"id":"moving-a-bucket-to-the-v1-layout","heading":"Moving a bucket to the v1 layout","level":3,"markdown":"Pictures are under `assets/v1/`. A bucket from before the first release has\nthem somewhere else, and `scripts/s3-migration` moves them:\n\n| The bucket has | From | Run with |\n| --- | --- | --- |\n| `assets/public/n<id>/com_…/` | The PHP application, \"v0\". The file half of an upgrade from it; the database half is `migration-job/legacy/upgrade.sql` | `-from v0` |\n| `assets/v2/`, with `avatar-service` and `cover-service` folders | This platform while it was being built | `-from v2` |\n\nFrom `scripts/s3-migration`, with the same `STORAGE_*` settings the file\nservice has, or `AWS_PROFILE` for a profile on AWS:\n\n~~~sh\n# 1. What would be copied. Changes nothing.\nSTORAGE_BUCKET=my-bucket STORAGE_REGION=ca-central-1 go run . -from v2\n\n# 2. Copy. The old files stay.\nSTORAGE_BUCKET=my-bucket STORAGE_REGION=ca-central-1 go run . -from v2 -apply\n\n# 3. Deploy the services, and look at the site: avatars, covers, photos.\n\n# 4. Remove the old files.\nSTORAGE_BUCKET=my-bucket STORAGE_REGION=ca-central-1 go run . -from v2 -apply -delete-old\n~~~\n\n**Copy before deploying.** Services that read the v1 layout find no\npictures in a bucket that has not been copied. Until step 4 both layouts\nare in the bucket, so going back means deploying the services from before.\n\nThe store copies the files itself, two dozen at a time: tens of thousands\ntake minutes, and nothing is downloaded. Each step can be run again. An old\nfile is deleted only when its copy is in place and the same size, and a\nfile the script does not recognise is left and listed.\n\nThe script's own [README](https://github.com/anahitasocial/anahita-services/blob/main/scripts/s3-migration/README.md) has every\noption."},{"id":"database-redis-and-rabbitmq","heading":"Database, Redis and RabbitMQ","level":2,"markdown":"The `generic` overlay runs all three inside the cluster, from\n`k8s/components`, each with one replica and a volume from the default\nStorageClass. That is enough for a small site. **Nothing in `generic`\nbacks them up.** The `mysql-backup` component, which the `k3s` overlay has,\ndumps the database at 03:30 UTC to `backups/mysql/` in the site's bucket and\nkeeps the newest 14; add it to `components:` in any overlay that runs the\n`mysql` component. To take one now:\n\n~~~sh\nkubectl -n anahita create job --from=cronjob/mysql-backup mysql-backup-now\n~~~\n\nA dump is restored into an empty database with\n`gunzip -c <dump>.sql.gz | mysql <database>`. Try one before a site opens:\nnothing else proves a backup.\n\nTo use a managed service instead, remove its line from `components:` in\n`kustomization.yml` and point the settings at it:\n\n| Service | In `config.env` | In `secrets.env` |\n| --- | --- | --- |\n| MySQL 8 or later | `MYSQL_HOST` | `DSN`, `MYSQL_USER`, `MYSQL_PASSWORD` |\n| Redis or Valkey | `REDIS_ADDR`, and `REDIS_TLS=true` if it requires TLS | `REDIS_PASSWORD` |\n| RabbitMQ | | `AMQP_ADDR` |\n\nThe database account needs every right on its own database, including\ncreating and dropping stored procedures, which the upgrade from the PHP\napplication uses.\n\nTo put a volume on a StorageClass other than the default, add a patch to the\noverlay:\n\n~~~yaml\npatches:\n  - target:\n      kind: StatefulSet\n      name: mysql\n    patch: |-\n      - op: add\n        path: /spec/volumeClaimTemplates/0/spec/storageClassName\n        value: fast-ssd\n~~~"},{"id":"on-aws","heading":"On AWS","level":2,"markdown":"`k8s/overlays/aws` differs from `generic` in three ways:\n\n- **S3 without keys.** file-grpc-service runs as a service account tied to an\n  IAM role (`service-accounts.yml`). Put your role's ARN there. The role needs\n  `s3:GetObject`, `s3:PutObject` and `s3:DeleteObject` on the bucket's\n  objects, and `s3:ListBucket` on the bucket.\n- **RDS and ElastiCache** in place of the MySQL and Redis components.\n- **SES** as the SMTP relay.\n\nThe key services are optional. To seal secrets and sign tokens with KMS, set\n`CRYPT_BACKEND=kms`, `AWS_KMS_KEY_ID`, `OAUTH_SIGNER_BACKEND=kms` and\n`OAUTH_KMS_KEY_ALIAS` in `config.env`, and give auth-service a service\naccount with a role that may use those keys, the same way.\n\nThis overlay has been checked to build. It has not been run on EKS."},{"id":"joining-the-fediverse","heading":"Joining the fediverse","level":2,"markdown":"Being found and followed from Mastodon and the like is its own piece of\nsetting up, with names that cannot be changed afterwards. It has a page of\nits own, in the order to do it: [Federation](https://www.anahita.io/docs/services/federation/#setting-it-up).\nReading Bluesky, and writing to it for people who connect their own\naccount, needs much less: see [Bluesky](https://www.anahita.io/docs/services/federation/#bluesky)."},{"id":"images","heading":"Images","level":2,"markdown":"Each version of anahita-services is published to GitHub's container\nregistry, for amd64 and arm64 under one name, when it is tagged\n(`.github/workflows/images.yml`). A tagged version is a **Birth** release;\nthe main branch is always the **Embryo**, and is not published as images:\n[Releases](https://www.anahita.io/docs/services/releases/).\n\n~~~\nghcr.io/anahitasocial/<image>:<version>\n~~~\n\n`<image>` is the name of its file in `dockerfiles/`, except `nginx`, which\nis published as `gateway`. Each also has `latest`, the newest Birth release, for\ntrying Anahita out; a site names the version it runs, in the `images:` of\nits overlay, so that it changes only when its operator changes it. The tags\nending in `-amd64` and `-arm64` are the two halves the workflow joins, not\nfor use.\n\nTo publish a Birth release: `git tag v1.0.0 && git push origin v1.0.0`.\nOnly the images that changed since the release before are built; the rest\nare that release's images, tagged again ([What is rebuilt](https://www.anahita.io/docs/services/releases/#what-is-rebuilt)). The\nworkflow builds each architecture natively, on its own runner."},{"id":"upgrading","heading":"Upgrading","level":2,"markdown":"~~~sh\nkubectl -n anahita delete job migration-job --ignore-not-found\nkubectl apply -k k8s/overlays/generic\n~~~\n\nA finished Job cannot be changed, which is why the old one is deleted first.\nThe new one applies whatever migrations have not run. Changing `config.env`\nor `secrets.env` and applying again restarts the services that read them."},{"id":"running-more-than-one-copy-of-a-service","heading":"Running more than one copy of a service","level":2,"markdown":"Every Deployment starts with one replica. The services that only answer\nrequests can be given more:\n\nexport, feed, identity-image, like, link-preview, location, photo, website,\nsearch, socialgraph, text, video, web-shell, and the gateway.\n\nThe three workers, export-worker, video-worker and syndication-worker, take their\njobs from a table one at a time, so any number of copies share the work\nwithout doing it twice.\n\nThese listen for events on the bus, and each copy receives every event, so\ntwo copies would do the work twice (two notifications, two emails, two\nposts sent to another server). Keep them at one until the event bus has\ndurable queues:\n\nabuse-report, activitypub, actor, atproto, auth, hashtag, notification,\nrecommendation, file-grpc, graph-grpc, mail-grpc.\n\nAn event is acknowledged as it arrives and is never delivered twice, even\nwhen its handler fails part way: what a handler wrote or sent is never\nwritten or sent again. Its error is logged, a panic in it is caught, and\nwhen the bus closes the channel the service says so and stops, to be\nstarted again (anahita-libs/event/consumer.go). An event published while a\nservice is down is not heard by it."},{"id":"trying-it-on-your-own-machine","heading":"Trying it on your own machine","level":2,"markdown":"The `local` overlay runs the whole stack on\n[minikube](https://minikube.sigs.k8s.io/), over plain HTTP with `ENV=dev`. It\nis for checking the manifests, not for daily work.\n\n~~~sh\nmake k8s-start     # a cluster with the ingress controller and cert-manager\nmake k8s-build     # builds every image and loads it into the cluster\nmake k8s-deploy    # applies k8s/overlays/local\nkubectl --context minikube -n anahita get pods\n~~~\n\n`make k8s-start` starts minikube with `--keep-context`, so `kubectl` keeps\ntalking to the cluster it was, and stops if that changed. The `make k8s-*`\ntargets always name the minikube cluster, so they cannot touch another\none.\n\nUploads go to the Garage that docker-compose runs, so `make up` first if you\nwant them to work. To reach the API, either forward a port:\n\n~~~sh\nkubectl --context minikube -n anahita port-forward service/gateway 8080:80\ncurl http://localhost:8080/nodeinfo/2.1\n~~~\n\nor stop docker-compose, which uses port 80, and run `make k8s-tunnel` to\nserve it at `http://localhost`. `make k8s-cleanup` deletes the cluster."},{"id":"with-k3d","heading":"With k3d","level":3,"markdown":"The `k3d` overlay runs the `k3s` overlay's components (Traefik, MySQL and\nits backup, Redis, RabbitMQ, Garage) on [k3d](https://k3d.io/), which is\nk3s in Docker, with a mailpit that catches every message. It needs k3d and\nnothing else from docker-compose, which can keep running.\n\n~~~sh\nmake k3d-start     # the cluster, with traefik-config.yml and cert-manager\nmake k3d-build     # builds every image and imports it into the cluster\nmake k3d-deploy    # applies k8s/overlays/k3d\nkubectl --kubeconfig ~/.config/k3d/kubeconfig-anahita.yaml -n anahita get pods\n~~~\n\nThe cluster's kubeconfig is its own file, `~/.config/k3d/kubeconfig-anahita.yaml`:\n`~/.kube/config` is never written, `kubectl`'s current context is left\nalone, and every target names that file, so none can reach another cluster.\n\n| Address | What is there |\n| --- | --- |\n| `http://api.localhost:8088` | The API |\n| `http://auth.localhost:8088` | The sign-in and sign-up pages |\n| `http://files.localhost:8088` | Garage, where signed links to uploads point |\n\nSign-up codes and every other message are in mailpit (the `mailpit`\ncomponent), which a site can also use before it opens, so that nobody is\nmailed by mistake:\n`kubectl --kubeconfig ~/.config/k3d/kubeconfig-anahita.yaml -n anahita port-forward deploy/mailpit 8025`,\nthen `http://localhost:8025`. `make k3d-cleanup` deletes the cluster and its\nkubeconfig."},{"id":"one-machine-without-kubernetes","heading":"One machine without Kubernetes","level":2,"markdown":"There is no production docker-compose file. The one in the repository is for\ndevelopment: it publishes every service's port and uses development\npasswords."}]},{"source":"services","slug":"releases","title":"Releases","group":"Start here","url":"https://www.anahita.io/docs/services/releases/","sections":[{"id":"","heading":null,"level":1,"markdown":"Anahita's releases have two names, as they have had since its first years:\n**Embryo** and **Birth**. Not alpha, beta or release candidate.\n\n| Release | What it is | Where it is | For |\n| --- | --- | --- | --- |\n| **Embryo** | The `main` branch, always. It changes with every commit: new features, fixes, and now and then something experimental that is taken out again | `main` on GitHub. No images are published from it; `make build_dockerfiles` builds your own | Working on Anahita, and trying what is next |\n| **Birth** | A version tagged when a milestone is reached and the code is stable: `v1.0.0`, `v1.1.0` | The tag on GitHub, a release named after it (\"v1.0.0 Birth\"), and the images for it on GitHub's registry | Running a site |\n\nA site runs a Birth release. It names the version in its overlay, so that\nit changes only when its operator changes it: [Images](https://www.anahita.io/docs/services/deploying/#images)."},{"id":"version-numbers","heading":"Version numbers","level":2,"markdown":"`v<major>.<minor>.<patch>`, as [semantic versioning](https://semver.org) has\nthem:\n\n| Part | Goes up when |\n| --- | --- |\n| Major | Something a site, an app or a service of its own relies on changes in a way it must follow: a route removed or answering differently, a setting renamed, a step to take by hand when upgrading |\n| Minor | Something is added, and what was there still works the same |\n| Patch | Something is fixed, and nothing else changes |\n\nWhat each Birth release changes, and anything to do when upgrading to it,\nis in its notes on GitHub, under\n[Releases](https://github.com/anahitasocial/anahita-services/releases)."},{"id":"the-history","heading":"The history","level":2,"markdown":"The PHP Anahita's Birth releases ran from 3.0.0 to 4.8.0, its `master`\nbranch the Embryo. These services, written in Go from 2023, began again at\n**v1.0.0**, the first Birth release of Anahita Services, on 2026-10-27.\nAnahita Web, Mobile and CLI use the same names."},{"id":"making-one","heading":"Making one","level":2,"markdown":"For whoever maintains anahita-services:\n\n1. On an up-to-date `main` that passes `make test`, tag it and push the tag:\n\n   ~~~sh\n   git tag v1.0.0 && git push origin v1.0.0\n   ~~~\n\n2. The tag starts `.github/workflows/images.yml`, which publishes every\n   image for amd64 and arm64 as `ghcr.io/anahitasocial/<image>:v1.0.0`, and\n   as `latest`. It builds only the images that changed since the Birth\n   release before: [What is rebuilt](#what-is-rebuilt).\n3. On GitHub, Releases › Draft a new release, from the tag, named\n   \"v1.0.0 Birth\": what it adds, changes and fixes, and what to do when\n   upgrading.\n\nUpgrading a site to it: [Upgrading](https://www.anahita.io/docs/services/deploying/#upgrading)."},{"id":"what-is-rebuilt","heading":"What is rebuilt","level":2,"markdown":"A fix to one service should publish one new image, not thirty, yet nothing\nthat changed may be left out. So each image carries a hash of everything it\nis made from, as the label `org.anahita.inputs`: its Dockerfile, the image\nit starts from, and every file it copies, the compiled binaries among them\n(`scripts/image-inputs.sh`).\n\n| When an image's hash is | The release |\n| --- | --- |\n| The same as in the Birth release before | Tags that release's image with the new version too. Nothing is built, and the image is the very one that ran before |\n| Different, or there is no release before | Builds it and publishes it |\n\nThe hash is taken from what is built, not from which folders a commit\ntouched, so a change to `anahita-libs`, to `go.mod` or to a base image\nreaches every image it changes, however many commits lie between the two\nreleases. The binaries are built with `GOFLAGS=\"-buildvcs=false -trimpath\"`,\nso that the same code makes the same binary. The run's summary on GitHub\nlists what was built and what was kept.\n\nTo build everything regardless: Actions › images › Run workflow, with the\nversion and **rebuild_all** ticked."}]},{"source":"services","slug":"licensing","title":"Licensing","group":"Start here","url":"https://www.anahita.io/docs/services/licensing/","sections":[{"id":"","heading":null,"level":1,"markdown":"Anahita's code is open source. How Anahita works is copyleft, so that it\nstays open wherever it is run; how to talk to it is permissive, so that\nanybody can build on it, in any language, under any licence.\n\n| Part | Licence | File |\n| --- | --- | --- |\n| The services, `anahita-libs`, and everything else in this repository but the protocol definitions and the examples | GNU Affero General Public License, version 3 only (`AGPL-3.0-only`) | [`LICENSE`](https://github.com/anahitasocial/anahita-services/blob/main/LICENSE) |\n| The gRPC and protocol buffer definitions in `anahita-libs/proto` (22 `.proto` files), and the code generated from them | Apache License 2.0 (`Apache-2.0`) | [`anahita-libs/proto/LICENSE`](https://github.com/anahitasocial/anahita-services/blob/main/anahita-libs/proto/LICENSE) |\n| The examples in `examples/`, made to be copied | Apache License 2.0 | [`examples/LICENSE`](https://github.com/anahitasocial/anahita-services/blob/main/examples/LICENSE) |\n| The web app, [anahita-web](https://github.com/anahitasocial/anahita-web) | MIT | its `LICENSE` |\n| The MCP server for the docs, [anahita-mcp](https://github.com/anahitasocial/anahita-mcp) | MIT | its `LICENSE` |\n\nCopyright 2008–2026 Rastin Mehr Design Studio Inc. (rmd studio). This page\nexplains the licences; it is not legal advice, and the licence texts are\nwhat count."},{"id":"running-a-site","heading":"Running a site","level":2,"markdown":"Nothing is owed for running Anahita as it comes: a school, a newsroom or a\ngroup of friends can install it, use it and charge for hosting it, and\npublish nothing.\n\nA site that **changes the services** and lets people use them over the\nnetwork must offer those people the source of its changed version, under\nthe same licence (section 13 of the AGPL). A link to a public repository\nwith the changes is enough. Changes kept to a developer's own machine or\ntest server, used by nobody else, owe nothing. Settings (`config.env`,\n`secrets.env`) are not changes to the services, and the web app is under\nits own licence, MIT, so a site's theme and texts in it are its own."},{"id":"building-on-anahita","heading":"Building on Anahita","level":2,"markdown":"| What you build | What it may be |\n| --- | --- |\n| A client: a mobile app, a bot, a script, another web app, talking to the API | Any licence. It is a separate program |\n| A service of your own that calls Anahita's services over gRPC, in any language, with code generated from the definitions | Any licence, open or closed: the definitions are Apache 2.0. See [Services over gRPC](https://www.anahita.io/docs/services/grpc/) |\n| A service of your own that answers in place of one of Anahita's, from the same definitions | Any licence, for the same reason |\n| A service that uses `anahita-libs` (its permissions, federation, entities, clients), or a changed copy of one of Anahita's services | AGPL-3.0, offered to the people who use it |\n\nThe line runs between how to talk to Anahita, which anybody may use, and how\nAnahita works, which stays open wherever it runs: nobody can take Anahita's\nown code, change it, and run it closed for others."},{"id":"the-name-and-the-logo","heading":"The name and the logo","level":2,"markdown":"Neither licence grants the use of the name Anahita, a registered trademark\nof Rastin Mehr Design Studio Inc. in Canada, or of its logo, except to say\nwhere the software comes from. A site built with Anahita has its own name."},{"id":"other-terms","heading":"Other terms","level":2,"markdown":"rmd studio holds the copyright in all of Anahita's code, so it can license\nany of it on other terms, or more permissively later. None are offered\ntoday."}]},{"source":"services","slug":"ai-agents","title":"Anahita MCP","group":"AI agents","url":"https://www.anahita.io/docs/services/ai-agents/","sections":[{"id":"","heading":null,"level":1,"markdown":"Anahita's docs and API reference, in the AI agent you already use: Claude,\nGemini, Cursor, VS Code, or any other that takes an MCP server. The agent\nthen answers from the docs as they are today, not from what it remembers:\nhow to set up a site, how federation works, which route follows a profile\nand what it answers.\n\nThe server is [anahita-mcp](https://github.com/anahitasocial/anahita-mcp),\nopen source under MIT. It reads, and only reads: these docs, the web app's,\nand the API reference. There are two ways to use it.\n\n| Way | Address or command | For |\n| --- | --- | --- |\n| Hosted, nothing to install | `https://mcp.anahita.io/mcp` | Chat apps, which reach servers over the internet, and anybody without Node.js |\n| On your computer | `npx -y anahita-mcp` | Coding agents; your questions never leave your machine through it. Needs [Node.js](https://nodejs.org) 20 or later |"},{"id":"adding-it","heading":"Adding it","level":2,"markdown":"| Client | Hosted | On your computer |\n| --- | --- | --- |\n| Claude Code | `claude mcp add --transport http anahita https://mcp.anahita.io/mcp` | `claude mcp add anahita -- npx -y anahita-mcp` |\n| Claude Desktop and claude.ai | Settings › Connectors › Add custom connector, with the address | Settings › Developer › Edit Config: in `mcpServers`, `\"anahita\": { \"command\": \"npx\", \"args\": [\"-y\", \"anahita-mcp\"] }`, then restart |\n| Gemini CLI | `gemini mcp add --scope user --transport http anahita https://mcp.anahita.io/mcp` | `gemini mcp add --scope user anahita npx -- -y anahita-mcp`: the `--` keeps `-y` for `npx`, as the Gemini CLI has a `-y` of its own |\n| Cursor | In `~/.cursor/mcp.json`, under `mcpServers`: `\"anahita\": { \"url\": \"https://mcp.anahita.io/mcp\" }` | `\"anahita\": { \"command\": \"npx\", \"args\": [\"-y\", \"anahita-mcp\"] }` |\n| VS Code | In `.vscode/mcp.json`, under `servers`: `\"anahita\": { \"type\": \"http\", \"url\": \"https://mcp.anahita.io/mcp\" }` | `\"anahita\": { \"type\": \"stdio\", \"command\": \"npx\", \"args\": [\"-y\", \"anahita-mcp\"] }` |\n| Any other | The address, as a remote MCP server (Streamable HTTP) | The command, as a local one (standard input and output) |\n\nThen ask, for example: *\"How does Anahita decide what an old post is\nworth?\"*, or *\"Which API route lets a person follow a group, and what does\nit send back?\"*"},{"id":"what-it-can-do","heading":"What it can do","level":2,"markdown":"| Tool | Answers |\n| --- | --- |\n| `search_docs` | The sections of the docs that match some words, best first, with where each is |\n| `list_docs` | Every page, in its groups, with its sections |\n| `read_doc` | A page, or one section of it |\n| `list_endpoints` | The API's services; one service's routes; or the routes that match some words |\n| `read_endpoint` | One route in full: who may call it, what it takes, every answer it gives |\n| `read_shape` | One shape the API takes or answers with, and its fields |\n\nEvery answer gives the www.anahita.io address of what it read, so that the\nagent can point you to the page."},{"id":"what-it-keeps","heading":"What it keeps","level":2,"markdown":"- **On your computer:** nothing is sent anywhere. It fetches two files from\n  www.anahita.io, keeps them in your system's cache folder, and searches\n  them there. It works offline with what it fetched last.\n- **Hosted:** it keeps no record of the questions or of who asked. AWS keeps\n  only when the function ran and for how long, for one day. It runs in\n  Montreal (AWS ca-central-1).\n\nThe docs it reads are a day behind these at most: the website is rebuilt\non every change to it and every night."},{"id":"without-mcp","heading":"Without MCP","level":2,"markdown":"| File | What it holds |\n| --- | --- |\n| [`www.anahita.io/llms.txt`](https://www.anahita.io/llms.txt) | The site and its docs in brief, for language models |\n| [`www.anahita.io/docs/index.json`](https://www.anahita.io/docs/index.json) | Every page of the docs as Markdown, cut into sections, in one file |\n| [`www.anahita.io/api/anahita.json`](https://www.anahita.io/api/anahita.json) | The API reference, OpenAPI 3.1 |"},{"id":"your-own-copy","heading":"Your own copy","level":2,"markdown":"An operator can host the server for their own site's docs, on any server\n(`npx -y anahita-mcp --http`) or on AWS Lambda:\n[Hosting it](https://github.com/anahitasocial/anahita-mcp/blob/main/docs/hosting.md)."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The server | [anahita-mcp](https://github.com/anahitasocial/anahita-mcp): `src/server.ts` for the tools |\n| The docs index it reads | anahita.io: `src/app/docs/index.json/route.js` |\n| The API reference it reads | `docs/api/`, made from the code ([The API](https://www.anahita.io/docs/services/api/#the-reference)) |"}]},{"source":"services","slug":"why-anahita","title":"Why Anahita","group":"Concepts","url":"https://www.anahita.io/docs/services/why-anahita/","sections":[{"id":"","heading":null,"level":1,"markdown":"Anahita is a social software platform organizations use to foster community\nengagement in settings such as schools, newsrooms, libraries, university\nresearch groups, or non-governmental organizations.\n\nAnahita instances communicate with each other and the global network of\nsocial networks, blogs, and online forums that utilize either ActivityPub or\nthe AT Protocol. This enables seamless interaction while keeping your\noriginal data and files on your server, governed by your privacy and\nmoderation policies. Key concepts in Anahita's design philosophy include:"},{"id":"the-principles","heading":"The principles","level":2,"markdown":"Five choices, each a rule for whoever changes the code.\n\n1. **Federation first, and only with consent.** Anahita speaks ActivityPub\n   and the AT Protocol natively, so that a community on it is not a walled\n   garden: its people can follow and be followed by people on other servers\n   and on Bluesky. Each profile chooses whether to federate. Sharing is\n   consented to, never assumed. Nothing is built that does not federate\n   sensibly. See [Federation](https://www.anahita.io/docs/services/federation/).\n2. **The feed is chronological.** The home page is what the people,\n   groups and events somebody follows posted, newest first. There is no\n   ranked feed chosen for them. A ranked feed is one a person makes, and is\n   always called a custom feed. See [Custom feeds](https://www.anahita.io/docs/services/feeds/).\n3. **Nobody is tracked.** There are no profile analytics, no share counts,\n   no lists of who viewed what. A like is counted because somebody chose to\n   like; a view is not, because nobody chose to be seen viewing. Who may\n   see what is each person's choice: see [Privacy](https://www.anahita.io/docs/services/privacy/).\n4. **Old and useful counts as much as new.** A community builds knowledge\n   as well as news: a school's notes, a library's guides, a research group's\n   findings stay useful for years. Nothing is pushed down only for being\n   old, and how fast interest fades is the operator's choice. See\n   [How things are found](https://www.anahita.io/docs/services/how-things-are-found/).\n5. **The breadth is the product.** Groups, events, articles, topics,\n   photos, videos and custom feeds, in one place, for the organisations that\n   need more than a stream of short posts.\n\nEach has a cost. A chronological feed asks people to choose whom to follow\nwith care. Without tracking, there is less to tell an operator how the site\nis used. Without ranking by engagement, a lively post does not reach\nstrangers on its own. These are costs Anahita chooses to pay."},{"id":"two-networks-not-a-third","heading":"Two networks, not a third","level":2,"markdown":"There are two open ways for social software to talk to each other now:\nActivityPub, which Mastodon and most of the fediverse speak, and the AT\nProtocol, which Bluesky speaks. They are built on different ideas.\n\n- **ActivityPub** is servers sending each other messages. A person's\n  identity is on the server they joined (`@ana@example.org`), and that\n  server holds their keys and speaks for them.\n- **The AT Protocol** gives a person an identity of their own, a DID, and a\n  repository of what they posted that any app can read and that can move\n  between hosts. Apps read the whole network through relays.\n\nAnahita takes part in both rather than inventing a third. On the fediverse\nit is a server; on Bluesky it is an app, with the account a person already\nhas. [A server on one network, an app on the other](https://www.anahita.io/docs/services/federation/#a-server-on-one-network-an-app-on-the-other)\nsays what follows from that."},{"id":"everything-is-a-node","heading":"Everything is a node","level":2,"markdown":"Inside, Anahita is one idea: everything is a node, and every relationship\nbetween two things is an edge. A person, a group, a post, a hashtag, a\nplace and a feed are nodes; following, liking, tagging, citing and saving\nare edges. A post from another server is a node too, so feeds, follows and\nblocks need nothing new for it.\n\nThat one shape is what lets a group's events, a person's photos and a\nhashtag's posts be read, permitted, searched and suggested in the same way.\nSee [The graph](https://www.anahita.io/docs/services/the-graph/)."},{"id":"further-reading","heading":"Further reading","level":2,"markdown":"The protocols Anahita speaks, for those who want to know them from the\nsource. Each is described for Anahita in [Federation](https://www.anahita.io/docs/services/federation/#further-reading).\n\n| | Read |\n| --- | --- |\n| ActivityPub | [The W3C recommendation](https://www.w3.org/TR/activitypub/), and [activitypub.rocks](https://activitypub.rocks/) |\n| The AT Protocol | [atproto.com's docs](https://atproto.com/docs), beginning with [the overview](https://atproto.com/guides/overview) |\n| The AT Protocol's design | [Bluesky and the AT Protocol: Usable Decentralized Social Media](https://arxiv.org/abs/2402.03239), by the people who designed it |"}]},{"source":"services","slug":"the-graph","title":"The graph","group":"Concepts","url":"https://www.anahita.io/docs/services/the-graph/","sections":[{"id":"","heading":null,"level":1,"markdown":"Everything in Anahita is a node, and every relationship between two things\nis an edge. A person, a group, a post, a hashtag, a location and a feed are\nnodes; following, liking, tagging, citing and saving are edges. This page is\nabout what the two are, how their types are named, and every type there is.\n\n![A person follows a group, likes a post on its profile, and keeps a custom\nfeed on their home page; a hashtag and a location are tagged on the post.\nEach arrow is an edge; the dashed one is the post's owner_id.](https://www.anahita.io/docs-assets/services/images/graph.svg)"},{"id":"why-one-graph","heading":"Why one graph","level":2,"markdown":"A school's community holds people, groups, events, notes, articles, photos\nand videos, and they are all connected: who follows what, who is in which\nphoto, which article cites which note, which posts are about `#seeds`. Kept\nas one graph, each of these is read, permitted, searched and suggested in\nthe same way:\n\n- **One rule for who may see what,** applied in one place, to every kind of\n  node ([Who may see a node](#who-may-see-a-node)).\n- **One shape for search and suggestions** to walk: \"posts liked by people\n  you follow\" and \"people cited by people you follow\" are the same walk\n  over different edges ([How things are found](https://www.anahita.io/docs/services/how-things-are-found/)).\n- **Something new needs no new tables.** A custom feed is a node, and\n  keeping it on the home page is an edge\n  ([A feed is a node](https://www.anahita.io/docs/services/feeds/#a-feed-is-a-node)). A post from Mastodon is\n  a node too, so following it, liking it and blocking its author need\n  nothing new.\n\nThe graph is owned by one service, `graph-grpc-service`. Every other service\nreads and writes it through that one, over gRPC\n([Architecture](https://www.anahita.io/docs/services/architecture/#components))."},{"id":"nodes","heading":"Nodes","level":2,"markdown":"A node is a row of the `nodes` table. Every kind shares it; what a column\nmeans can depend on the kind, and a column a kind has no use for is left\nempty.\n\n| Column | What |\n| --- | --- |\n| `id` | Its number on this installation |\n| `uuid` | What another server is told it is called: random, and never changed |\n| `type` | What kind of node it is ([How a type is named](#how-a-type-is-named)) |\n| `name`, `alias`, `body` | What it is called, its handle or address, and what it says |\n| `owner_id` | The profile it is on: the person or group a post was posted to. On a profile, empty |\n| `created_by` | Who made it, who need not be its owner: a person's post in a group is owned by the group |\n| `parent_id`, `root_id` | On a reply, what it answers and the post at the top of its thread. On a repost, the original |\n| `quote_id` | On a note that quotes a post, that post |\n| `access` | Who may see it ([Who may see a node](#who-may-see-a-node)) |\n| `meta` | What one kind keeps that the others do not, as JSON: a feed's definition, for one |\n| `uri`, `origin_domain` | What it is called on the server it came from, when it came from another one |\n| `follower_count`, `vote_up_count`, `comment_count`, `citation_count`, … | Counts the graph keeps in step with the edges, so that a list does not count each time it is read |"},{"id":"how-a-type-is-named","heading":"How a type is named","level":2,"markdown":"A type is a name from a fixed grammar, read left to right from the most\ngeneral to the most particular:\n\n```\nnode.<family>.<service>.<entity>.v1      node.actor.person-service.person.v1\nedge.<family>.<name>.v1                  edge.actor.follow.v1\n```\n\n- **The family** is what everything of that kind has in common. Every\n  `node.actor.` can be followed, blocked and posted to; every `node.medium.`\n  is a post, with an owner, an author and an audience. Code that asks\n  \"is this a profile?\" asks whether the type begins with `node.actor.`.\n- **The service** is the one that answers for that kind of node. A group's\n  is still `group-service`, an older name, because a type is never renamed.\n- **The version** is there because a type is stored for good: in every row,\n  in every edge's `node_a_type` and `node_b_type`, and in what other\n  services keep. A change to what a type means would be a new version, read\n  beside the old, never an edit of the old."},{"id":"every-kind-of-node","heading":"Every kind of node","level":2,"markdown":"| Family | Type | What |\n| --- | --- | --- |\n| Actors | `node.actor.person-service.person.v1` | A person with an account here |\n| | `node.actor.group-service.group.v1` | A group, which people follow and post in |\n| | `node.actor.actor-service.event.v1` | An event, which people say they are going to; often hosted by a group |\n| | `node.actor.actor-service.website.v1` | A website's feed, verified, as a profile to follow ([Websites](https://www.anahita.io/docs/services/websites/)) |\n| | `node.actor.activitypub-service.person.v1` | Somebody on a server of the fediverse |\n| | `node.actor.atproto-service.person.v1` | Somebody on Bluesky |\n| Media | `node.medium.text-service.note.v1` | A note: a short post, and the kind that arrives from another server |\n| | `node.medium.text-service.article.v1` | An article: long, with a title; what a website posts for each item of its feed |\n| | `node.medium.text-service.topic.v1` | A topic: a conversation with a title, started on a profile, with replies under it |\n| | `node.medium.photo-service.photo.v1` | A photo post, of one or more photos ([Photo posts](https://www.anahita.io/docs/services/photos/)) |\n| | `node.medium.video-service.video.v1` | A video post ([Videos](https://www.anahita.io/docs/services/videos/)) |\n| Tags | `node.tag.hashtag-service.hashtag.v1` | A hashtag ([Hashtags and locations](https://www.anahita.io/docs/services/hashtags/)) |\n| | `node.tag.location-service.location.v1` | A location posts are tagged with |\n| Feeds | `node.feed.feed-service.generator.v1` | A custom feed: what it shows, kept in its `meta` ([Custom feeds](https://www.anahita.io/docs/services/feeds/)) |\n| Reposts | `node.base.repost-service.repost.v1` | A repost: its `parent_id` is the original, and its owner the profile that reposted it |\n\nA reply is a node of the same type as a note, with a `parent_id`. People\nelsewhere have types of their own, not the person type with a flag:\nwhatever lists the people of this installation by type then leaves them out\nwithout having to be told."},{"id":"edges","heading":"Edges","level":2,"markdown":"An edge is a row of the `edges` table: a type, and two nodes, `node_a` and\n`node_b`, with each one's type beside its id. It points from `a` to `b`, and\nreads as a sentence: *Ana* **follows** *the library*.\n\n| Column | What |\n| --- | --- |\n| `type` | What the relationship is |\n| `node_a_id`, `node_a_type` | Where it is from |\n| `node_b_id`, `node_b_type` | Where it is to |\n| `meta` | What it says beyond that, as JSON: whether a tag of a person has been approved, for one |\n| `ordering` | Its place among its siblings, for the ones kept in an order somebody chose: feeds on the home page |\n| `created_by`, `created_at` | Who made it, and when; what fades with time is counted from this |\n\nThere is one edge of a type between the same two nodes, never two\n(`uc_edge`): liking a post twice is liking it."},{"id":"every-kind-of-edge","heading":"Every kind of edge","level":2,"markdown":"| Type | From | To | Means |\n| --- | --- | --- | --- |\n| `edge.actor.follow.v1` | A profile | A profile | Follows it |\n| `edge.actor.follow-request.v1` | A profile | A profile | Has asked to follow it, and is waiting for an answer |\n| `edge.actor.block.v1` | A profile | A profile | Blocks it: neither sees the other |\n| `edge.actor.admin.v1` | A person | A group or an event | Is one of its administrators |\n| `edge.actor.host.v1` | A group | An event | Hosts it; what puts it on the group's list of events |\n| `edge.actor.invite.v1` | A person | A profile | Was invited to follow it, by `created_by` |\n| `edge.base.voteup.v1` | A person | A post | Likes it |\n| `edge.base.bookmark.v1` | A person | A post | Saved it. Private: nobody else is told, the post's author included, and nothing is counted from it |\n| `edge.base.subscription.v1` | A person | A post or a profile | Is notified of what happens to it ([Notifications](https://www.anahita.io/docs/services/notifications/)) |\n| `edge.tag.hashtag.v1` | A hashtag | A post | The post carries the hashtag |\n| `edge.tag.location.v1` | A location | A post | The post is tagged with the location |\n| `edge.tag.mention.v1` | A profile | A post | The post names the profile: \"this is for you\" |\n| `edge.tag.person.v1` | A person | A photo or video post | The person is in it, tagged by its author; shown once they approve |\n| `edge.medium.citation.v1` | A post | A post | The first shows the second within its text, as an article or a topic does an address on a line of its own |\n| `edge.medium.hidden-reply.v1` | A post | A reply | The reply is hidden from the post's thread; the reply stays its author's |\n| `edge.feed.pin.v1` | A person | A custom feed | Keeps it on their home page, in the place its `ordering` says |"},{"id":"who-may-see-a-node","heading":"Who may see a node","level":2,"markdown":"Every node has an audience, its `access`: `public`, `registered`,\n`followers`, `leaders`, `mutuals`, `admins` or `myself`, each measured\nagainst the profile the node is on, not whoever wrote it\n([The audiences](https://www.anahita.io/docs/services/privacy/#the-audiences)). A block, either way, hides\neverything.\n\nThe graph applies these to every read, whatever asked: a list, a search, a\nsuggestion, a feed. What a service is sent is already what the viewer may\nsee, so no service has a rule of its own to get wrong\n([Permissions](https://www.anahita.io/docs/services/permissions/))."},{"id":"nodes-from-other-servers","heading":"Nodes from other servers","level":2,"markdown":"A person on Mastodon or Bluesky, and what they post, are nodes here once\nsomebody here has met them: looked them up, followed them, or been replied\nto. Each has a `uri`, what it is called where it lives, and an\n`origin_domain`; a node without a `uri` was made here. See\n[What a node is called elsewhere](https://www.anahita.io/docs/services/architecture/#what-a-node-is-called-elsewhere)\nand [Federation](https://www.anahita.io/docs/services/federation/)."},{"id":"events-are-not-types","heading":"Events are not types","level":2,"markdown":"What happens to the graph is told to the services that care over the bus,\nRabbitMQ, by events with names in the same grammar and an action at the end:\n`node.medium.text-service.note.added.v1`, `edge.follow.socialgraph.added.v1`.\nThese are the names of things that happened, not of nodes or edges, and are\nnever stored as a type. They are listed in `anahita-libs/event`\n([Architecture](https://www.anahita.io/docs/services/architecture/#components))."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The two tables | `migration-job/schema/init.sql` |\n| The type names | `anahita-libs/constants/entity_types.go`, and beside the code of the kind (`anahita-libs/feeds`, `anahita-libs/videos`, `text-service/constants`) |\n| The audiences | `anahita-libs/constants/access.go` |\n| Every read and write | `graph-grpc-service/data/repositories` |\n| How other services call it | `anahita-libs/proto/graphpb`, over gRPC ([Services over gRPC](https://www.anahita.io/docs/services/grpc/)) |"}]},{"source":"services","slug":"how-things-are-found","title":"How things are found","group":"Concepts","url":"https://www.anahita.io/docs/services/how-things-are-found/","sections":[{"id":"","heading":null,"level":1,"markdown":"There are six ways somebody comes across a post or a profile on Anahita:\ntheir home page, a custom feed, search, suggestions of whom to follow, and\nwhat is trending. This page is how they fit together, and what each one\norders by. The pages it links to have the numbers."},{"id":"six-ways-in","heading":"Six ways in","level":2,"markdown":"| Way | What it shows | In what order | Page |\n| --- | --- | --- | --- |\n| The home page | What the people, groups and events somebody follows posted | Newest first, always | [Custom feeds](https://www.anahita.io/docs/services/feeds/#the-home-page) |\n| A custom feed | What a person chose: sources, hashtags, words, locations, languages, a distance | Newest first | [Custom feeds](https://www.anahita.io/docs/services/feeds/) |\n| Search | People, groups, events, posts and shared feeds that match the words | Most relevant, or most recent | [Search](https://www.anahita.io/docs/services/search/) |\n| A hashtag's or a location's posts | The posts that carry it | Most relevant, without the words | [Hashtags and locations](https://www.anahita.io/docs/services/hashtags/#a-hashtags-and-a-locations-posts) |\n| Suggestions | People, groups and events to follow | By the reasons for each, scored | [Recommendations](https://www.anahita.io/docs/services/recommendations/) |\n| Trending | The hashtags and locations the most profiles used lately | By how many profiles, each once | [Trending](https://www.anahita.io/docs/services/hashtags/#trending) |\n\nThe home page and custom feeds are by date and nothing else. Only search,\na tag's posts and suggestions rank, and every one of them says why."},{"id":"what-they-share","heading":"What they share","level":2,"markdown":"Every way in walks the same [graph](https://www.anahita.io/docs/services/the-graph/), over the same edges,\nand reads only what the viewer may see.\n\n| Sign | The edges and columns | Used by |\n| --- | --- | --- |\n| Who somebody follows | `edge.actor.follow.v1` | Every one: the home page is made of it; search puts what you follow higher; suggestions look at whom your follows follow |\n| What people found useful | Likes (`edge.base.voteup.v1`), saves (`edge.base.bookmark.v1`, private: never shown to anybody), replies, reposts and quotes | Search's standing; `you_engaged` in suggestions |\n| What people cite | `edge.medium.citation.v1`, counted once for each person who cites | Search's standing; `cited` and `cited_by_leaders` in suggestions |\n| Who is in a photo or a video together | `edge.tag.person.v1`, approved tags only | `pictured_with_followed` in search; `pictured_with_you` and `pictured_with_leaders` in suggestions |\n| What a post is about | `edge.tag.hashtag.v1`, `edge.tag.location.v1` | Custom feeds' filters; search; `shared_interest` in suggestions; trending |\n| How lately a profile was active | When it last posted, or was visited | Suggestions put the quiet last, and leave out those quiet longer than the reader chose |\n\nA sign is never more than one of several. A logarithm keeps one post liked\na thousand times from burying everything else in search, and every reason\nfor a suggestion has a most it can add."},{"id":"what-is-never-used","heading":"What is never used","level":2,"markdown":"What somebody looked at, for how long, what they scrolled past, what they\nclicked on, where they were when they did: none of it is recorded, so none\nof it can rank anything. Each sign above is something a person chose to do\nand could see themselves doing: follow, like, save, reply, cite, tag. There\nare no hidden signals, and no profile of interests is built from behaviour\nto rank by ([Why Anahita](https://www.anahita.io/docs/services/why-anahita/))."},{"id":"interest-fades-usefulness-does-not","heading":"Interest fades; usefulness does not","level":2,"markdown":"A guide a library wrote years ago is as useful as one from this week. So\nnothing is pushed down for being old. What may fade is **interest**: each\nlike, save, reply or follow is counted by when it happened, halved every\n`half_life_days`, a year by default. A guide that people saved this month\nstands as high as a new post saved as often.\n\nA citation does not fade. Citing takes effort and is lasting, and what is\ncited stays useful. See\n[Old and useful counts as much as new](https://www.anahita.io/docs/services/search/#old-and-useful-counts-as-much-as-new)."},{"id":"each-persons-own-weights","heading":"Each person's own weights","level":2,"markdown":"Each person who is signed in can change how much each sign counts, for them\nalone, and nobody else's results change:\n\n- **In search,** six weights: likes, saves, replies, followers, citations,\n  and what they follow ([Their own weights](https://www.anahita.io/docs/services/search/#their-own-weights)),\n  and two choices, *Profiles I follow first* and *Newer results first*.\n- **In suggestions,** a weight for each reason, from 0, not used, to 10;\n  whether what they liked and wrote may be used at all (`use_activity`);\n  and how lately somebody must have been active to be suggested\n  ([Each person's own weights](https://www.anahita.io/docs/services/recommendations/#each-persons-own-weights)).\n- **Custom feeds** are the rest: a person who wants a different feed makes\n  one, and calls it what they like."},{"id":"the-operators-numbers","heading":"The operator's numbers","level":2,"markdown":"Every default is the installation's to change:\n\n| What | Where |\n| --- | --- |\n| Search: the points for a match, the weights of each sign, `half_life_days` | `graph-grpc-defaults/search_ranking.json`, read again when it changes ([Tuning it](https://www.anahita.io/docs/services/search/#tuning-it)) |\n| Suggestions: the score of each reason and its most | `recommendation-service/constants`, in the code: a change is a new build |\n| Trending: the window | `TRENDING_WINDOW` ([Configuration](https://www.anahita.io/docs/services/configuration/#trending)) |\n\nA newsroom may want interest to fade in a month; a library, a school or a\nresearch group in a year or more, or never."}]},{"source":"services","slug":"operating","title":"Operating","group":"Run a site","url":"https://www.anahita.io/docs/services/operating/","sections":[{"id":"","heading":null,"level":1,"markdown":"The pages for running a live site from day to day.\n\n1. **Upgrading** to a new release: [Upgrading](https://www.anahita.io/docs/services/deploying/#upgrading). The\n   migration job brings the database up to date.\n2. **The database, Redis and RabbitMQ.** What runs in the cluster, and using a\n   managed service instead:\n   [Database, Redis and RabbitMQ](https://www.anahita.io/docs/services/deploying/#database-redis-and-rabbitmq).\n   Nothing backs them up for you.\n3. **Storage.** What is in the bucket, and moving an old one to the current\n   layout: [Object storage](https://www.anahita.io/docs/services/deploying/#object-storage).\n4. **Running more than one copy** of a service:\n   [Running more than one copy of a service](https://www.anahita.io/docs/services/deploying/#running-more-than-one-copy-of-a-service).\n5. **Accounts.** Deleted accounts, the 30 days to restore them, purging, the\n   accounts list and the sweeper that clears out unused ones:\n   [Account lifecycle](https://www.anahita.io/docs/services/account-lifecycle/).\n6. **Reports.** What administrators do with what people report:\n   [What administrators do](https://www.anahita.io/docs/services/abuse-reports/#what-administrators-do).\n7. **Federation.** Keeping the fediverse and Bluesky working, and what to\n   check when they do not:\n   [Keeping it running](https://www.anahita.io/docs/services/federation/#keeping-it-running) and\n   [When something does not work](https://www.anahita.io/docs/services/federation/#when-something-does-not-work).\n8. **Apps that sign in.** The web app and other clients, and changing what\n   they may do: [OAuth scopes and clients](https://www.anahita.io/docs/services/oauth/).\n\nNot written yet:\n\n1. **Backing up and restoring:** what to back up (MySQL, the bucket, the keys\n   in `secrets.env`), how often, and how to restore a site from it.\n2. **Upgrading from one release to the next,** step by step: what to read\n   first, and what to do when a migration fails."}]},{"source":"services","slug":"customising","title":"Customising","group":"Run a site","url":"https://www.anahita.io/docs/services/customising/","sections":[{"id":"","heading":null,"level":1,"markdown":"The pages for changing how a site looks, what it says and what it allows,\nwithout changing the code.\n\n1. **Its name and look.** The name and description:\n   [Site identity](https://www.anahita.io/docs/services/configuration/#site-identity) here, and in the web app,\n   [Name and description](https://www.anahita.io/docs/web/customising/#name-and-description).\n   Logo, colours, fonts and the home page visitors see:\n   [Themes](https://www.anahita.io/docs/web/customising/#themes).\n2. **Its languages.**\n   [Languages](https://www.anahita.io/docs/web/customising/#languages)\n   in the web app. What a post's language is used for:\n   [The language of a post](https://www.anahita.io/docs/services/post-language/).\n3. **Its legal texts.** Your Terms of Service and Privacy Policy, and asking\n   everybody to accept a new version:\n   [Terms of Service and Privacy Policy](https://www.anahita.io/docs/web/customising/#terms-of-service-and-privacy-policy)\n   in the web app, and [the server's side](https://www.anahita.io/docs/services/registration/#terms-of-service-and-privacy-policy).\n4. **Who may do what, by default.** Who may post, reply, like and add\n   followers on each kind of profile: [The defaults](https://www.anahita.io/docs/services/permissions/#the-defaults),\n   and [what profile owners can change](https://www.anahita.io/docs/services/permissions/#what-profile-owners-can-change).\n5. **Who may join, and what visitors see.**\n   [Registration modes](https://www.anahita.io/docs/services/registration/#registration-modes), and the\n   [privacy settings](https://www.anahita.io/docs/services/privacy/#what-the-installation-allows) of the whole\n   installation.\n6. **How search ranks.** The weights, and how fast the past fades:\n   [Tuning it](https://www.anahita.io/docs/services/search/#tuning-it).\n7. **The reasons for a report.** Your own list in place of the built-in one:\n   [Using your own reasons](https://www.anahita.io/docs/services/abuse-reports/#using-your-own-reasons).\n8. **How long things are kept.** Unused accounts, deleted ones, and the\n   archives people download:\n   [Account cleanup](https://www.anahita.io/docs/services/configuration/#account-cleanup),\n   [Account lifecycle](https://www.anahita.io/docs/services/account-lifecycle/),\n   [Download your data](https://www.anahita.io/docs/services/data-export/#keeping-and-deleting).\n9. **Its rules and promises.** The rules everybody agrees to, the promises\n   on its About page, and the rules a new group starts from:\n   [Rules and promises](https://www.anahita.io/docs/services/configuration/#rules-and-promises).\n\nNot written yet: starting configurations for kinds of site (a school, a\nnewsroom, a library), planned in [recipes.md](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/recipes.md)."}]},{"source":"services","slug":"registration","title":"Registration and agreements","group":"Run a site","url":"https://www.anahita.io/docs/services/registration/","sections":[{"id":"","heading":null,"level":1,"markdown":"How people join an installation, how invitations and the approval queue work,\nhow the Terms of Service and Privacy Policy are accepted, and how a new member\nis walked through filling in their profile.\n\nIts routes are in the API reference: [auth-service](https://www.anahita.io/api/auth/), from [`/signup`](https://www.anahita.io/api/auth/#postSignup) and [`/invites`](https://www.anahita.io/api/auth/#postInvites) on."},{"id":"where-it-happens","heading":"Where it happens","level":2,"markdown":"Signing in, signing up, accepting an invitation and confirming an email address\nare all server-rendered pages on auth-service. The web app only links to them.\nPasswords never pass through the web app.\n\n| Page | Path |\n| --- | --- |\n| Sign up | `/signup` |\n| Confirm an email address, with the emailed code | `/signup/confirm` |\n| Ask for a new confirmation code | `/signup/confirm/resend` |\n| Accept an invitation | `/invites/accept` |"},{"id":"registration-modes","heading":"Registration modes","level":2,"markdown":"`REGISTRATION_MODE` decides how open the installation is.\n\n| Mode | Self-serve signup | Invitations |\n| --- | --- | --- |\n| `open` | Anybody, once their email is confirmed | Work |\n| `approval` | Anybody may apply; an administrator decides | Work |\n| `invite` | None | Work |\n| `closed` | None | **Do not work**, including ones already sent |\n\n**Unset means `closed`.** An installation nobody has configured should not be\nopen to the internet.\n\n`invite` is the normal state for a private community. `closed` is a kill\nswitch — for an abuse incident or an archived community — which is why it also\nsuspends invitations already sent."},{"id":"what-signup-asks-for","heading":"What signup asks for","level":2,"markdown":"| | `open` | `invite` | `approval` |\n| --- | --- | --- | --- |\n| Username | required | required | required |\n| Email | required | from the invitation | required |\n| Password | required | required | required |\n| Display name | — | optional | required |\n| Bio | — | — | required |\n| Website | — | — | required |\n\n- Passwords are at least 15 characters.\n- Usernames are 3–30 letters, numbers, `_` or `-` (Mastodon's 30), and\n  share one namespace with every other profile, groups included.\n- In `approval` mode the extra fields are the request: an administrator\n  cannot decide on an email address alone.\n- A display name is optional everywhere. An empty one displays as the username.\n- Avatar, bio and pronouns for everyone else are for onboarding, after signing\n  in."},{"id":"email-verification","heading":"Email verification","level":2,"markdown":"A self-serve account cannot sign in until its email address is confirmed.\nSigning up leads straight to a page waiting for a 6-digit code, which is\nemailed to the address. The code is valid for 15 minutes and allows five\ntries. A new one can be sent from that page, or later from\n`/signup/confirm/resend`, at most three times an hour for one address.\n\nIt is a code and not a link so that it can be read on any device and typed\nwhere the signup started, and because a link is confirmed by any mail scanner\nthat follows it. Signing in with an emailed code\n([Signing in](https://www.anahita.io/docs/services/signing-in/#signing-in-with-a-code)) confirms the address too.\n\nA request to join, in approval mode, is confirmed the same way, and only then\ngoes to the administrators.\n\nAccepting an invitation confirms the address, because the invitation was\ndelivered to it."},{"id":"invitations","heading":"Invitations","level":2,"markdown":"| Variable | Effect |\n| --- | --- |\n| `INVITES_FROM` | `registered`, `administrators` (default) or `super-administrators` — the minimum role that may send invitations |\n| `INVITE_PENDING_MAX` | Outstanding invitations one person may have at once. Default `5` |\n| `INVITE_PENDING_MAX_ADMIN` | The same for administrators. Default `100` |\n\n- The cap counts **outstanding** invitations only. A slot frees when one is\n  accepted, expires or is revoked.\n- Anybody may revoke an invitation they sent. Browsing all invitations is for\n  administrators.\n- Rate limits: 30 invitations an hour per sender, and 3 a day to any one\n  address."},{"id":"the-approval-queue","heading":"The approval queue","level":2,"markdown":"In `approval` mode somebody signing up confirms their email first, and only\nthen joins the queue. No account exists until the request is approved.\n\nRows live in `signup_requests`. The name is deliberate: a follow request is a\nrequest to follow, a signup request is a request to sign up. \"Application\" is\navoided because everywhere else in this product it means an OAuth client.\n\n| Endpoint | Does |\n| --- | --- |\n| `GET /signup-requests` | Lists pending requests, oldest first |\n| `POST /signup-requests/:id/approve` | Creates the account |\n| `POST /signup-requests/:id/reject` | Rejects the request |\n\nAll three are for administrators. Approval checks again that the username is\nstill free, since somebody else may have taken it while the request waited.\n\nWhen a request joins the queue, every administrator and super administrator\nis emailed a link to it, however many there are:\n`/admin/signup-requests` in the web app, a tab of its administration area.\n\n`approve` and `reject` are named in the path rather than being a bare `POST`\nand `DELETE` the way follow requests are answered. Ignoring a follow request is\na silent dismissal; rejecting a signup request records a decision note, stamps\nthe deciding administrator and sends mail.\n\n**Invitations and the queue.** An invitation from an administrator is already a\ndecision, so it skips the queue. An invitation from someone who is not one\n(`INVITES_FROM=registered`) is a recommendation, so in `approval` mode it joins\nthe queue like any other request.\n\nAdministrators work through the queue in the web app's administration area,\nunder Signup requests."},{"id":"protection-against-bots","heading":"Protection against bots","level":2,"markdown":"There is no CAPTCHA. The signup form uses:\n\n- **A honeypot field**, hidden from people. A submission that fills it in is\n  shown a success page and nothing is created.\n- **A minimum fill time.** A form submitted less than 2 seconds after it was\n  rendered is refused.\n- **A CSRF token** bound to the session, used once per submission.\n- **Rate limits** per source: 3 accounts an hour from one IP address, 10 an\n  hour from one network (`/24` for IPv4, `/64` for IPv6), and 3 confirmation\n  emails an hour to one address."},{"id":"terms-of-service-and-privacy-policy","heading":"Terms of Service and Privacy Policy","level":2,"markdown":"Agreements are not collected at signup. They are checked **every time somebody\nuses the web app**, so a revised document asks everyone again, new members and\nexisting ones alike.\n\n- **The text** lives in the web app, in `src/statics/legal/`.\n- **The versions** live beside it, in `src/statics/legal/index.js`. Raising a\n  version is how a revision is published.\n- When either version is ahead of what a signed-in person accepted, the web app\n  sends them to `/agreements` before anything else. The legal pages and\n  support stay reachable.\n- Accepting calls `PATCH /agreements/tos` or `PATCH /agreements/privacy` for the\n  signed-in person. auth-service records the version together with the time,\n  IP address, user agent and operating system, which it takes from the request.\n- `TOS_URL` and `PRIVACY_URL` tell auth-service where the documents can be read,\n  for links on its own pages. They default to `/legal/tos` and `/legal/privacy`\n  on the web app.\n\nThis is enforced by the web app's interface, not the API."},{"id":"the-sites-rules","heading":"The site's rules","level":3,"markdown":"Everybody ticks the site's rules before their account is made, on the page\nauth-service shows for it, and again in the web app when their version is\nraised (roadmap row 102):\n\n- **Where an account is made:** open sign-up and a sign-up request\n  (`/signup`) and an invitation's acceptance (`/invites/accept`). A\n  plain checklist, a box for each rule, numbered when there are more than\n  three, and the form refused (`Tick each of\n  our rules to agree to it.`) unless every box is ticked at the version the\n  site has now. The account is made with the agreement recorded; a request\n  keeps the version it agreed to (`signup_requests.rules_accepted_version`)\n  until it is approved, and the account gets it then, with where the request\n  came from.\n- **Whoever installs Anahita**, its first super administrator, makes a\n  promise instead, on the set-password page the boot log's link opens,\n  whatever the site's rules and `SITE_RULES` say: \"Anahita is built to create\n  safe online spaces…\", then \"I won't use Anahita to:\" and six things, each\n  ticked. A promise to the people who will trust them, not a licence: it\n  binds nobody in law, and Anahita's licences are unchanged. Its words are\n  Anahita's creator's, written in the code (`auth-service/about/pledge.go`),\n  not in `site.json`, so no configuration changes them. The boot log prints\n  it beside the link, and auth-service logs who made it, when and from where.\n  The site's own rules come to them afterwards in the web app, as to anybody.\n- **After that,** a raised version sends people to `/rules` in the web app,\n  after the Terms and the Privacy Policy, ticked one at a time.\n\n- **The rules and their version** are the server's, in `site.json`\n  ([Rules and promises](https://www.anahita.io/docs/services/configuration/#rules-and-promises)), served on\n  `GET /site`.\n- Agreeing calls `PATCH /agreements/rules` with the version `GET /site` has\n  now; any other is refused (409, `version_not_current`). It is recorded as the\n  Terms are, in `auth_accounts.rules_accepted_*`, and the version agreed to is\n  the `rules_agreement` claim of `/oauth/userinfo`.\n- **`SITE_RULES=off`** asks nobody but a super administrator who has never\n  agreed, once: a reminder of what Anahita is not for. NodeInfo says which as\n  `metadata.siteRules`."},{"id":"onboarding","heading":"Onboarding","level":2,"markdown":"Signup asks only for a username, an email address and a password. After the\nagreements, the web app walks a signed-in person with an incomplete profile\nthrough adding an avatar, a display name, a bio and pronouns. Existing members\nwith an empty profile get the same walk-through on their next sign-in.\n\n- **Whether they have been through it** is `auth_accounts.onboarded_at`. It is\n  set when the person reaches the dashboard through the flow, whether they\n  completed the steps or skipped them, and only once: a profile that later\n  becomes incomplete does not send them back.\n- **Whether the profile is incomplete** is read from `/oauth/userinfo`, under\n  the `profile` scope: `avatar_urls` is absent when there is no avatar, and\n  `has_bio` is false when the bio is empty or only whitespace.\n- **`onboarded_at`** is also on userinfo — the timestamp, or `null`.\n- Finishing calls `PATCH /onboarding` for the signed-in person, with no body.\n  It answers `200` with `{ \"onboarded_at\": \"<timestamp>\" }`, and a second call\n  keeps the first date.\n\n**Who invited them** is `auth_accounts.invited_by`. It is written when an\ninvitation is accepted, from the invitation, and when a signup request a member\nvouched for is approved. A self-serve signup has none. Onboarding shows the\ninviter first among the accounts it offers to follow, and never follows anybody\non the person's behalf.\n\n`GET /onboarding` returns it for the signed-in person, by alias:\n`{ \"invited_by\": \"<alias>\" }`. It is `null` when nobody invited them, and when\nthe inviter's account is gone, disabled or being deleted.\n\n**Featured accounts** are the ones onboarding offers to follow. A super\nadministrator features a person or a group from its profile:\n\n| Endpoint | Does |\n| --- | --- |\n| `PATCH /people/:id/featured` | `{ \"featured\": true }` or `false` |\n| `PATCH /groups/:id/featured` | The same, for a group |\n\nSuper administrators only; anybody else gets `403`. The answer is `200` when\nsomething changed and `204` when it was already so. A disabled, archived or\ndeleted account cannot be featured (`409`). Featured profiles carry\n`featured_at` in their JSON.\n\n`GET /people/?featured=1` and `GET /groups/?featured=1` list them, most\nrecently featured first unless `ordering` is given, through the ordinary browse:\nthe viewer's relationship to each is filled in and blocked accounts are left\nout. A featured account that is later disabled, archived or deleted drops out\nof the list without being unfeatured.\n\nLike the agreements, this is enforced by the web app's interface, not the API."},{"id":"nodeinfo","heading":"NodeInfo","level":2,"markdown":"auth-service publishes [NodeInfo 2.1](https://nodeinfo.diaspora.software/) at\n`/.well-known/nodeinfo` and `/nodeinfo/2.1`. It is the standard other fediverse\nservers read, and the web app reads it too.\n\n- `protocols` — what the installation speaks to other servers: `activitypub`\n  when its operator says that service is running (`FEDERATION_PROTOCOLS`),\n  and nothing on a site that is not open to read. See\n  [Federation](https://www.anahita.io/docs/services/federation/#setting-it-up).\n- `metadata.networks` — the networks the site takes part in, from the same\n  setting: `activitypub`, `atproto`, or both. The web app's front page says\n  from it that the site is on Mastodon and Bluesky.\n- `openRegistrations` — true in `open` and `approval`. The web app shows its\n  \"Create account\" button from this.\n- `metadata` — `nodeName`, `registrationMode`, `approvalRequired`,\n  `invitationsAccepted`, `termsOfServiceUrl`, `privacyPolicyUrl`,\n  `supportEmail`, `supportPhone`, `supportWebsite` and `nodeDescription`.\n  Also `invitesFrom`, `groupsFrom`, `eventsFrom` and `locationsFrom`, the\n  least role that may invite, create a group, create an event or add a\n  location; `photoMaxFiles`, the most\n  pictures in one post; `videoUploads`, `videoMaxBytes` and\n  `videoMaxSeconds`, whether videos may be posted and how big; and\n  `readAccess`, `public`, `preview` or `registered`: what somebody who is not\n  signed in may read, and `minContentAccess`, the widest audience anything new may be\n  given (see\n  [Privacy](https://www.anahita.io/docs/services/privacy/#what-the-installation-allows))."},{"id":"the-first-super-administrator","heading":"The first super administrator","level":2,"markdown":"Nothing in signup can create a super administrator. On a fresh database, set\n`SUPER_ADMIN_EMAIL` (and optionally `SUPER_ADMIN_USERNAME` and\n`SUPER_ADMIN_NAME`) and start auth-service:\n\n1. If no active super administrator exists, it creates the account with an\n   unusable password and a confirmed email address.\n2. It prints a set-password link, valid for 48 hours, to its log, and also\n   emails it. The log works before SMTP is configured:\n\n   ~~~sh\n   docker compose logs auth-service | grep \"super admin seed\"\n   ~~~\n\n3. On an installation that already has a super administrator it does nothing,\n   so the variable can stay set."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Registration modes, invitation settings, agreement links | `auth-service/config/bootstrap.go` (`GetRegistrationMode`, `AllowsSelfServeRegistration`, `MayInvite`, `GetInvitePendingMax`); settings `REGISTRATION_MODE`, `INVITES_FROM`, `INVITE_PENDING_MAX`, `INVITE_PENDING_MAX_ADMIN`, `TOS_URL`, `PRIVACY_URL`. Checked by `auth-service/config/registration_test.go` |\n| Signup and protection against bots | `auth-service/cmd/api/handlers/signup.go` (`looksAutomated`), `auth-service/requests/signup.go`, `auth-service/cmd/api/handlers/rate_limit.go` |\n| Email verification | `auth-service/cmd/api/handlers/signup_confirm.go`, `signup_confirm_test.go` |\n| Invitations | `auth-service/cmd/api/handlers/invite.go`, `invite_accept.go`, `auth-service/cmd/api/permissions/invite.go`, table `tokens` (type `invite`) |\n| The approval queue | `auth-service/cmd/api/handlers/signup_requests.go`, `auth-service/data/repositories/signup_requests.go`, table `signup_requests` |\n| Terms of Service and Privacy Policy | `auth-service/cmd/api/handlers/agreements.go`, the `tos_accepted_*` and `pp_accepted_*` columns of table `auth_accounts` |\n| Onboarding | `auth-service/cmd/api/handlers/onboarding.go`, `onboarding_test.go`; `onboarded_at` and `invited_by` in table `auth_accounts` |\n| Featured profiles | `actor-service/cmd/api/handlers/person.go` and `group.go` (`SetFeatured`), `featured_at` in table `nodes` |\n| NodeInfo | `auth-service/cmd/api/handlers/node_info.go` (`federationProtocols`); setting `FEDERATION_PROTOCOLS` |\n| The first super administrator | `auth-service/data/services/super_admin_seed.go` (`SeedSuperAdmin`); settings `SUPER_ADMIN_EMAIL`, `SUPER_ADMIN_USERNAME`, `SUPER_ADMIN_NAME` |"}]},{"source":"services","slug":"privacy","title":"Privacy","group":"Run a site","url":"https://www.anahita.io/docs/services/privacy/","sections":[{"id":"","heading":null,"level":1,"markdown":"Who can see what on an installation. There are three layers, and something is\nseen only by somebody who gets past all of them:\n\n1. **The installation**: what people who are not signed in may read at all,\n   and whether anything new may be public. Set by whoever runs the site.\n2. **The profile**: who can see a person's profile or a group. Set by its\n   owner or administrators.\n3. **The post**: who can see one post. Set by its author.\n\nThe routes that set who may see what are in the API reference, each with the audiences it takes: making everything registered-only is [`POST /accounts/make-registered`](https://www.anahita.io/api/auth/#postAccountsMakeRegistered)."},{"id":"the-audiences","heading":"The audiences","level":2,"markdown":"Posts, profiles and groups all use the same words:\n\n| Audience | Who that is |\n| --- | --- |\n| `public` | Anybody, signed in or not |\n| `registered` | Anybody who is signed in |\n| `followers` | People who follow the profile or group |\n| `leaders` | People the profile follows. People only |\n| `mutuals` | People who follow the profile and are followed back. People only |\n| `admins` | The administrators of the group. Groups only |\n| `myself` | Only the owner of the profile. People only |\n\nEach one is measured against **the profile the post is on**, not against\nwhoever wrote it: `followers` are that profile's followers.\n\nThe owner of a profile and its administrators always see everything on it, and\nan author always sees their own post where they can still see the profile.\nNobody sees anything belonging to somebody they have blocked or been blocked\nby. The full rules are in [Permissions](https://www.anahita.io/docs/services/permissions/)."},{"id":"who-can-see-a-post","heading":"Who can see a post","level":2,"markdown":"The author chooses when writing it, with the audience button beside Post, and\nwhoever may edit the post can change it afterwards with the same button.\n\nWhich audiences a post may have depends on where it is:\n\n| Where the post is | Audiences |\n| --- | --- |\n| Your own profile | `public`, `registered`, `followers`, `leaders`, `mutuals`, `myself` |\n| Somebody else's profile | `public`, `registered`, `followers`, `leaders`, `mutuals` |\n| A group, as one of its followers | `public`, `registered`, `followers` |\n| A group, as one of its administrators, or as a site administrator | `public`, `registered`, `followers`, `admins` |\n\nAnything else is refused with 422 `access_not_allowed`, and the answer lists\nthe audiences that are allowed.\n\n- **No \"only me\" on somebody else's profile.** It would be a post only its\n  author and that person can see, which is a private message. This platform\n  does not have those.\n- **No \"administrators only\" from a follower who is not one**, for the same\n  reason.\n- **A post is never seen more widely than its profile.** On a followers-only\n  group a `public` post is still seen only by its followers.\n- A post written without saying is `public`, or `registered` where the\n  installation allows nothing new to be public.\n- Replies have no audience of their own. They are seen by whoever can see the\n  post at the top of their thread.\n\nThe API: `access` when the post is created (`POST /notes/:owner_id/`, and the\nsame for articles, topics and photos; for a video, when its upload begins,\n`POST /videos/:owner_id/uploads`), and `PATCH /…/:id/access` to change it.\nEvery person, group and post in a response carries `authorized.audiences`, the\naudiences the viewer may choose there, so a client offers only those."},{"id":"who-can-see-a-profile-or-a-group","heading":"Who can see a profile or a group","level":2,"markdown":"Set on the profile's Access settings.\n\n| | Audiences |\n| --- | --- |\n| A person | `public`, `registered`, `followers`, `leaders`, `mutuals`, `myself` |\n| A group | `public`, `registered`, `followers`, `admins` |\n\nA new account's profile is `public`, or `registered` where the installation\nallows nothing new to be public. A new group starts as `admins`, a draft only\nits administrators see, until they open it.\n\nSomebody who cannot see a profile cannot see anything on it, whatever each\npost says."},{"id":"what-the-installation-allows","heading":"What the installation allows","level":2,"markdown":"Two settings, independent of each other."},{"id":"visitors-site_read_access","heading":"Visitors: SITE_READ_ACCESS","level":3,"markdown":"What somebody who is **not signed in** may read.\n\n| Value | A visitor gets |\n| --- | --- |\n| `public` (the default) | Everything that is public |\n| `preview` | Only the start of what is public |\n| `registered` | Nothing. A members-only site |\n\nSigned-in people see exactly the same under all three. Nothing stored changes,\nso any of them can be switched to any other: a post that says `public` is\npublic again the moment the setting is `public`.\n\n**`registered`, the members-only site.** Every route that serves content\nanswers 401 `members_only` to a visitor: profiles, posts, feeds, replies,\nhashtags, locations, followers and search. What stays open is what people need to\nget in: signing in and up, password reset, accepting an invitation, the OAuth\nendpoints and NodeInfo.\n\n**`preview`.** A visitor still reaches what is public, and is sent only the\nstart of it:\n\n| | What a visitor is sent |\n| --- | --- |\n| A post's text, and a profile's | The first 280 characters or so, as plain words, cut at a word and ending in \"…\". The post is marked `truncated: true`. A text that fits is sent as it was written |\n| A photo | The first image only, at its smallest size. `files_held_back` says how many more the post has |\n| A list | The first 10 items. `pagination.more` is true when there are more, and `pagination.total` is the number sent. Asking for a later page answers 401 `members_only` |\n| Replies | Nothing: 401 `members_only`. The count on the post is still there |\n| Who follows a profile, and whom it follows | Nothing: 401 `members_only`. The counts on the profile are still there |\n| Avatars and covers | As usual |\n\nIt is enough for a link shared elsewhere to say what it leads to, while the\nrest stays behind signing in."},{"id":"new-content-min_content_access","heading":"New content: MIN_CONTENT_ACCESS","level":3,"markdown":"The widest audience anything **new** may be given.\n\n| Value | |\n| --- | --- |\n| `public` (the default) | Anything may be public |\n| `registered` | Nothing new may be public |\n\nWith `registered`:\n\n- A post can no longer be created public or changed to public, and neither can\n  a person's profile or a group. The answer is 422 `access_not_allowed`.\n- A post written without saying who can see it is for signed-in people, and so\n  is a new account's profile.\n- **What is already public stays public**, and may stay so when it is edited.\n  The setting governs what is written from now on."},{"id":"using-them-together","heading":"Using them together","level":3,"markdown":"| `SITE_READ_ACCESS` | `MIN_CONTENT_ACCESS` | The site |\n| --- | --- | --- |\n| `public` | `public` | Open. The default |\n| `public` | `registered` | Old public posts stay readable by visitors; nothing new is added to them |\n| `preview` | `public` | Visitors see the start of things; people still post publicly, which is what makes a preview worth having |\n| `registered` | `public` | Members-only. \"Public\" then means every signed-in person, and would mean everybody again if the site were opened |\n| `registered` | `registered` | Fully private, and it would stay private if the site were opened later |"},{"id":"making-what-is-already-public-members-only","heading":"Making what is already public members-only","level":3,"markdown":"`MIN_CONTENT_ACCESS=registered` does not reach back. A super administrator can\nbring the rest into line, once: **Site Settings › Privacy › Make everything\npublic members-only**.\n\n- It changes every public person, group and post to `registered`. Hashtags and\n  locations are left: they are nobody's content.\n- It asks for the number of things it will change to be typed (`CONVERT 8675`)\n  and for a proof of identity.\n- It is refused, with 409 `public_content_allowed`, on an installation that\n  still allows public content: there it would be undone by the next public\n  post.\n- It records who did it and how much it changed, in `site_actions`.\n- **It cannot be undone.** Nothing records what was public before.\n\nThe API: `GET /accounts/public-content` for the counts, and\n`POST /accounts/make-registered` with `{ \"confirm\": \"CONVERT 8675\" }`."},{"id":"setting-them","heading":"Setting them","level":2,"markdown":"Both are environment variables read by every service, so they are set in one\nplace and need a restart.\n\nWith docker-compose, in `.env`:\n\n~~~sh\nSITE_READ_ACCESS=registered\nMIN_CONTENT_ACCESS=registered\n~~~\n\nthen `make up`.\n\nOn Kubernetes, in the overlay's `config.env`, then apply it and restart the\ndeployments. See [Deploying](https://www.anahita.io/docs/services/deploying/).\n\nA value that is not understood is treated as `registered`, the strictest, and\neach service says so in its log:\n\n~~~\npolicy: SITE_READ_ACCESS is \"privat\", which is not \"public\", \"preview\" or \"registered\"; treating it as \"registered\"\n~~~"},{"id":"checking-them","heading":"Checking them","level":2,"markdown":"NodeInfo says what the installation is set to, and answers on every kind of\nsite:\n\n~~~sh\ncurl -s https://api.example.org/nodeinfo/2.1 | grep -E 'readAccess|minContentAccess'\n~~~\n\nThen, without signing in, ask for something public. `SOMEONE` is any handle\nand `5` any person's id:\n\n| Request | Open | Preview | Members-only |\n| --- | --- | --- | --- |\n| `GET /people/SOMEONE` | 200 | 200 | 401 |\n| `GET /notes/5/?limit=50` | 200 | 200, at most 10 items, long ones cut and marked `truncated` | 401 |\n| `GET /notes/5/?start=10` | 200 | 401 | 401 |\n| `GET /socialgraph/5/followers/` | 200 | 401 | 401 |\n| `GET /photos/5/download` (where the post allows it) | 302 | 403 | 401 |\n| `GET /oauth/login` | 200 | 200 | 200 |"},{"id":"what-the-web-app-does","heading":"What the web app does","level":2,"markdown":"- **The audience button** offers what the server says a post may have there.\n  A choice wider than the profile is greyed out, with the reason.\n- **On a members-only site**, a visitor sees a notice with a Sign in button\n  and the home page, in place of whatever they asked for and at the same\n  address. The search box and the People, Groups, Hashtags and Locations menu\n  entries are not shown. Pages carry `robots: noindex`.\n- **On a preview site**, a visitor sees a notice above each page, \"Sign in to\n  read more\" under a post that was cut, and a sign-in prompt where a post's\n  replies or a profile's list of followers would be. The app does not ask the\n  server for those.\n- **Where nothing new may be public**, \"Public\" is not offered.\n- **Site Settings › Privacy**, for super administrators, shows the two\n  settings as the server has them."},{"id":"where-it-is-enforced-and-how-it-is-tested","heading":"Where it is enforced, and how it is tested","level":2,"markdown":"Each rule lives in one place, with tests that run without a database.\n\n| Rule | Where | Tests |\n| --- | --- | --- |\n| Reading the two settings, and treating a bad value as the strictest | `anahita-libs/policy` | `policy_test.go` |\n| Which audiences a post may have | `permissions.MediumAudiences` in `anahita-libs/permissions/audience.go` | `audience_test.go` |\n| Who can see a post, a profile or a group | `SetMediumAccessQuery` and `SetActorAccessQuery` in `graph-grpc-service/data/repositories/` (`access_medium.go`, `access_actor.go`, `access.go`) | `access_test.go` |\n| Refusing a visitor on a members-only site | `SetViewer` in `anahita-libs/middleware/oauth.go` | `members_only_test.go`, `signed_in_test.go` |\n| Cutting what a visitor is sent on a preview site | `anahita-libs/middleware/preview.go` | `preview_test.go` |\n| Making everything public members-only | `nodes_public_access.go` in graph-grpc-service, which records it in table `site_actions`; the endpoint in auth-service (`public_content.go`) | `nodes_public_access_test.go`, `public_content_test.go` |\n| What NodeInfo reports | auth-service | `node_info_privacy_test.go` |\n| Sending nothing out on a preview or members-only site | `siteFederates` in `activitypub-service/cmd/api/handlers/handlers.go`; `canWrite` in `atproto-service/cmd/api/handlers/settings.go` | `handlers_test.go` (activitypub-service), `out_test.go` (atproto-service) |\n\nThree tests are guards. They read the code and fail when something new goes\naround a rule:\n\n- **`TestEveryRouteNamesItsViewer`** reads every service's routes and fails if\n  one has neither `Authorize` nor `SetViewer` in front of it. Such a route\n  would serve visitors on a members-only site.\n- **`TestTheRoutesAPreviewLeavesOutSaySo`** checks that replies and the\n  follower lists are marked as left out of a preview.\n- **`TestEveryHandlerThatSetsAnAudienceChecksIt`** checks that every handler\n  that creates a post, changes its audience, changes a profile's access or\n  creates an account asks the rule first.\n\n`signed_in_test.go` is the other half of both restrictions: somebody who is\nsigned in is never refused and never sent a preview.\n\nThe services' own reads, with nobody signed in, are not restricted: writing a\nnotification, extracting hashtags, creating an account. They do not come\nthrough `SetViewer`."},{"id":"federation","heading":"Federation","level":2,"markdown":"- Only `public` posts of people who chose to take part are sent to other\n  servers: on the fediverse, people who turned federation on; on Bluesky,\n  people who connected their own account. Only people whose profile\n  anybody may see take part at all.\n- With `MIN_CONTENT_ACCESS=registered`, nothing new is public, so nothing\n  new is sent.\n- With `SITE_READ_ACCESS` set to `preview` or `registered`, the installation\n  sends nothing to the fediverse or to Bluesky, and connecting a Bluesky\n  account is not offered. On a preview site, sending out would give away\n  the whole of what the preview holds back. Reading from elsewhere still\n  works.\n- What came from elsewhere is copied in, so a reader's browser never asks\n  another server for it. See [Federation](https://www.anahita.io/docs/services/federation/#who-owns-what)."}]},{"source":"services","slug":"abuse-reports","title":"Abuse reports","group":"Run a site","url":"https://www.anahita.io/docs/services/abuse-reports/","sections":[{"id":"","heading":null,"level":1,"markdown":"How people report something, how administrators deal with it, and how an\ninstallation sets the reasons people can choose from. It is all in\nabuse-report-service, under `/abuse-reports`.\n\nIts routes are in the API reference: [abuse-report-service](https://www.anahita.io/api/abuse-report/)."},{"id":"reports-and-cases","heading":"Reports and cases","level":2,"markdown":"Anything can be reported: a person, a group, a post, a reply, a hashtag or\na location.\n\nA **report** is one person's complaint. A **case** is one reported thing and\nthe decision about it. Everybody who reports the same thing while its case is\nopen joins that case, so a post that twenty people report is one case for an\nadministrator to handle.\n\nA case is `open` until somebody decides it, and then `actioned` (something was\ndone) or `dismissed` (nothing needed doing). If the same thing is reported\nagain after its case was closed, a new case opens, and the earlier one shows\nas its history.\n\nReports are not nodes. They are kept in two tables of their own,\n`abuse_cases` and `abuse_reports`, so that nothing which lists, searches,\nfederates or exports content can ever show one, and so that a case outlives\nthe thing it was about."},{"id":"reporting-something","heading":"Reporting something","level":2,"markdown":"`POST /abuse-reports` with `{ \"target_id\": 123, \"reason_key\": \"spam\", \"details\": \"…\" }`.\n\n| Answer | Meaning |\n| --- | --- |\n| 201 | Reported |\n| 200 | You had already reported it. Your report now has the new reason and details |\n| 400 | The reason is not on the list or does not apply to this kind of thing, the reason needs an explanation and none was given, the rule is not one of the rules it is under (`rule_required`, `rule_not_applicable`), or it is your own |\n| 403 | You are not signed in, or the app you use may not write for you (`account:write`) |\n| 404 | There is no such thing that you can see |\n| 429 | More than 20 reports in the last hour |\n\n- **Signed in only.**\n- **You can only report what you can see.** Something hidden from you answers\n  exactly as something that does not exist, so reporting cannot be used to\n  find out what is hidden.\n- **Not yourself, and not what you wrote.**\n- **One report per person per case.** Reporting again changes yours.\n- **Details** are optional, up to 1,000 characters, except where the reason\n  requires them."},{"id":"the-reasons-list","heading":"The reasons list","level":2,"markdown":"`GET /abuse-reports/reasons?type=<node type>` returns the reasons that can be\ngiven for that kind of thing, in the reader's language (`?lang=fr-FR`, or the\nbrowser's `Accept-Language`).\n\nThe service carries a default list, in English and French:\n\nSpam · Harassment or bullying · Racism · Sexism · Other hate · Violence or\nthreats · Self-harm or suicide · Sexual content shared without consent · Child\nsafety · Impersonation (people and groups only) · Private information ·\nMisleading or false information · Intellectual property or copyright ·\nTrademark · Illegal content or activity · Something else (an explanation is\nrequired)"},{"id":"using-your-own-reasons","heading":"Using your own reasons","level":2,"markdown":"What a community forbids is its own business. To replace the list:\n\n1. Copy `abuse-report-service/data/reasons/reasons.json` and edit it.\n2. Make the file available to the service: a volume in docker-compose, a\n   ConfigMap on Kubernetes.\n3. Set `REASONS_PATH` to where it is mounted.\n\nThe file is read again whenever it changes, so an edit takes effect without a\nrestart.\n\n~~~json\n{\n  \"reasons\": [\n    {\n      \"key\": \"hate_racism\",\n      \"label\": { \"en-GB\": \"Racism\", \"fr-FR\": \"Racisme\" },\n      \"description\": {\n        \"en-GB\": \"Attacks or slurs based on race or ethnicity.\",\n        \"fr-FR\": \"Attaques ou injures fondées sur la race ou l’origine ethnique.\"\n      },\n      \"applies_to\": [\"node.actor.\", \"node.medium.\"],\n      \"requires_details\": false,\n      \"atproto_reason\": \"com.atproto.moderation.defs#reasonRude\"\n    }\n  ]\n}\n~~~\n\n| Field | Meaning |\n| --- | --- |\n| `key` | What is stored on each report. Up to 64 characters, and unique. Treat it as permanent: reports that used a key you remove show the key in place of a label |\n| `label` | The name, by language. `en-GB` is required |\n| `description` | A sentence shown under the name. Optional |\n| `applies_to` | The kinds of thing the reason can be given for, as type prefixes: `node.actor.` (people and groups), `node.medium.` (posts and replies), `node.tag.` (hashtags and places). Leave it out for everything |\n| `requires_details` | `true` makes the reporter explain |\n| `atproto_reason` | The AT Protocol reason this corresponds to. Not used yet; it is there for when reports are sent to other servers |\n\nA file that cannot be used, because it is missing, is not valid JSON, has no\nreasons, repeats a key or lacks an `en-GB` label, is not applied in part. The\nbuilt-in list is used until it is fixed, and the service's log says what is\nwrong with it."},{"id":"breaking-a-rule","heading":"Breaking a rule","level":2,"markdown":"Besides a reason from the list, a report can say which rule was broken: one\nof the site's rules (`site.json`, [https://www.anahita.io/docs/services/configuration/](https://www.anahita.io/docs/services/configuration/)) or\none of the rules of the group or event it was posted in, or of the group or\nevent itself. The rules are the reasons here, so they are not in the reasons\nfile.\n\n`POST /abuse-reports` with `{ \"target_id\": 123, \"reason_key\": \"rule\", \"rule\": \"Be kind\", \"rule_profile_id\": 456 }`.\n\n| Field | Value |\n| --- | --- |\n| `reason_key` | `rule` |\n| `rule` | The rule's title as the reporter read it, up to 100 characters |\n| `rule_profile_id` | `0` for the site's rules, or the group's or event's id |\n\n- **A group's or an event's rule is checked:** it must be one of its rules\n  now, and the thing reported must be the group or event, be posted in it,\n  or be a reply in a thread posted in it. Otherwise `rule_not_applicable`.\n- **The site's rules are taken as given.** This service does not read\n  `site.json`; the administrator reading the report has the rules in front\n  of them.\n- **Stored with the report**, in `abuse_reports.rule_title` and\n  `rule_profile_id`. The case answers with `rule`, `rule_profile_id` and the\n  profile itself as `rule_profile`.\n- **Filtered like any reason:** `GET /abuse-reports/cases?reason=rule`."},{"id":"a-groups-own-reports","heading":"A group's own reports","level":3,"markdown":"A group's or an event's administrators see the reports against its rules:\n`GET /abuse-reports/profiles/:id/cases` (`?status=open`, the default, and\n`limit`, `start`), in the web app the **Reports** tab of its settings.\n\n| Answer | Meaning |\n| --- | --- |\n| 200 | The cases with a report against its rules, newest first, each with those reports: `rule`, `details` and `created_at` |\n| 403 | You administer neither it nor the site |\n| 404 | There is no such group or event |\n\n- **Nobody's name.** Who reported is shown to the site's administrators\n  only.\n- **Never what is about them.** A report on the group or event itself, or on\n  something the viewer is answerable for, is left out: whoever is reported\n  is not told.\n- **They act where it is.** Deleting a post on its page closes its case, as\n  for any deleted post. Deciding a case (Dismiss, Close) stays with the\n  site's administrators, whose case it is."},{"id":"what-administrators-do","heading":"What administrators do","level":2,"markdown":"In the web app, under **Administration › Reports**. The endpoints:\n\n| Endpoint | Does |\n| --- | --- |\n| `GET /abuse-reports/cases?status=open&reason=spam` | Lists cases, the most recently reported first, 20 at a time (`limit`, `offset`). `status` is `open`, `actioned` or `dismissed`. `reason` is optional: with it, only the cases in which somebody gave that reason |\n| `GET /abuse-reports/cases/summary` | `{ \"open\": n }`, for the number on the Administration menu |\n| `GET /abuse-reports/cases/:id` | The case: what was reported and by whom, each report's reason and details, and the earlier cases the same thing has had |\n| `POST /abuse-reports/cases/:id/resolve` | Closes it: `{ \"status\": \"actioned\" or \"dismissed\", \"action\": \"…\", \"note\": \"…\" }` |\n| `POST /abuse-reports/cases/:id/reopen` | Opens a closed case again |\n\n**The service records decisions. It does not carry them out.** Deleting a\npost, disabling an account or archiving a group is done through the service\nthat owns it, under that service's own rules: an administrator who may not\narchive a group cannot do it from here either. The app does the action first,\nthen records the case as `actioned` with what was done: `deleted`, `disabled`,\n`archived`, `blocked`, `removed_as_spam` or `other`. A person or a group\nreported can be removed as spam from its case, which is then closed as\n`removed_as_spam` ([Removing as spam](https://www.anahita.io/docs/services/account-lifecycle/#removing-as-spam)).\n\nTwo administrators deciding the same case at once get one decision recorded;\nthe second is told it was already decided (409). A closed case cannot be\nreopened while a newer case for the same thing is open (409,\n`newer_case_open`): use the newer one. Reopening a case that is open already\nis answered 409 with `already_open`.\n\nNobody is emailed about a report. Administrators see the number waiting on\nthe Administration menu."},{"id":"when-the-reported-thing-is-deleted","heading":"When the reported thing is deleted","level":2,"markdown":"There is nothing left to decide about something that no longer exists, so its\nopen case is closed as `actioned`, `deleted`, with nobody named as having\ndecided it.\n\n- A **post or reply** is gone the moment it is deleted.\n- A **person or group** is not: deleting one starts a 30-day wait during which\n  it can be restored. Its case stays open until it is purged.\n\nThe service hears of this through an event. Events can be lost, so opening a\ncase also checks whether the thing still exists.\n\nWhen a **person who filed reports** is purged, their reports are removed with\neverything else they wrote. A case that other people also reported keeps going\nwith one report fewer. An open case that only they had reported is removed,\nbecause no complaint is left to decide. A case already decided stays, as the\nrecord of the decision.\n\nThis too arrives as an event, and nothing checks again if it is lost: the\nreports then stay, naming an account that is only a reserved handle."},{"id":"who-sees-what","heading":"Who sees what","level":2,"markdown":"| | Sees |\n| --- | --- |\n| The reporter | That their report was received. Nothing after that |\n| The person reported | Nothing. They are not told of the report, or who made it |\n| Administrators and super administrators | Every case, every report, and who made it |\n\nThe network address a report came from is stored with it and is not shown in\nthe app. It is blanked 90 days after the report was filed; the report itself\nstays.\n\nFiling a report needs the `account:write` scope. Reading cases needs\n`actors:read` and deciding them `actors:write`, on top of being an\nadministrator."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The service | `abuse-report-service/` |\n| Reporting something | `abuse-report-service/cmd/api/handlers/reports.go` (`Add`) |\n| The reasons list, and your own | `abuse-report-service/data/reasons/reasons.go` (`BuiltIn`, `Parse`, `Source`), `abuse-report-service/data/reasons/reasons.json`, setting `REASONS_PATH` |\n| What administrators do | `abuse-report-service/cmd/api/handlers/cases.go`, `abuse-report-service/cmd/api/permissions/permissions.go` |\n| Cases and reports | `abuse-report-service/data/repositories/cases.go`, tables `abuse_cases` and `abuse_reports` in `migration-job/schema/init.sql` |\n| When the reported thing is deleted | `abuse-report-service/cmd/api/handlers/events.go` (`Handle`) |\n| Blanking network addresses after 90 days | `abuse-report-service/cmd/api/main.go` (`forgetOldAddresses`) |\n| Limits: 20 reports an hour, 1,000 characters | `abuse-report-service/constants/constants.go` |\n| Tests of the rules | `abuse-report-service/cmd/api/handlers/handlers_test.go`, `abuse-report-service/data/repositories/cases_integration_test.go` |\n| The web app | `src/containers/admin/Reports/`, `src/api/abuseReports.js` (anahita-web) |"}]},{"source":"services","slug":"account-lifecycle","title":"Account lifecycle","group":"Run a site","url":"https://www.anahita.io/docs/services/account-lifecycle/","sections":[{"id":"","heading":null,"level":1,"markdown":"What can happen to a person's account or a group: disabling, archiving,\ndeleting and purging. It covers who may do each, what each removes, and how a\nsuper administrator clears out accounts nobody uses.\n\nIts routes are in the API reference: [actor-service](https://www.anahita.io/api/actor/) for a profile, and the accounts list in [auth-service](https://www.anahita.io/api/auth/#getAccounts)."},{"id":"the-four-states","heading":"The four states","level":2,"markdown":"| | What it does | Undone by | Who may |\n| --- | --- | --- | --- |\n| **Disable** | The account cannot be used or seen. Nothing is removed | Enabling it again | Site administrators |\n| **Archive** | The account stops being live: it cannot sign in or post, and it is no longer suggested to anybody. Everything it posted stays readable at the same addresses | Nothing. It is permanent | The person themselves, a group's administrators, or a super administrator |\n| **Delete** | The account disappears at once and is purged 30 days later | Restoring it within the 30 days | The person themselves, a group's administrators, or a site administrator |\n| **Purge** | Everything the account owned or wrote is removed for good | Nothing | Super administrators, or the 30 days running out |\n\nDeleting, archiving and purging ask the person doing it to prove who they are\nagain first, with their password or a passkey.\n\nNobody can disable, archive, delete or purge the only super administrator. The\nanswer is 409, and says to make somebody else a super administrator first (see\n[Account types](https://www.anahita.io/docs/services/permissions/#account-types))."},{"id":"deleting-and-the-30-days","heading":"Deleting and the 30 days","level":2,"markdown":"Thirty days unless the installation says otherwise, in `ACCOUNT_GRACE_DAYS`\n([Account cleanup](https://www.anahita.io/docs/services/configuration/#account-cleanup)); the web app says the\nsite's number.\n\nA deleted account is hidden immediately and its sessions are ended. Nothing it\nowns is touched, so restoring it brings it back whole.\n\nHidden, except from those with an edge to it (the user, 2026-10-18): its\nfollowers and its administrators still find it in their lists of who they\nfollow and what they look after, and on its profile, labelled **Scheduled for\ndeletion** with the days left, so an administrator can get back to it and\nrestore it. Super administrators see every one: in those lists too, and all\ntogether, soonest first, on the **Scheduled for deletion** tab of the web\napp's administration (`GET /accounts?state=deleted` for each kind: `person`,\n`group`, `event`, `website`). Anybody else no longer finds it. Once purged it is gone from every\nlist. A disabled account leaves every list until it is enabled again; an\narchived one stays.\n\n- A **person** restores their account by signing in within 30 days. They are\n  emailed when the deletion is scheduled, with the date it becomes final.\n- A **group** cannot sign in, so one of its administrators restores it from\n  the group's settings. The group's administrators are emailed when it is\n  deleted and when it is restored: each whose own account is enabled, but\n  not the one who did it.\n\nAfter 30 days the [sweeper](#the-sweeper) purges it."},{"id":"what-a-purge-removes","heading":"What a purge removes","level":2,"markdown":"Everything the account **owns**, everything it **wrote**, and everything\nunderneath either:\n\n- its posts, wherever they are: on its own profile, in a group, or on somebody\n  else's profile;\n- its replies to other people's posts, and whatever was said under those\n  replies;\n- **other people's replies to its posts**, which go with the posts;\n- every like, follow, subscription, block and administrator role it had;\n- every notification sent to it, or sent to others about it or its posts;\n- its uploaded avatar, cover and photos, from object storage;\n- its sign-in details, sessions, passkeys and sign-in history;\n- the invitations it sent that nobody used, and the signup request it began\n  as, where signing up needed approval;\n- the abuse reports it filed.\n\nThe numbers other people see are counted again afterwards: the follower and\nfollowing counts of everybody it was connected to, and the like, reply and\nsubscriber counts on posts and replies that stay.\n\nThe confirmation shown before a deletion counts this same set, so the figures\nthere are what will go."},{"id":"what-a-purge-leaves","heading":"What a purge leaves","level":2,"markdown":"- **A tombstone.** The account's row stays, with the handle and nothing else.\n  It is what keeps the handle from being registered again by somebody who\n  would then inherit the old account's mentions and followers.\n- **What the account started but does not own**: groups, hashtags and locations\n  it created. Other people are in the group and other posts carry the tag.\n- **The audit trail**: who deleted or purged what, and when\n  (`node_deletion_logs`), and the purge request, if a super administrator\n  asked for it (`purge_requests`).\n- **Abuse cases about it**, closed as `actioned`, `deleted`."},{"id":"purging-now","heading":"Purging now","level":2,"markdown":"A super administrator can remove accounts without the 30 days. This is for\nclearing out accounts nobody uses, not for moderation: for that, disable the\naccount or decide its [abuse report](https://www.anahita.io/docs/services/abuse-reports/).\n\n| Endpoint | What it does |\n| --- | --- |\n| `POST /people/purge-batch` | Removes up to 500 people |\n| `POST /groups/purge-batch` | Removes up to 500 groups |\n| `DELETE /people/:id/purge`, `DELETE /groups/:id/purge` | Removes one |\n| `GET /people/purge-batch/:batch_id`, `GET /groups/purge-batch/:batch_id` | How far a request has got |\n\nThe body of a batch request names the accounts and repeats their number:\n\n~~~json\n{ \"ids\": [41, 42, 57], \"confirm\": \"PURGE 3\" }\n~~~\n\n| Answer | Meaning |\n| --- | --- |\n| 202 | Queued. The body has the `batch_id` to ask about, and how many were `queued` and `skipped` |\n| 200 | Nothing to do: every account named was already purged or queued |\n| 400 `confirmation_mismatch` | `confirm` does not name the number of ids |\n| 403 `step_up_required` | Prove who you are first |\n| 409 `purge_refused` | One or more of the accounts cannot be purged. Nothing was touched. `refused` gives the reason for each |\n\nThe reasons an account stops a request:\n\n| Reason | Meaning |\n| --- | --- |\n| `not_found` | No such account |\n| `wrong_type` | Not a person, on the people route; not a group, on the groups route |\n| `administrator` | An administrator or super administrator. A batch never removes one: change their account type first ([Account types](https://www.anahita.io/docs/services/permissions/#account-types)), or remove them by name with the single route |\n| `yourself` | The account asking |\n| `last_super_administrator` | The only super administrator |\n\nA request does not wait for the work. The accounts are hidden before it\nanswers, and the purge follows in the background, usually within seconds. The\nprogress endpoint gives `total`, `queued`, `done` and `failed`, and each\naccount's state. A purge that fails is tried three times and then marked\n`failed` with the reason; asking again for that account tries again.\n\nNobody is emailed about a purge."},{"id":"removing-as-spam","heading":"Removing as spam","level":2,"markdown":"A moderator's decision, not a cleanup (roadmap row 102): an account found to\nbe spam is removed at once, as a purge removes it, without the 30 days a\ndeleted account waits, since by the time those run out it may be too late.\n\n| Endpoint | What it does |\n| --- | --- |\n| `POST /people/:id/spam` | Removes a person as spam |\n| `POST /groups/:id/spam` | Removes a group as spam |\n\n- **Site administrators and super administrators** may, with no proof of\n  identity: the web app asks once more, saying it cannot be undone, and\n  spam is removed often and in a hurry.\n- **Never an administrator's or a super administrator's account,** nor the\n  viewer's own: refused with 409 `purge_refused`, `administrator` or\n  `yourself`, as a batch purge refuses them.\n- **Only a new account,** made in the last 30 days (`SPAM_REMOVAL_MAX_DAYS`):\n  spam is new, and one here longer is disabled or deleted instead, with the\n  30 days to bring it back (the user, 2026-10-19). An older one is refused\n  with `too_old`, and the web app offers the action only while it may.\n- **Recorded** in `node_deletion_logs` as `removed-as-spam` with who decided,\n  when it is asked for; the purge then writes its own rows (`purged`,\n  `hard-deleted`).\n- The answer, and how far it has got, are as for a purge (202 with a\n  `batch_id`).\n- In the web app: **Remove as spam** in the profile's menu, and on a report's\n  case, which is then closed as `removed_as_spam`\n  ([Abuse reports](https://www.anahita.io/docs/services/abuse-reports/#what-administrators-do))."},{"id":"the-accounts-list","heading":"The accounts list","level":2,"markdown":"`GET /accounts` lists the site's people or groups for cleanup, least recently\nactive first. Super administrators only.\n\n| Parameter | Value |\n| --- | --- |\n| `kind` | `person` (the default) or `group` |\n| `q` | Part of a name or handle |\n| `tier` | `empty`, `dormant` or `active` |\n| `state` | `active`, `disabled`, `archived` or `deleted` |\n| `last_active_before` | A date, `YYYY-MM-DD` |\n| `created_before` | A date, `YYYY-MM-DD` |\n| `content_max` | At most this many posts and replies. `0` for none |\n| `email_verified=false` | People who never verified their address |\n| `onboarded=false` | People who never finished the welcome steps |\n| `has_admin=false` | Groups nobody administers |\n| `sort`, `dir` | `last_active_at`, `created_at` or `name`; `asc` or `desc` |\n| `limit`, `offset` | `limit` is 50 by default and at most 500 |\n\nEach account comes with when it was created and last active, how much it has\nwritten, how many follow it, its state and its tier. Purged accounts are not\nlisted.\n\n**Last active** is the latest of: the last visit, the last sign-in, the last\nthing written, and the day the account was created. For a group it is the last\npost in it.\n\n**Tiers:**\n\n| Tier | Meaning |\n| --- | --- |\n| `empty` | Nothing written, follows nobody and has no followers. For a group: nothing posted in it and at most one follower |\n| `dormant` | Not empty, and last active more than two years ago |\n| `active` | Everything else |\n\nThe two years can be changed with `ACCOUNT_DORMANT_DAYS`; see\n[Configuration](https://www.anahita.io/docs/services/configuration/#account-cleanup). A tier only sorts accounts\nfor somebody to look at. Nothing is removed because of one."},{"id":"the-sweeper","heading":"The sweeper","level":2,"markdown":"The `account-sweeper` job does the purging that is nobody's request: every\naccount whose 30 days have run out. It also finishes any requested purge that\nwas interrupted.\n\n- On Kubernetes it is a CronJob, every ten minutes, and never two at once.\n- With docker-compose it is not started by `make up`. Run it with\n  `docker compose run --rm account-sweeper`.\n\nA purge works through an account in batches of 500 posts, and keeps a list of\nwhat is left (`purge_work`). If the job is stopped part-way, the next run\ncarries on from that list. An account of any size finishes.\n\nUploaded files are removed only where `FILE_SERVICE_ADDR` is set, for both the\nsweeper and graph-grpc-service. Without it the database is cleared and the\nfiles stay in storage.\n\nauth-service and abuse-report-service learn of a purge through an event. If\nthat event is lost, the sign-in details or the filed reports of that account\nstay until somebody removes them by hand; nothing retries."},{"id":"groups-left-without-an-administrator","heading":"Groups left without an administrator","level":2,"markdown":"Purging a person who was a group's only administrator leaves the group in\nplace with nobody running it. Find these with\n`GET /accounts?kind=group&has_admin=false`.\n\nA site administrator can then appoint one of its followers with\n`POST /groups/:id/admins/`, giving that person's id as `admin_id`."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Disabling, archiving, deleting and restoring | `actor-service/cmd/api/handlers/person.go` (`SetEnabled`, `Archive`, `Delete`), `group.go` (`Archive`, `Restore`); in the graph, `graph-grpc-service/data/repositories/nodes_archive.go` (`ArchiveActor`, `SetEnabled`) and `nodes_soft_delete.go` (`SoftDeleteActor`, `RestoreActor`) |\n| Proving who you are again | `actor-service/cmd/api/handlers/step_up.go` (`requireStepUp`) |\n| The only super administrator | `graph-grpc-service/data/repositories/nodes_super_admin.go` (`GuardLastSuperAdmin`) |\n| What a deleted person is emailed, and their sign-in details removed on a purge | `auth-service/cmd/api/handlers/account_deleted.go` (`handlePurged`), `auth-service/cmd/api/notifiers/account_delete.go` |\n| What a purge removes and leaves | `graph-grpc-service/purge/purge.go` (`Account`), `graph-grpc-service/data/repositories/nodes_purge_steps.go`, `nodes_purge.go` (`TombstoneActor`), `nodes_deletion_counts.go` (`ReadDeletionCounts`), tables `purge_work` and `node_deletion_logs`. Checked by `purge_integration_test.go` |\n| Purging now | `actor-service/cmd/api/handlers/person_purge.go`, `group_purge.go`; `graph-grpc-service/data/repositories/purge_requests.go` (`PurgeRequestMaxAttempts`), table `purge_requests` |\n| The filed reports of a purged person | `abuse-report-service/cmd/api/handlers/events.go` |\n| The accounts list | `auth-service/cmd/api/handlers/accounts.go`, `auth-service/data/repositories/accounts.go`; `auth-service/config/bootstrap.go` (`GetAccountDormantDays`), setting `ACCOUNT_DORMANT_DAYS` |\n| The sweeper | `graph-grpc-service/sweeper/sweeper.go` (`Run`; the days are `anahita-libs/constants` `AccountGraceDays()`), `graph-grpc-service/cmd/sweeper/main.go`, `k8s/base/jobs/sweeper-cronjob.yml`, `account-sweeper` in `docker-compose.yml`; settings `FILE_SERVICE_ADDR` and `ACCOUNT_GRACE_DAYS` |"}]},{"source":"services","slug":"signing-in","title":"Signing in","group":"People","url":"https://www.anahita.io/docs/services/signing-in/","sections":[{"id":"","heading":null,"level":1,"markdown":"The ways a person proves who they are, and the limits around them. Everything\nhere is in auth-service, whose pages the web app sends people to.\n\nIts routes are in the API reference: [auth-service](https://www.anahita.io/api/auth/)."},{"id":"the-ways-in","heading":"The ways in","level":2,"markdown":"| Way | Page | Then |\n| --- | --- | --- |\n| Password | `/oauth/login` | Two-factor, if the account has it |\n| Passkey | `/oauth/login`, the passkey button | Nothing more: a passkey is both factors |\n| Emailed code | `/oauth/login/code` | Two-factor, if the account has it |\n\nAll three end the same way, and all three apply the same rules to the account\nonce it has been proved: a deleted account is offered its restore, an archived\nor disabled one is refused."},{"id":"signing-in-with-a-code","heading":"Signing in with a code","level":2,"markdown":"1. The person follows **Sign in with a code instead** and enters their email\n   address or username.\n2. If the account exists, it is emailed a 6-digit code. The code is in the\n   subject as well, so it can be read from a notification.\n3. They type the code on the page that is waiting for it.\n\nThe email carries no link. The page that wants the code is already open, and\na link is what mail scanners follow.\n\nThe pages never say whether an account exists. An address with no account\ngets the same pages and wording, and the same five tries, as a real one. It\nis just never sent anything. A username is never answered with its address.\n\nA code sent to an account whose address was never confirmed confirms it: the\ncode arriving and coming back is what the confirmation link proves."},{"id":"what-a-code-can-and-cannot-do","heading":"What a code can and cannot do","level":2,"markdown":"- It replaces the **password**, not the second factor. An account with\n  two-factor turned on is still asked for its authenticator code.\n- It works **once**, for **10 minutes**, and only in the browser that asked\n  for it: the browser's session holds which code it is waiting for.\n- A **new code retires the old one.**\n- It is checked against the account **as it is when the code is typed**, not\n  when it was sent.\n- A code is issued for one purpose and refused for any other."},{"id":"limits","heading":"Limits","level":2,"markdown":"| What | Limit | Then |\n| --- | --- | --- |\n| Tries at one code | 5 | The code is dead; ask for another |\n| Codes requested for one address or username | 5 an hour | Nothing more is sent. The pages look the same |\n| Codes requested from one network address | 20 an hour | The same |\n| Password tries for one address or username | 10 in 15 minutes | Refused until the time has passed |\n| Password tries from one network address | 50 in 15 minutes | The same |\n\nSomething that is not six digits is not counted as a try. Every limit is\ncounted before the account is looked up, so reaching one says nothing about\nwhether the account exists. A successful sign-in clears that address's count.\n\nSomebody guessing at an account's password can use up its ten tries and lock\npassword sign-in for a quarter of an hour. A code or a passkey still works.\n\nThe counts are kept in Redis. If Redis cannot be reached they are not\nenforced, and the log says so.\n\nThe sign-in forms carry a token tied to the browser's session, so another\nsite cannot submit them on somebody's behalf."},{"id":"how-codes-are-kept","heading":"How codes are kept","level":2,"markdown":"The six digits are never stored. The `tokens` row holds an HMAC of them,\nkeyed with a value derived from `SECURE_COOKIE_KEY` and salted with the\ntoken's own random identifier. Reading the database does not reveal a code,\nand trying all million possibilities against it is not possible without the\nkey. Changing `SECURE_COOKIE_KEY` invalidates the codes outstanding at that\nmoment.\n\nCounting a try and using a code up are each a single statement in the\ndatabase, so guesses arriving at the same moment cannot share a try, and only\none of two correct submissions signs in."},{"id":"resetting-a-forgotten-password","heading":"Resetting a forgotten password","level":2,"markdown":"1. At `/password/forgot` the person enters their email address.\n2. If it has an account, it is emailed a 6-digit code, valid for 15 minutes\n   and five tries.\n3. They type the code on the page that is waiting for it, and only then are\n   shown the form for a new password.\n\nThe code is used up when the password is set, so a mistake on the password\nform can be corrected without a new code. Asking for a new code retires the\nearlier one, including in a browser that had already entered it.\n\nSetting the password this way removes every passkey and signs out every\nsession, and the account is told by email. It does not sign the person in.\n\nAt most three codes an hour are sent to one address, and the pages do not say\nwhether the address has an account.\n\nOne link remains: the set-password link for the\n[first super administrator](https://www.anahita.io/docs/services/registration/#the-first-super-administrator),\nwhich is read from a log with no page open to type a code into."},{"id":"changing-an-email-address","heading":"Changing an email address","level":2,"markdown":"Done from the app's settings, signed in. The address is what password reset\ndelivers to, so moving it takes two proofs:\n\n1. **It is you.** `PATCH /email/change` with the new address needs a recent\n   step-up (a passkey, or the password and two-factor). One step-up buys one\n   request.\n2. **You hold the current inbox.** A 6-digit code is mailed to the *current*\n   address, naming the new one. The person types it into the same settings\n   screen, which calls `POST /email/change/confirm` with `{ \"code\": \"…\" }`.\n\n| Answer | Meaning |\n| --- | --- |\n| 204 | Changed. Every passkey is removed and every session is signed out, this one included. Both addresses are told |\n| 400 | Not six digits. Nothing was counted |\n| 409 | The new address was taken in the meantime |\n| 410 | Nothing is pending, or the code expired (15 minutes), was replaced by a newer request, or ran out of its five tries |\n| 422 | Wrong code. The body is `{ \"remaining\": n }` |\n\nThe code is found by whose it is, never by its digits, so one person cannot\nuse or wear out another's."},{"id":"adding-text-messages-later","heading":"Adding text messages later","level":2,"markdown":"The flow has a step for choosing how the code is sent, which is skipped while\nemail is the only way. Text messages need a verified phone number on the\naccount, a sender, and `sms` added to the list of channels in\n`auth-service/cmd/api/handlers/oauth_login_code.go`. The pages, the limits and\nthe storage do not change."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Signing in with a password, then two-factor | `auth-service/cmd/api/handlers/oauth_login.go` (`AddCredentials`, `AddTotp`) |\n| Signing in with a passkey | `auth-service/cmd/api/handlers/oauth_web_authn.go`, table `webauthn_credentials` |\n| Signing in with a code | `auth-service/cmd/api/handlers/oauth_login_code.go` (`loginCodeChannels`), `oauth_login_code_test.go` |\n| Issuing and checking codes | `auth-service/cmd/api/handlers/otp.go` (`issueCode`, `verifyCode`, `consumeCode`), `auth-service/data/repositories/tokens.go` (`SpendCodeAttempt`, `ConsumeCode`), table `tokens` |\n| How codes are kept | `auth-service/data/entities/token.go` (`hashCode`), `auth-service/config/config.go` (`GetCodeKey`); setting `SECURE_COOKIE_KEY` |\n| Code lifetimes | `auth-service/constants/token.go` (`TokenExpiryTimeOTP`, `TokenExpiryTimeCode`) |\n| Limits | `auth-service/cmd/api/handlers/rate_limit.go` (`rateLimits`) |\n| The form token | `auth-service/cmd/api/handlers/form_csrf.go` |\n| A deleted account offered its restore | `auth-service/cmd/api/handlers/oauth_restore.go` |\n| Resetting a forgotten password | `auth-service/cmd/api/handlers/password_reset_code.go`, `password_reset_code_test.go` |\n| Changing an email address | `auth-service/cmd/api/handlers/email_change.go`, `email_change_test.go` |"}]},{"source":"services","slug":"permissions","title":"Permissions","group":"People","url":"https://www.anahita.io/docs/services/permissions/","sections":[{"id":"","heading":null,"level":1,"markdown":"Who can post, reply, like and add followers on a profile, and who can edit or\ndelete a reply. It covers the defaults an installation ships with, how to\nchange them, and what profile owners can change for themselves.\n\nWhat each route answers when the viewer may not is in the [API reference](https://www.anahita.io/api/)."},{"id":"two-separate-settings","heading":"Two separate settings","level":2,"markdown":"Every profile, whether a person or a group, has two settings that are easy to\nconfuse:\n\n| Setting | Question it answers | Where it is set |\n| --- | --- | --- |\n| **Access** | Who can *see* this profile and its posts | Settings › Privacy |\n| **Permissions** | Who can *add* to it: posts, replies, likes, followers | Settings › Permissions |\n\nPermissions never widen access. Anybody refused by the profile's access is\nrefused everything below, whatever the permissions say."},{"id":"access-levels","heading":"Access levels","level":2,"markdown":"A permission is one of these levels. Each one is measured against the profile\nthe post is on, not against the person who wrote the post.\n\n| Level | Admits | Shown as |\n| --- | --- | --- |\n| `registered` | Anybody signed in | Anyone signed in |\n| `followers` | People who follow the profile | Followers |\n| `leaders` | People the profile follows (people only) | Leaders |\n| `mutuals` | People who follow the profile and are followed back (people only) | Mutuals |\n| `admins` | The profile's administrators. For a person, that is the person | Admins / Only me |\n\nA profile's administrators meet every level. A group follows nobody, so\n`leaders` and `mutuals` are only offered for people."},{"id":"a-members-only-installation","heading":"A members-only installation","level":2,"markdown":"Everything on this page assumes somebody who is not signed in can read what is\n`public`. An installation can switch that off with `SITE_READ_ACCESS=registered`:\nthen they can read nothing, whatever each profile and post says, and `public`\nmeans the same as `registered`. With `SITE_READ_ACCESS=preview` they can read\nonly the start of what is public. See\n[Privacy](https://www.anahita.io/docs/services/privacy/#what-the-installation-allows), which also gathers the\naudiences of posts and profiles in one place."},{"id":"who-is-always-allowed","heading":"Who is always allowed","level":2,"markdown":"These people are allowed whatever a profile has set:\n\n- **Site administrators and super administrators**\n- **The profile's owner**: the person, or for a group its administrators\n\nThe one exception is editing somebody else's reply, which nobody but its\nauthor can do (see [Replies](#replies))."},{"id":"seeing-posts","heading":"Seeing posts","level":2,"markdown":"A post has an access level of its own, as well as the one on the profile it is\non. Somebody sees a post when both admit them:\n\n1. the **profile's** access admits them, and\n2. the **post's** access admits them.\n\nBoth are measured against the profile the post is on. A followers-only post in\na group is for the group's followers, whoever wrote it.\n\nTwo people see a post without its own access being consulted:\n\n- **The person whose profile it is on** sees everything on it.\n- **The author** sees their own post for as long as they can see the profile\n  it is on.\n\nNobody sees a post by, or on the profile of, somebody they have blocked or\nbeen blocked by.\n\nA block also takes out of sight, for **every** reader, what the blocked\nperson has left on what is the blocker's:\n\n- their replies in a thread whose post the blocker owns or wrote, and what is\n  under those replies; the thread's reply count leaves them out;\n- their likes of the blocker's posts, in the list of who liked it and in the\n  count;\n- their posts on the blocker's profile.\n\nOnly there. Under somebody else's post, what the blocked person wrote stays\nfor other readers, and hiding it is for whoever that post belongs to. Nothing\nis deleted: it is still theirs, and comes back if the block is lifted. A block\nof somebody on another server is also told to that server (see\n[https://www.anahita.io/docs/services/federation/](https://www.anahita.io/docs/services/federation/)).\n\nA person's block does not reach the groups and events they administer. A group\nor an event blocks somebody in its own right, done by one of its\nadministrators from the menu on what that person wrote in it, and lifted from\nits settings (Privacy, Blocked). Its block hides what the blocked person left\nin it, for every reader, as a person's does, and keeps them out of it. It is\ntold to no other server: groups are not on the fediverse.\n\n| Request | Allowed to |\n| --- | --- |\n| `POST` and `DELETE /socialgraph/<person>/blocks/<group>` | The group's administrators, and site administrators |\n| `GET /socialgraph/<group>/blocks/` | The same |\n\nPosts and replies in a group carry `authorized.block_from_owner`, true for the\ngroup's administrators and for site administrators on what somebody else wrote\nthere (`permissions.CanBlockFromOwner`). In feeds and lists it is true for the\ngroup's administrators only; a site administrator blocks from the post's own\npage.\n\nA post's access is always one of the levels above. There is no way to share a\npost with named people only, and no private messaging: the private notes of\nthe PHP version were removed, not carried over.\n\nThe same rule applies on a profile, in the home feed, and under a hashtag or a\nlocation. Search applies both access checks but not the two exceptions above,\nso searching may not find your own restricted posts.\n\nWhat depends on another post is judged by that post:\n\n| Item | Seen by |\n| --- | --- |\n| A reply | Whoever can see the post at the top of its thread |\n| A repost | Whoever can see the original post, and the reposter's profile |\n\nSo following somebody who reposts a followers-only post does not show it to\nyou. You see it only if you could see the original."},{"id":"choosing-who-can-see-a-post","heading":"Choosing who can see a post","level":3,"markdown":"The author chooses a post's audience when writing it, and whoever may edit the\npost can change it afterwards. It is sent as `access` when the post is created\n(`POST /notes/:owner_id/` and the same for articles, topics and photos; for a\nvideo, when its upload begins, `POST /videos/:owner_id/uploads`) and changed\nwith `PATCH /…/:id/access`. Left out, a new post is `public`.\n\nWhich audiences a post may have depends on where it is:\n\n| Where the post is | Audiences |\n| --- | --- |\n| Your own profile | `public`, `registered`, `followers`, `leaders`, `mutuals`, `myself` |\n| Somebody else's profile | `public`, `registered`, `followers`, `leaders`, `mutuals` |\n| A group, as one of its followers | `public`, `registered`, `followers` |\n| A group, as one of its administrators, or as a site administrator | `public`, `registered`, `followers`, `admins` |\n\nAnything else is refused with 422 `access_not_allowed`, and the answer lists\nthe audiences that are allowed. Two of the gaps are deliberate:\n\n- **No \"only me\" on somebody else's profile.** It would be a post only its\n  author and that person can see, which is a private message. This platform\n  does not have those.\n- **No \"administrators only\" from a follower who is not one.** That would be the\n  same thing, addressed to a group's administrators.\n\nAs with every level, the audience is measured against the profile the post is\non: `followers` are that profile's followers, not the author's. A post is\nnever seen more widely than its profile, so on a followers-only group a\n`public` post is still only seen by its followers. The web app shows those wider\nchoices disabled, and does not offer `leaders` when writing a post.\n\nAn installation can take `public` out of all four rows, for everybody, with\n`MIN_CONTENT_ACCESS=registered`; see\n[Privacy](https://www.anahita.io/docs/services/privacy/#new-content-min_content_access).\nA post that is already public may keep that audience.\n\nEvery person and group in a response carries `authorized.audiences`: the\naudiences a post the viewer writes there may have, from this same rule. The\nweb app's composer offers those and nothing else.\n\nA post carries `authorized.audiences` too, for a viewer who may edit it: the\naudiences it may be changed to. They can differ from the profile's list,\nbecause \"only me\" depends on who wrote the post, not on who is changing it."},{"id":"posting","heading":"Posting","level":2,"markdown":"Who can create a note, article, topic, photo or video on somebody's profile.\n\n- The owner and the profile's administrators always can.\n- Everybody else has to be able to see the profile and meet the level set for\n  that kind of post.\n- `registered` is not accepted for posts. Nobody can open their profile to\n  posts from anybody who happens to be signed in.\n\nDefault: `followers`."},{"id":"replies","heading":"Replies","level":2,"markdown":"A reply is a post that answers a post or another reply. Most are notes; a\nreply with pictures is a photo post, and one with a GIF or a video a video\npost (since 2026-10-14: \"nodes are nodes\"). It can be liked, reposted, tagged\nand searched like any post of its kind, and replies can be answered in turn,\nto any depth. Whatever its kind, every rule below applies to it the same way:\ntext-service, photo-service and video-service each judge a post that is a\nreply by its thread, with the thread read by one shared loader\n(`anahita-libs/handlers/threads.go`), and each refuses a change to a\nreply's audience, reply rule or quote rule with `422\nreply_follows_its_thread`.\n\nEvery kind of post is answered with replies: notes, photos, articles and\ntopics. What used to be comments were turned into replies on 2026-10-05, each\na reply made directly to the post it was under, and the service that served\nthem is gone.\n\n**Everything about a reply is decided by the post at the top of its thread,**\ncalled the root here. A reply is owned by whoever wrote it and has no audience\nof its own, so none of the rules for an ordinary post apply to it. Its owner\nand its access are not consulted for anything."},{"id":"seeing-a-reply","heading":"Seeing a reply","level":3,"markdown":"A reply is seen by exactly the people who can see the root. That holds in\nevery place a note can turn up: the thread, a profile's list, a feed, search,\nand the page of a hashtag or a location.\n\nSo a reply to a post in a private group is seen only by the group's\nfollowers, though the person who wrote it has a public profile. And when a\npost is later made private, everything said under it goes with it.\n\nReplies are left out of feeds and out of a profile's list of notes. They are\nread in their thread."},{"id":"replying","heading":"Replying","level":3,"markdown":"Everybody who is not always allowed has to be able to **see the root** first.\nAfter that, it depends on the access of the profile the root is on:\n\n| Profile access | Who can reply |\n| --- | --- |\n| `public` or `registered` | The profile's setting for replies: `registered` or `followers` |\n| `followers` | Followers only, whatever the setting says |\n| `leaders`, `mutuals`, `admins`, `myself` | Anybody who can see the profile |\n\nThe setting only matters on a public or registered profile, because a narrower\nprofile already limits who can see its posts. A followers-only profile is\nspelled out as followers only, even though people the profile follows can also\nread it. Default: `registered`.\n\nThe setting is stored under the name `comment`, from when replies were\ncomments, so what profiles had saved still applies. The scope a token needs is\n`media:write`: a reply is a post.\n\nThen the post's own setting, which its author chooses (below).\n\nA reply cannot be given an audience, and neither its switch for comments nor\nwho may reply to it can be changed. All three follow the root."},{"id":"who-the-author-lets-reply","heading":"Who the author lets reply","level":3,"markdown":"A post carries who may reply to it, in `nodes.reply_access`, returned as\n`reply_access`:\n\n| Value | Who may reply |\n| --- | --- |\n| `anyone` | Whoever the rule above lets. What a post has when its author has not said. |\n| `nobody` | Nobody. |\n| `followers` | People who follow the post's author. |\n| `following` | People the post's author follows. |\n| `mentioned` | People the post mentions by `@name`. A name with a host after it, `@name@example.net`, is somebody on another server and nobody here, whoever here shares the name. |\n\nThe last three can be combined, joined by commas (`followers,mentioned`), and\nsomebody in any one of them may reply.\n\n- **It narrows and never widens.** The profile's rule is applied first. In a\n  group that lets only its followers comment, being mentioned in a post does\n  not let somebody outside the group reply.\n- **Measured against the author, not the profile.** They are the same for a\n  post on its author's own profile. For a post in a group, `followers` are\n  the people who follow whoever wrote it, not the group's followers.\n- **The people who look after the thread (below) reply whatever is set.**\n  Closing a post is how they stop other people, not themselves.\n- **Set when the post is made** (`reply_access` beside `access`) **or\n  afterwards** with `PATCH /{articles,notes,topics,photos}/:id/reply-access`,\n  by whoever may edit the post. A value that is not one is refused with 400\n  `reply_access_invalid`; a reply answers 422 `reply_follows_its_thread`.\n- **The older switch**, `comment_status`, is kept in step: off is `nobody`,\n  and turning it on makes `nobody` `anyone`. A post closed before this\n  setting existed has no value stored and still reads as `nobody`.\n- **A post from another server** (the fediverse or Bluesky) can be replied\n  to here by anybody who can see it, and its `reply_access` reads `anyone`.\n  Who its author lets reply is their server's to hold to; nothing of it is\n  kept here.\n\nThe rule is `permissions.CanReply`; the values are in\n`anahita-libs/constants/reply_access.go`. The thread says the answer for the\nviewer (`can_reply`) and the setting (`reply_access`), so the app can say why\nnot.\n\nNot built: letting in a list of people (there are no lists). Who may quote a\npost is a setting of its own; see [Quotes](#quotes)."},{"id":"who-looks-after-a-thread","heading":"Who looks after a thread","level":3,"markdown":"Three kinds of people can deal with what is said under a post:\n\n- **the author of the root**, wherever it is posted;\n- **the profile the root is on**: the person, or a group's or a website's\n  administrators;\n- **site administrators.**\n\nThe first is there for somebody who posts in a group they do not administer.\nThe replies are under their post, and they can act on them without being one\nof the group's administrators."},{"id":"editing-deleting-and-hiding-a-reply","heading":"Editing, deleting and hiding a reply","level":3,"markdown":"| | Who |\n| --- | --- |\n| **Edit** | Its author only, and only while they can still see the root. Nobody who looks after the thread: an edited reply still carries its author's name. A reply that is a website's, by whoever replies as it |\n| **Delete** | Its author, and whoever looks after the thread. The author can delete it even after losing sight of the root |\n| **Hide**, and show again | Whoever looks after the thread. Not its author, who can delete it |\n\n**Deleting** removes a reply for good, and is asked of the service of its\nkind (`DELETE /notes/:id`, `/photos/:id`, `/videos/:id`), so that a photo or\nvideo reply's files go with it. Its words are changed at `PATCH /notes/:id`\nwhatever its kind. If it has been answered, an empty\nplaceholder is left where it was, with no text and no name, so the replies\nunder it still hang from something. Deleting a post removes its whole thread.\n\n**Hiding** takes a reply out of the thread for everybody, with everything\nsaid under it. It is not destroyed and its author is not told. Whoever looks\nafter the thread still sees it, marked as hidden, and can show it again. A\nhidden reply is not counted in the number of replies a post shows.\n\nHiding exists as well as deleting because, once this installation federates, a\nreply that came from another server can be hidden here and cannot be deleted\nthere."},{"id":"replying-as-a-website","heading":"Replying as a website","level":3,"markdown":"Under a website's own post, a reply in words by one of its administrators is\nthe website's, its owner and author, so the site answers its readers as\nitself; which administrator wrote it is kept for those who handle reports\n(`written_by`). Anywhere else a reply is its writer's. Who may connect a\nwebsite is `WEBSITES_FROM`, administrators unless set. See [Websites](https://www.anahita.io/docs/services/websites/)."},{"id":"liking-a-reply","heading":"Liking a reply","level":3,"markdown":"Under the rule for liking the root: see it, and meet the like permission of\nthe profile the root is on."},{"id":"likes","heading":"Likes","level":2,"markdown":"Who can like a post or a reply on a profile. The viewer has to be able to\nread the post, then meet the level set for likes.\n\nDefault: `followers`."},{"id":"following","heading":"Following","level":2,"markdown":"The rules are in `anahita-libs/permissions/follow.go`, and\nsocialgraph-service asks them.\n\n| Action | Who |\n| --- | --- |\n| Follow a profile | Yourself, a profile you can see, and that has not blocked you |\n| Unfollow | Always yours to do, whether or not you can still see the profile |\n| Remove a follower | Whoever looks after the profile: the person, a group's administrators, a site administrator |\n| Ask to follow | Yourself, of a profile that lets people ask and has not blocked you, when you do not follow it already |\n| Accept a request | Whoever looks after the profile that was asked |\n| Ignore a request, or take it back | Whoever looks after the profile, or whoever asked |\n\nNobody follows, or asks to follow, on somebody else's behalf. The one\nexception is the group setting below.\n\n**A group or an event with rules** (roadmap row 102) is followed, asked to\nfollow, answered or joined by an invitation only once its rules are agreed\nto, each ticked, at the version they are at now (`POST /groups|events/:id/rules/agree`).\nOtherwise graph-grpc-service refuses, and the route answers 409\n`rules_not_agreed`. Its administrators, who write the rules, are not asked;\nnor is somebody an administrator adds as a follower, nor leaving.\n\n**A new post on it** is held to the same: after its rules change, a follower\nagrees to them again before their next post there (409 `rules_not_agreed`\nfrom text-service and photo-service). A reply is its writer's, and is not.\nA video post is refused when its upload is made into a post, so the web app\nasks before it is sent.\n\nAsking does not need you to be able to see the profile. A profile you can see,\nyou can follow outright; the ones worth asking are the ones you cannot.\n\"Others can request to follow\" (`allow_follow_request`) is the profile saying\nit may be asked, and it can be turned off again.\n\nA request is an edge of its own, `edge.actor.follow-request.v1`, and is not\ncounted among followers. Accepting removes it and makes the follow. Following\ndirectly, where that is allowed, removes a request that was waiting.\n\nWhoever looks after the profile is told of a request (`actor_follow_request`),\nand that notification goes when the request is answered or taken back.\nWhoever asked is told when it is accepted (`actor_follow_request_accept`)."},{"id":"what-a-stranger-is-shown-of-a-private-profile","heading":"What a stranger is shown of a private profile","level":3,"markdown":"A profile somebody may not see is refused whole (403), with one exception. A\nprofile that lets people ask to follow it, or that the viewer has been invited\nto follow, shows **its name, alias, picture\nand verified mark, and nothing else** to somebody signed in whom it has not\nblocked. Without that there would be nothing to ask from.\n\n- **Opening the profile** answers 200 with that much, marked\n  `restricted: true`, and `is_follow_requested_by_viewer`.\n- **The lists of people and of groups** include such profiles for somebody\n  signed in, each marked the same way. Not the featured list, and not any\n  other listing: search, followers, mentions and feeds are unchanged.\n- **Visitors** are shown nothing, as before. So is anybody when the profile\n  is disabled, archived, on its way out, or does not let people ask.\n\nThe rule is `permissions.CanSeeLimitedProfile`. In a list the cutting down is\ndone inside the graph (`restrictUnseen`), before the actor leaves it, and the\nresponse is a `LimitedActor`, a written-out list of fields. It is not the\nordinary item with fields blanked."},{"id":"invitations","heading":"Invitations","level":2,"markdown":"Nobody is made a follower of a group without saying yes. They are invited,\ntold, and choose. The rules are in\n`anahita-libs/permissions/invite.go`, and what the data allows of\neach person is in the graph (`data/repositories/invites.go`).\n\n**Who can invite** is the group's setting, `invite` under socialgraph-service:\n`admins` or `followers`. Default, and what a group that never set it gets:\n`admins`. Site administrators always can. Only to a group the inviter can see\nthat is in use. Nobody is invited to follow a person.\n\n**Who can be invited:** a person who follows the inviter. The answer for\neach person named is one of:\n\n| Answer | Meaning |\n| --- | --- |\n| `invited` | An invitation was made |\n| `already_follower` | They follow the group |\n| `not_follower` | They do not follow the inviter |\n| `blocked` | Somebody among the person, the inviter and the group has blocked another |\n| `pending` | They are invited already and have not answered |\n| `cooldown` | They declined within the last 90 days |\n| `requested` | They have asked to follow and are waiting; accept that instead |\n| `unavailable` | Not a person, not there, or the inviter themselves |\n| `full` | 500 invitations to the group are waiting |\n\nLimits: 50 people in one request (400), and 200 invitations made in the last\nday that are still waiting (429).\n\n**An invitation** waits 30 days, then lapses. While it waits, the person\ninvited:\n\n- is shown the group's name and picture even where they could not see it\n  (see *What a stranger is shown of a private profile*);\n- becomes a follower on accepting, whatever the group's access says. The\n  invitation is the group's consent.\n\nDeclining keeps the invitation, marked refused, for 90 days, which is what\nstops the next one. Whoever invited is not told of a refusal.\n\n| Action | Who |\n| --- | --- |\n| Accept or decline | The person invited, and nobody for them |\n| See who is still to answer | Whoever looks after the group |\n| Take an invitation back | Whoever made it, or whoever looks after the group |\n\n**Adding a follower without asking** is for site administrators alone\n(`CanAddOthersAsFollowers`), for moderation and for moving data. It was the\ngroup's `follower` setting, which is still stored and is no longer read."},{"id":"events","heading":"Events","level":2,"markdown":"An event is an actor like a group, with a time and a place: followed, posted\non, looked after by administrators. Everything said of a group here is true\nof an event: its access, its permissions, who can invite to it. What only an\nevent has is below. The rules are in\n`anahita-libs/permissions/event.go`.\n\nGoing to an event is following it. The answer, **going** or **maybe**, is on\nthe follow. Following an event the ordinary way, with nothing said, counts as\nmaybe.\n\n| Action | Who |\n| --- | --- |\n| Make an event of your own | Anybody signed in, unless the installation sets `EVENTS_FROM` higher. They look after it, and are going |\n| Make an event for a group | That group's administrators, and nobody else, whatever `EVENTS_FROM` says. The group's administrators become the event's |\n| Answer going or maybe | Somebody who can see the event and whom it has not blocked, or somebody invited to it |\n| Take an answer back | Always yours to do |\n| See where to join online, and the address it is held at | Whoever is going, and whoever looks after the event. Not maybe, not invited |\n| Change it, call it off | Whoever looks after it |\n\n- **Places.** An event can limit how many are going. Going is refused when\n  every place is taken (409 `event_full`); maybe is not counted. The check and\n  the answer are made under a lock on the event, so the last place is given\n  once however many ask at the same moment.\n- **Over or called off.** No answers are taken (410 `event_closed`), and\n  nobody can be invited. Leaving still works. An event that is called off is\n  kept, saying so.\n- **Invitations.** Accepting one is going, or maybe when every place is\n  taken. An event with a limit on places may have twice that many invitations\n  waiting, and never fewer than 50; with no limit, 500 as for a group.\n- **For the followers of the group hosting it.** An event hosted by a group\n  can be opened to everybody who follows that group\n  (`events.open_to_host_followers`). They are then treated as invited, all at\n  once: shown the event's name, time and description where they could not\n  otherwise see it, in the group's list of events and on the event's own\n  page, and able to answer going or maybe. It is not an access level. What is\n  posted on the event stays for the people who answered, and a follower of\n  the group sees that only once they have. The relation says so as\n  `via_host`.\n- **Where it is held** is an address kept on the event's own node\n  (`geo_address` and the fields beside it), not a place on the site's map.\n  Places are public and shared, and an event at somebody's home should not\n  put their home among them. Nothing searches for an event by it. A public\n  venue can be tagged on the event like a place on any post.\n- **Its place on a map** is kept only when whoever saves the event asks for\n  a map (`show_map`), since finding it sends the address to a geocoder. The\n  point is sent with the address and to nobody who is not sent the address.\n- **Its address on the site** is set when it is made and does not change when it is\n  renamed, since invitations and calendars link to it."},{"id":"reposts","heading":"Reposts","level":2,"markdown":"Reposts have no setting under Permissions and cannot be restricted per\nprofile.\n\nA repost is shown only to people who can see the original post. See\n[Seeing posts](#seeing-posts).\n\nOnly people repost, as only people reply. What a person reposts is in the\nlist of reposts on their profile, and not among its posts. They can have\nreposts among the posts as well (\"Also show reposts in the Posts tab\", under\nSettings › Privacy). It changes that one list and nothing else: the list of\nreposts always has them, and followers are sent them in their own feeds, which\nis what a repost is for. It does not change who can see a repost."},{"id":"mentions","heading":"Mentions","level":2,"markdown":"A post mentions a profile when it names it: by its `@handle`, or by its\naddress on a line of its own, which shows its card. People, groups and\nevents can be mentioned, here or on another server.\n\n- **actor-service keeps the mentions,** as `edge.tag.mention.v1` from the\n  profile to the post, each time a post is added or edited: it adds what is\n  newly named and takes away what is no longer. A person's own description\n  can name people too.\n- **A person chooses who may mention them,** under Settings › Permissions,\n  \"Who can mention you?\": the `mention` permission of `socialgraph-service`,\n  one of `registered` (anybody signed in, the default), `followers`,\n  `leaders` (people they follow), `mutuals` or `admins` (\"Nobody\", but\n  themselves). Somebody they block may not mention them either. A mention\n  that isn't allowed is no edge at all, and nobody is told; the `@handle`\n  stays in the text as written. Groups and events may always be mentioned.\n  The rule is `permissions.CanMention`.\n- **The person is told,** in their notifications, \"mentioned you in a post\"\n  or \"in a reply\" (`actor_mention`, `actor_mention_reply`), from the event\n  `edge.mention.actor-service.added.v1`. Only a person here, never for a\n  draft, never for naming themselves, and only when they may see the post,\n  so being named in a private group's post tells nobody outside it that the\n  post exists. A group or an event mentioned is told nothing. Taken out of\n  the post in an edit, the news of it goes\n  (`edge.mention.actor-service.deleted.v1`).\n- **A mention is not a citation.** A post naming a post cites it; naming a\n  profile mentions it (the user, 2026-10-17)."},{"id":"people-in-pictures-and-videos","heading":"People in pictures and videos","level":2,"markdown":"A tag says who is in a photo or a video: not a mention, which says \"this is\nfor you\", but \"this person is in it\" (roadmap row 103).\n\n| Endpoint | Who | What |\n| --- | --- | --- |\n| `GET /people/tagged-in/:post_id` | whoever may see the post | who is in it, in the order they were tagged |\n| `POST /people/:id/tags/:post_id` | the post's author | tags them: 201 at once, 202 waiting for their approval, 409 `not_taggable` or `too_many` |\n| `PUT /people/:id/tags/:post_id` | the person tagged | approves |\n| `DELETE /people/:id/tags/:post_id` | the person tagged, or the post's author | declines, or takes them out |\n\n- **actor-service keeps the tags,** as `edge.tag.person.v1` from the\n  person to the post, its `meta` `pending` or `approved`. Only a photo or a\n  video, at most 20 people in one.\n- **A person chooses who may tag them,** under Settings › Permissions,\n  \"Who can tag you in photos and videos?\": the `tag` permission of\n  `socialgraph-service`, one of `registered` (\"Anyone signed in\", at once),\n  `approve` (\"Approve each first\", the default) or `admins` (\"Nobody\", but\n  themselves). Somebody\n  they block may not tag them. The rule is `permissions.TagDecision`.\n- **Only a person here may be tagged.** Somebody on another server cannot\n  approve a tag here, and approval comes first.\n- **A tag waiting is seen by the two of them only,** the person and the\n  post's author; nothing else reads it as a tag.\n- **Where tags show:** the person's **Tagged** list (`GET\n  /feeds/actor/:id/?filter=tagged`), search (a photo or a video is found by\n  the name or handle of somebody in it, and lifts what is pictured with\n  whom the searcher follows), and the fediverse, as a `Mention`\n  ([Federation](https://www.anahita.io/docs/services/federation/)), and Who to follow: \"In photos with you\"\n  and \"In photos with people you follow\" ([Recommendations](https://www.anahita.io/docs/services/recommendations/#why-somebody-is-suggested)),\n  from public posts only, never naming who or which photo. Recommendations\n  read no mentions.\n- **Events:** `edge.tag.actor-service.requested.v1` for a tag waiting,\n  `added.v1` for one made or approved, `deleted.v1` for one taken off; the\n  subject is the post's author, the object the post, the target the\n  person."},{"id":"quotes","heading":"Quotes","level":2,"markdown":"A quote is a note that carries another post under its own words. It sits\nbetween a repost, which has no words of the reposter's, and a reply, which\nstays in the other person's thread. The note is an ordinary note: its own\naudience, likes and replies, on its author's profile.\n\nQuoting puts somebody's post in front of another audience under somebody\nelse's words, so **the person quoted keeps the say**."},{"id":"who-can-quote-a-post","heading":"Who can quote a post","level":3,"markdown":"Somebody signed in, who can see the post, when:\n\n- **it is a post at the top level and still there.** Not a reply, which\n  belongs to its thread, and not a repost;\n- **it is public**, on a profile that is public or for members. A note quoting\n  a post with a narrower audience would be one most of its readers could not\n  see the point of;\n- **its author has not blocked them**;\n- **the post's quote policy lets them**:\n\n| Policy | Who can quote |\n| --- | --- |\n| `anyone` | Anybody who can see the post |\n| `followers` | People who follow the post's author |\n| `nobody` | Nobody but its author |\n\nA post has its own policy when its author sets one, in the post's interaction\nsettings, beside who can reply. A post that has none follows its author's own\nsetting (\"Who can quote my posts\", under Settings › Permissions), and a person who\nhas never chosen lets anyone. Both are kept in `nodes.quote_policy`: on a post\nit is that post's, on a person it is their default.\n\nSomebody can always quote their own post, whatever its policy. Only people\nquote, and only a note can quote.\n\nA refusal answers 403 `quote_not_allowed` with a `reason`: `policy`,\n`not_public`, `blocked` or `unavailable`. A post the viewer cannot see answers\n`unavailable`, the same as one that is not there."},{"id":"what-a-note-shows-of-the-post-it-quotes","heading":"What a note shows of the post it quotes","level":3,"markdown":"**A quote never shows anybody a post they could not otherwise see.** The\nquoted post is looked up for each reader, as that reader, every time the note\nis sent. The note carries `quote`:\n\n- the post, with its author, the profile it is on and its date, when the\n  reader may see it;\n- `{\"state\": \"unavailable\"}` when they may not, or it has been removed. So a\n  post later made private, or whose author blocks the reader, disappears from\n  the quote for that reader;\n- `{\"state\": \"detached\"}` when its author took it out (below).\n\nOne level only: a quoted note that itself quotes something is shown without\nit."},{"id":"taking-a-post-out-of-a-quote","heading":"Taking a post out of a quote","level":3,"markdown":"The author of a quoted post can remove it from any note that quotes it\n(`DELETE /notes/<the quoting note>/quote`), and so can a site administrator.\nThe note stays, saying \"Post removed by its author\". It cannot be undone, and\nthe note cannot be made to quote the post again.\n\nChanging a post's policy to `nobody` stops new quotes. Notes that already\nquote it stay until the author takes the post out of each."},{"id":"counting-and-telling","heading":"Counting and telling","level":3,"markdown":"A post counts the notes that still quote it (`quote_count`): not one that was\nremoved, and not one it was taken out of. Its author is told when it is\nquoted (`quote_add`), unless they quoted it themselves.\n\nThe rules are `permissions.CanQuote` and `permissions.CanDetachQuote`; the\nlookup for each reader is `handlers.SetQuotes`, which asks the access gate\n(`FilterVisible`) before it reads anything."},{"id":"saved-posts","heading":"Saved posts","level":2,"markdown":"Anybody signed in can save a post they can see, to find it again, and take\nit out of their list whenever they like. What somebody saved is theirs alone:\nnobody else can see the list, and nobody, the author of a post included, can\nfind out who saved it or whether anybody did. A saved post stays in the list\nonly while its saver can still see it."},{"id":"pinned-posts","heading":"Pinned posts","level":2,"markdown":"A post can be pinned to the profile it is on. It then comes first in that\nprofile's posts, and first in the profile's list of its kind (notes, photos\nand so on), marked as pinned.\n\n- **A profile has one pin.** Pinning another post moves the pin to it: the\n  post that had it gives it up. One is also exactly what Bluesky has, so a\n  pin here will be a pin there with nothing left over.\n- **The pin is the profile's, not the post's.** It is set by whoever the\n  profile belongs to: a person on their own profile, and a group's\n  administrators in the group; and site administrators, who may change any\n  post. The author of a post in a group they do not run can edit and remove\n  their post, and cannot pin it.\n- **Offered in feeds to whoever runs the profile.** Feeds and lists offer Pin\n  to the profile and its administrators only; a site administrator pins from\n  the post's own page (`permissions.RunsProfile`).\n- **Only a post at the top level.** A reply is read in its thread.\n- **It changes the order and nothing else.** A pinned post is seen by exactly\n  the people who could see it anyway.\n\n`PUT` and `DELETE /{articles,notes,topics,photos}/<id>/pin`. The rule is\n`permissions.CanPin`, and a post says whether the viewer may in\n`authorized.pin`."},{"id":"downloading-photos-and-videos","heading":"Downloading photos and videos","level":2,"markdown":"Whether what a post holds may be downloaded is its author's to say, on\neach post, and is no until they say yes. The rule is\n`permissions.CanDownload`, and `handlers.MayDownload` is what a download\naddress asks.\n\n| Who | May download |\n| --- | --- |\n| Somebody who can see the post, when it allows download | Yes |\n| The post's author | Always, whatever the post says |\n| Somebody who can see the post, when it does not | No (`403`) |\n| Somebody who cannot see the post | No, and told there is no such post (`404`) |\n| A visitor on a site in `preview` mode | No |\n\nAllowing download never widens who can see a post: it is asked only of\nsomebody who already can.\n\nWhoever may edit the post may change the switch: its author, and the\nadmins of the group it is on. A profile's default for new posts is changed\nby whoever may edit the profile. See [Photo posts](https://www.anahita.io/docs/services/photos/#downloading)."},{"id":"custom-feeds","heading":"Custom feeds","level":2,"markdown":"A custom feed is its maker's, and has an audience of its own. See\n[https://www.anahita.io/docs/services/feeds/](https://www.anahita.io/docs/services/feeds/) for what a feed is made of.\n\n- **Who may see a feed** is its `access`: `myself` unless its owner says\n  otherwise, or `followers`, `registered` or `public`. It is judged as a post\n  is, by its own access and its owner's, and a block either way hides it. One\n  that may not be seen is answered as if it were not there.\n- **What a reader sees in a feed** is decided for the reader, and never for\n  the feed's owner. Whatever a feed selects goes through the gate every feed\n  uses. A public feed built on a private group shows that group's posts to\n  its followers alone. A feed never widens what anybody may see.\n- **Whoever may see a feed** may read it, copy it, and keep it on their home\n  page. Its owner, and site administrators, may change and delete it.\n- **Making and changing one** needs somebody signed in, with the\n  `media:write` scope. A person owns 10 at most.\n- **No new public feed** on an installation that takes no new public content\n  (`MIN_CONTENT_ACCESS`). One that was public already may stay so."},{"id":"suggestions","heading":"Suggestions","level":2,"markdown":"Suggestions of who to follow are made to somebody signed in, about that\nperson, and to nobody else. See [https://www.anahita.io/docs/services/recommendations/](https://www.anahita.io/docs/services/recommendations/).\n\n- **Nobody is suggested whom the viewer may not see.** What is found is put\n  to the graph (`FilterVisible`) before any of it is sent, and so is what a\n  reason names.\n- **Nobody is suggested who asked not to be** (`discoverable`), nor anybody\n  on either side of a block with the viewer, nor an account that is\n  disabled, archived, deleted or unconfirmed.\n- **What somebody chose, and whom they turned down, is theirs alone.** Whoever\n  was turned down is not told."},{"id":"reporting","heading":"Reporting","level":2,"markdown":"Anybody signed in may report anything they can see, except themselves and\nwhat they wrote. Something they cannot see cannot be reported, and answers as\nif it did not exist.\n\nOnly administrators and super administrators may read what has been reported,\nsee who reported it, and decide a case. The person reported is not told. See\n[Abuse reports](https://www.anahita.io/docs/services/abuse-reports/)."},{"id":"account-types","heading":"Account types","level":2,"markdown":"A person's account type is **registered**, **administrator** or **super\nadministrator** (`person_type` in the code and the API). Everybody is registered when they join; the first super\nadministrator is made from `SUPER_ADMIN_EMAIL` (see\n[Registration](https://www.anahita.io/docs/services/registration/)). After that:\n\n| Rule | |\n| --- | --- |\n| Who may change a person's account type | An administrator may make somebody registered an administrator. Everything else, making a super administrator and taking a type away from anybody, is a super administrator's. Always after proving who they are again |\n| Whose | Anybody else's, never their own |\n| Of whom | A person here: not a group, not a profile from another server, not a deleted person |\n| Recorded | In `node_deletion_logs`, as `made-registered`, `made-admin` or `made-super-admin`, with who did it |\n| When it takes effect | At the person's next token, within fifteen minutes |\n\nWhat sets a super administrator apart is the installation's configuration and\nits system tasks, which is why an administrator may add administrators but not\nmake a super administrator or remove anybody's type (the user, 2026-10-18).\n\nNobody changing their own account type is what keeps a super administrator: the last\none can only be demoted by another, and there is no other. One who wants to\nstep down makes somebody else a super administrator first, who can then\nchange theirs. It is done from the **Account type** card in the\nAdministration section of a person's settings in the web app, or with\n`PATCH /accounts/:id/person-type`. The rule is\n`anahita-libs/constants/person_types.go` (`MayChangePersonType`)."},{"id":"removing-accounts","heading":"Removing accounts","level":2,"markdown":"Disabling, archiving, deleting and purging a person or a group each have their\nown rule, set in code and not changeable per profile:\n\n| Action | Who may |\n| --- | --- |\n| Disable or enable | Site administrators and super administrators |\n| Archive | The person themselves, a group's administrators, or a super administrator |\n| Delete, with 30 days to restore | The person themselves, a group's administrators, or a site administrator |\n| Purge, with no way back | Super administrators only |\n| Remove as spam, at once and with no way back | Site administrators and super administrators; never an administrator's or a super administrator's account, nor their own: see [Removing as spam](https://www.anahita.io/docs/services/account-lifecycle/#removing-as-spam) |\n| See the list of accounts and their activity | Super administrators only |\n| Appoint an administrator to any group | Site administrators and super administrators |\n| Change a person's account type | Administrators make administrators of registered people; the rest is super administrators'; never their own: see [Account types](#account-types) |\n\nWhat each one does is in [Account lifecycle](https://www.anahita.io/docs/services/account-lifecycle/)."},{"id":"the-defaults","heading":"The defaults","level":2,"markdown":"The defaults live in `graph-grpc-defaults/actor_features/`:\n\n| File | Applies to |\n| --- | --- |\n| `default.json` | Every profile, as the base |\n| `person.json` | People, layered over `default.json` |\n| `group.json` | Groups, layered over `default.json` |\n| `event.json` | Events, layered over `default.json` |\n| `website.json` | Websites, layered over `default.json` |\n\nEach service lists its `add_permissions` as an entity and a level:\n\n```json\n{\n    \"service\": \"text-service\",\n    \"add_permissions\": [\n        { \"entity\": \"note\", \"access\": \"followers\" },\n        { \"entity\": \"article\", \"access\": \"followers\" }\n    ],\n    \"include_common_permissions\": [\"content_creator\"]\n}\n```\n\nReplies and likes are shared by every service that has posts, so they are set\nonce in `default.json` under `common_permissions` and pulled in with\n`include_common_permissions`:\n\n```json\n\"common_permissions\": {\n    \"content_creator\": [\n        { \"entity\": \"comment\", \"access\": \"registered\" },\n        { \"entity\": \"like\", \"access\": \"followers\" }\n    ]\n}\n```"},{"id":"changing-them","heading":"Changing them","level":3,"markdown":"Edit the files and the change takes effect on the next request. graph-grpc\nreads the directory named by `ACTOR_FEATURES_PATH` every time it reads a\nprofile, so no restart is needed.\n\n`docker-compose.yml` mounts `graph-grpc-defaults/actor_features` at\n`/defaults/actor_features`, which is where `ACTOR_FEATURES_PATH` points by\ndefault. The graph-grpc image also carries a copy made at build time, at\n`/app/defaults/actor_features`. An installation that runs the image without\nthe mount has to set `ACTOR_FEATURES_PATH` to that path, and rebuild the image\nto pick up a change.\n\nOnly use the values listed under [Accepted values](#accepted-values). A value\nthe permission checks do not recognise refuses everybody except the owner, the\nprofile's administrators and site administrators, and nothing reports it."},{"id":"what-profile-owners-can-change","heading":"What profile owners can change","level":2,"markdown":"A profile's owner and its administrators can change the levels for their own\nprofile under **Settings › Permissions**. Site administrators can too, for any\nprofile.\n\n- A profile that has never saved this tab follows the defaults, including any\n  later change to them.\n- Saving stores the profile's **whole** feature list, not only the values that\n  changed. From then on, later changes to the defaults do not reach that\n  profile.\n- **Restore defaults** clears the profile's saved settings, and the defaults\n  apply again.\n\nOn a profile whose access is narrower than `registered`, the row for replies\nexplains who can reply instead of offering a choice. See\n[Replying](#replying)."},{"id":"accepted-values","heading":"Accepted values","level":2,"markdown":"The server refuses to save anything else. These are the values the permission\nchecks enforce.\n\n| Entity | People | Groups |\n| --- | --- | --- |\n| Posts (`note`, `article`, `topic`, `photo`) | `followers`, `leaders`, `mutuals`, `admins` | `followers`, `admins` |\n| `comment` | `registered`, `followers` | `registered`, `followers` |\n| `like` | `registered`, `followers`, `leaders`, `mutuals`, `admins` | `registered`, `followers`, `admins` |\n| `follower` | `admins` (not enforced) | `followers`, `admins` |"},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| What each level admits | `anahita-libs/permissions/add_access.go` (`AddAccessAllows`), tested in `add_access_test.go` |\n| Seeing a profile and a post | `anahita-libs/permissions/actor.go` and `medium.go` (`CanRead`); in lists and feeds, `graph-grpc-service/data/repositories/access.go`, `access_actor.go`, `access_medium.go` |\n| A post reached by its id | `anahita-libs/permissions/visibility.go` (`CanReadByID`, `CanReadEach`), `anahita-libs/data/services/graph_node_grpc.go` (`FilterVisible`) |\n| Choosing who can see a post | `anahita-libs/permissions/audience.go` (`MediumAudiences`), tested in `audience_test.go` |\n| Posting and likes | `anahita-libs/permissions/capabilities.go` (`CanPostTo`, `CanLike`) |\n| A group's or an event's block | `anahita-libs/permissions/block_from_owner.go` (`CanBlockFromOwner`) |\n| Replies | `anahita-libs/permissions/reply.go` (`CanReply`, `CanEditReply`, `CanDeleteReply`, `CanHideReply`, `CanLikeReply`); the thread, `anahita-libs/handlers/threads.go` |\n| Who the author lets reply | `anahita-libs/constants/reply_access.go`; table `nodes`, column `reply_access` |\n| Following, and what a stranger is shown | `anahita-libs/permissions/follow.go`, `limited_profile.go` (`CanSeeLimitedProfile`); `graph-grpc-service/cmd/server/handlers/actor_restricted.go` (`restrictUnseen`) |\n| Invitations | `anahita-libs/permissions/invite.go` (`CanInvite`), `graph-grpc-service/data/repositories/invites.go` |\n| Events | `anahita-libs/permissions/event.go` (`CanRsvp`, `CanSeeOnlineURL`); setting `EVENTS_FROM` |\n| Account types | `auth-service/cmd/api/handlers/person_type.go` (`EditPersonType`), `graph-grpc-service/cmd/server/handlers/actor_person_type.go` (`SetPersonType`, which refuses one's own), `graph-grpc-service/data/repositories/nodes_person_type.go` |\n| Adding a location | `location-service/cmd/api/permissions/locations.go` (`CanAdd`); setting `LOCATIONS_FROM` |\n| Mentions | `anahita-libs/permissions/mention.go` (`CanMention`) |\n| Quotes | `anahita-libs/permissions/quote.go` (`CanQuote`, `CanDetachQuote`), `anahita-libs/constants/quote_policy.go`, `anahita-libs/handlers/quotes.go` (`SetQuotes`) |\n| Pinned posts | `anahita-libs/permissions/pin.go` (`CanPin`, `RunsProfile`) |\n| Downloading | `anahita-libs/permissions/download.go` (`CanDownload`), `anahita-libs/handlers/download.go` (`MayDownload`) |\n| The defaults | `graph-grpc-defaults/actor_features/`, read by `graph-grpc-service/data/actor_features/`; setting `ACTOR_FEATURES_PATH` |\n| Accepted values | `anahita-libs/validators/request.go` (`validatePersonAddPermissionAccess`, `validateActorAddPermissionAccess`) |"}]},{"source":"services","slug":"notifications","title":"Notifications","group":"People","url":"https://www.anahita.io/docs/services/notifications/","sections":[{"id":"","heading":null,"level":1,"markdown":"What people are told, in the app, about what others do: who is told what,\nwho never is, how a notification goes when what it tells of goes, and how\nthey are delivered. The plan they follow is\n[https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/notifications.md](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/notifications.md) (roadmap rows 96 and 97)."},{"id":"no-email","heading":"No email","level":2,"markdown":"**Notifications are never emailed.** They are in the app only, under the\nbell. The only emails Anahita sends are auth-service's, each about one thing\nthat happened: codes, password resets and security alerts; invitations;\nsignup requests and answers; a group or an account scheduled for deletion.\n\nA test keeps it so: `anahita-libs/data/services/mail_senders_test.go` fails\nif any service but auth-service and mail-grpc-service uses the mail client."},{"id":"what-a-notification-is","heading":"What a notification is","level":2,"markdown":"A row in `notifications`, one for each person told, written by\nnotification-service when it hears an event on the bus (`anahita_events`).\n\n| Column | Holds |\n| --- | --- |\n| `type` | What it tells of: the kinds below |\n| `recipient_id` | Who is told |\n| `subject_id` | Who did it |\n| `object_id` | What it was done to or with: a post, for most kinds |\n| `target_id` | Where: the profile it belongs to |\n| `comment_id` | What holds what was done, when that is not the object: a reply, or a repost |\n| `read_at` | When the recipient read it: NULL while it is unread |\n\nNothing is copied into a row: the list fills the nodes in when it is read,\nso a notification always shows a post as it is now.\n\n**One row for one thing.** The unique key `one_of_each` (recipient, type,\nsubject, object, target and `comment_id`) keeps a repeated event from\nmaking a second row. Telling somebody the same thing again moves the row to\nthe top of their list, unread: an event moved twice says so once."},{"id":"the-kinds","heading":"The kinds","level":2,"markdown":"| `type` | Told when | Who is told |\n| --- | --- | --- |\n| `note_add`, `article_add`, `photo_add`, `topic_add`, `video_add` | A profile posts | Its subscribers who may see the post |\n| `reply_add` | A reply is made | The author of the post at the top of the thread, the author of what it answers, and the post's subscribers, each when they may see the thread and the reply |\n| `quote_add` | A note quotes a post | The quoted post's author, when they may see the note |\n| `repost_add` | A post is reposted | Its author |\n| `voteup` | A post is liked | Its author |\n| `actor_mention`, `actor_mention_reply` | A post or a reply names a person | The person, when they may see it ([Permissions](https://www.anahita.io/docs/services/permissions/)) |\n| `actor_tag_request` | A person is tagged in a photo or a video, and the tag waits for them | The person, when they may see the post ([Permissions](https://www.anahita.io/docs/services/permissions/#people-in-pictures-and-videos)) |\n| `actor_tag` | A person is tagged at once, as they chose | The person, likewise |\n| `actor_tag_accept` | A person approves being tagged; its subject is them | The post's author |\n| `actor_follow` | A profile is followed | The person, or a group's or an event's administrators |\n| `actor_follow_request`, `actor_follow_request_accept` | Somebody asks to follow, and is let | The person or the administrators; then the one who asked |\n| `actor_invite`, `actor_invite_accept` | Somebody is invited to follow, and accepts | The one invited; then the one who invited |\n| `admin_add` | Somebody becomes an administrator of a profile | Its other administrators |\n| `event_rsvp` | Somebody answers an event | Its administrators |\n| `event_changed`, `event_cancelled`, `event_reminder` | An event moves, is called off, or is near | Each person who answered it |\n\n**Subscribers.** Following a profile, or becoming one of its\nadministrators, subscribes to it; each is a reason to stay subscribed, and\nthe subscription ends with the last of them. Get notifications, in a post's\nmenu, subscribes to that post: its replies are told."},{"id":"only-what-its-reader-may-see","heading":"Only what its reader may see","level":2,"markdown":"Nobody is told of a post they may not see, and nobody is shown one:\n\n1. **When a notification is made,** each recipient is asked about, as a\n   registered member, with `permissions.CanReadEach`: the post, and the\n   reply that holds what was done. A post for a profile's followers is told\n   to its followers, not to everybody subscribed; one for its\n   administrators, to its administrators. Asked as a member, a site\n   administrator is told what they would be as one.\n2. **When the list is read,** the viewer is asked about every post and\n   reply in it at once. A notification about one they may no longer see (it\n   was made private, they left its audience, or it is gone) is left out and\n   deleted, so that the unread count says what the list does. When the\n   question cannot be answered, the list is empty rather than unchecked.\n3. **A block** ends the follows and the subscriptions between the two,\n   both ways, and what each was told of the other goes, with what a group's\n   or an event's administrators were told of the one it blocked.\n\nThe rule is the same one every post is read by, `permissions.CanRead`, asked\nby id through the graph's `FilterVisible`. A test keeps the two in step:\n`graph-grpc-service/data/repositories/visibility_agreement_integration_test.go`."},{"id":"who-is-never-told","heading":"Who is never told","level":2,"markdown":"- **Anybody, of an edit.** Changing a post tells nobody (the user,\n  2026-10-19); a name an edit adds is a mention, and told as one.\n- **Whoever did it**, of their own doing.\n- **A profile on another server,** of a like, a repost, a reply, a quote, a\n  mention or a follow: its own server is told, by federation.\n- **Of a draft.** Nothing is said of a post until it is published.\n- **Of the notes after the first of a thread written in one go**\n  (`POST /notes/…/thread`): whoever is told of the thread is told of its\n  first note, once (`part` in the event; roadmap row 101). A name mentioned\n  in any of its notes is still told, as a mention."},{"id":"when-what-it-tells-of-goes","heading":"When what it tells of goes","level":2,"markdown":"A notification that leads nowhere is noise, so it goes with what it tells\nof, for everybody who was told, whoever's hand removed it:\n\n| What goes | What goes with it |\n| --- | --- |\n| A post or a reply | Every notification naming it as object, `comment_id` or target: its likes, reposts, quotes, mentions, and the replies under it |\n| A repost, taken back | Its `repost_add`, found by the repost's id in `comment_id` |\n| A like, taken back | Its `voteup` |\n| A name, taken out of a post in an edit | Its `actor_mention` (actor-service says so, `edge.mention.*.deleted.v1`) |\n| A tag, taken off by the person or the author | Its `actor_tag_request`, `actor_tag` and `actor_tag_accept` (`edge.tag.*.deleted.v1`) |\n| A follow | Its `actor_follow`, and the subscription unless they administer the profile |\n| An administrator | Their `admin_add`, and the subscription unless they follow the profile |\n| A note from another server, taken back there | Everything about it |\n| An account, purged | Everything to, by or about it (graph-grpc-service's purge) |\n| A block | What the two were told of each other |\n\nTaking one of these back tells nobody."},{"id":"choosing-what-one-is-told-about","heading":"Choosing what one is told about","level":2,"markdown":"Everybody is told about everything to begin with. A person turns kinds of\nthing off, and on again, in the Notifications section of their settings\n(`GET` and `PATCH /notifications/settings`); a notification of a kind turned\noff is not made at all. Each off is a row in `notifications_off`; on is\nnone. A purge takes them with the person.\n\n| Kind of thing (`topic`) | Notifications |\n| --- | --- |\n| `likes` | `voteup` |\n| `reposts` | `repost_add`, `quote_add` |\n| `replies` | `reply_add` |\n| `mentions` | `actor_mention`, `actor_mention_reply`, `actor_tag_request`, `actor_tag`, `actor_tag_accept` |\n| `follows` | `actor_follow`, `actor_follow_request`, `actor_follow_request_accept` |\n| `posts` | `note_add`, `article_add`, `photo_add`, `topic_add`, `video_add` |\n| `events` | `event_rsvp`, `event_changed`, `event_cancelled`, `event_reminder` |\n| `invitations` | `actor_invite`, `actor_invite_accept`, `admin_add` |"},{"id":"several-as-one","heading":"Several as one","level":2,"markdown":"Likes of one post, reposts of one post, and follows of one profile are one\nline of the list: \"Worf and 2 others liked your post\"\n(`notification-service/cmd/api/handlers/group.go`). They are grouped on each\npage of the list, at the place of the newest; each row stays as it was\nstored. The line carries every row's id (`ids`), whoever else did the same,\nnewest first (`others`, at most two), and how many more (`others_count`).\nIt is unread while any of its rows is, and reading or deleting it reads or\ndeletes them all. A line of several follows offers no **+ Follow**."},{"id":"what-can-be-done-from-one","heading":"What can be done from one","level":2,"markdown":"The list says, for each notification, what its reader can still do from it\n(`actions`, worked out as it is read, from the graph as it is now; never\nstored: `notification-service/cmd/api/handlers/actions.go`):\n\n| `actions` | On | When |\n| --- | --- | --- |\n| `answer_request` | `actor_follow_request` | The request is still waiting: accept or decline it |\n| `answer_tag` | `actor_tag_request` | The tag is still waiting: accept (`PUT /people/:id/tags/:post_id`) or decline (`DELETE`) |\n| `follow` | `actor_follow` | The reader does not follow whoever followed |\n| `reply` | `reply_add`, `actor_mention`, `actor_mention_reply`, `quote_add` | Always: answer the reply (`comment_id`), or the post |\n\nAn event's notifications have none: whoever is told has answered the event\nalready, and changes the answer on its page. Using an action reads the\nnotification."},{"id":"read-and-how-long-they-are-kept","heading":"Read, and how long they are kept","level":2,"markdown":"A notification is read when its reader opens it, by following one of its\nlinks, or uses one of its actions; or with Mark all read. Seeing it in the\nlist does not make it read."},{"id":"how-long-they-are-kept","heading":"How long they are kept","level":3,"markdown":"A read notification is removed `NOTIFICATIONS_KEEP_DAYS` days after it was\nread: 90 unless the installation sets another number, and never with `0`\n([Configuration](https://www.anahita.io/docs/services/configuration/#notifications)). An unread one stays until\nit is read. notification-service looks for them when it starts and every six\nhours after, and removes them a thousand at a time\n(`notification-service/cmd/api/keeping.go`)."},{"id":"delivery","heading":"Delivery","level":2,"markdown":"Events are acknowledged as they arrive and never delivered twice, so\nnothing is written or sent twice; a handler that fails says so in the log,\nand an event published while notification-service is down is not heard\n([Deploying](https://www.anahita.io/docs/services/deploying/#running-more-than-one-copy-of-a-service)). Keep\nnotification-service at one copy: each copy hears every event."},{"id":"the-api","heading":"The API","level":2,"markdown":"| Route | What it does |\n| --- | --- |\n| `GET /notifications/?start=&limit=` | The viewer's notifications, newest first |\n| `GET /notifications/unread-count` | How many they have not read (`count`), and when the newest was told (`newest_at`) |\n| `GET /notifications/settings` | What the viewer is told about: every kind of thing, on or off |\n| `PATCH /notifications/settings` | Turns one kind of thing on or off (`topic`, `on`) |\n| `PATCH /notifications/read-all` | Marks all of them read (Mark all read), and answers how many were unread |\n| `PATCH /notifications/:id` | Marks one read |\n| `DELETE /notifications/:id` | Deletes one |\n| `POST /notifications/subscriptions` | Subscribes to a profile or a post |\n| `DELETE /notifications/subscriptions/:target_id` | Ends that subscription |\n\nEvery route is the viewer's own: nobody reads, marks or deletes another's\nnotifications. The reference is [https://github.com/anahitasocial/anahita-services/blob/main/docs/api/notification-service.json](https://github.com/anahitasocial/anahita-services/blob/main/docs/api/notification-service.json)."}]},{"source":"services","slug":"recommendations","title":"Recommendations","group":"People","url":"https://www.anahita.io/docs/services/recommendations/","sections":[{"id":"","heading":null,"level":1,"markdown":"recommendation-service suggests people, groups and events to follow. It uses no model: each suggestion is worked out from the graph as it stands, by rules written down here, and is sent with the reasons for it.\n\nIts routes are in the API reference: [recommendation-service](https://www.anahita.io/api/recommendation/).\n\nIt is optional. An installation that does not want suggestions leaves the service out, and the web app shows none. An installation that wants them chosen another way runs another service that answers the same paths."},{"id":"the-contract","heading":"The contract","level":2,"markdown":"Every path needs somebody signed in: a suggestion is made to a person, about that person.\n\n| Endpoint | What |\n| --- | --- |\n| `GET /recommendations/actors?kind=&context=&limit=` | Who the viewer might follow, best first |\n| `GET /recommendations/interests?limit=` | Hashtags to offer somebody who has just joined, the most used first (see below). `limit` up to 40, 20 by default |\n| `GET /recommendations/preferences` | What the viewer has chosen about suggestions |\n| `PUT /recommendations/preferences` | Changes any of those choices. One not sent is left as it was |\n| `GET /recommendations/dismissals` | The profiles the viewer said they were not interested in |\n| `POST /recommendations/dismissals` `{ actor_id }` | Not interested in this one |\n| `DELETE /recommendations/dismissals/<actor_id>` | Takes that back |\n\n`GET /recommendations/actors` takes:\n\n| Parameter | Values |\n| --- | --- |\n| `kind` | `person` (the default), `group` or `event` |\n| `context` | `home` (the default), or `onboarding` for somebody who has just joined and follows nobody: nothing worked out from who they follow is used |\n| `limit` | Up to 50. 10 when left out |\n\nand answers:\n\n```json\n{\n  \"items\": [\n    {\n      \"actor_id\": 4,\n      \"score\": 9,\n      \"reasons\": [\n        { \"type\": \"followed_by_leaders\", \"count\": 3, \"ids\": [2, 3, 9] }\n      ],\n      \"actor\": { \"id\": 4, \"type\": \"node.actor.person-service.person.v1\", \"name\": \"…\" }\n    }\n  ],\n  \"related\": [\n    { \"id\": 2, \"type\": \"node.actor.person-service.person.v1\", \"name\": \"…\" }\n  ]\n}\n```\n\n- `actor` is the profile as the viewer is sent it anywhere else.\n- `reasons` is why, the one that counted most first. `count` is how strongly, and `ids` up to three of what it was found through.\n- `related` is what the reasons name, to say them in words: the people who follow somebody suggested, the hashtags shared. Only the ones the viewer may see."},{"id":"why-somebody-is-suggested","heading":"Why somebody is suggested","level":2,"markdown":"Each reason adds to a score, up to a most. The score is then kept in full for a profile that was visited or posted on in the last 90 days, halved for one quiet for up to a year, and cut to a tenth for one quiet longer, so that the dormant come last.\n\n**A person quiet for longer than the reader chose is not suggested at all,** whatever else speaks for them: somebody who has gone will not see that they were followed, and has nothing new to show. The choice is `active_within_days` in the preferences, one of 30, 90, 180, 365, 730, 1825 or 0 for no limit (`active_within_choices` in the answer lists them); it is a year for somebody who has not chosen, and `reset_weights` puts it back there. It is kept beside their weights, in the same column. A group that quiet is only put last, since it is still there to be joined and woken. On an installation brought over from an older one, where few people have been back, a year leaves few people to suggest: the reader slides it further back, to any time if need be.\n\n| Reason | Found when | Each | Most |\n| --- | --- | --- | --- |\n| `follows_you` | They follow the viewer, who does not follow them. People only | 4 | 4 |\n| `pictured_with_you` | They and the viewer are in the same public photo or video, tagged, or one posted it and the other is tagged in it. Approved tags only. People only | 3 | 6 |\n| `followed_by_leaders` | People the viewer follows, follow them. `ids` are those people | 3 | 15 |\n| `pictured_with_leaders` | They are in public photos or videos with people the viewer follows, or in ones those people posted. Approved tags only. `ids` are those people, and are never named under the suggestion. People only | 2 | 8 |\n| `cited_by_leaders` | People the viewer follows cited a public post of theirs or on their profile, in a published public article or topic of the last year. `ids` are those people | 3 | 9 |\n| `inviter` | They invited the viewer, or the viewer invited them. People only | 3 | 3 |\n| `hosted_by_leader` | A group the viewer follows is hosting it. Events only. `ids` are those groups | 3 | 3 |\n| `you_engaged` | The viewer liked what is posted on their profile, in the last 90 days | 2 | 8 |\n| `shared_interest` | Their public posts of the last year carry hashtags the viewer has used or liked, or follows. `ids` are those hashtags | 2 | 8 |\n| `cited` | Anybody cited them, as above, but never themselves and never the viewer. `ids` are those people | 1 | 4 |\n| `featured` | The installation's administrators featured them | 0.5 | 0.5 |\n| `popular` | Many people followed them in the last 30 days | 0.5 | 0.5 |\n\nTwo profiles that score the same are in the order of their ids, so an answer is the same each time it is asked for.\n\n`shared_interest` looks only at public posts. The hashtags come back as the reason, and a reason must not say what is in a post the viewer could not read.\n\n**Hashtags somebody follows** count toward `shared_interest` as well. Following a hashtag makes a custom feed of it, the viewer's own; the service reads the hashtags of the feeds the viewer owns. That is what the Interests step of signing up gives it: somebody who has just joined has written and liked nothing, and what they picked is the one thing to go by besides who invited them, what is popular and what is featured. Since following is a choice they made, it counts even when they turned `use_activity` off, and in `context=onboarding`."},{"id":"hashtags-to-offer","heading":"Hashtags to offer","level":3,"markdown":"`GET /recommendations/interests` lists the hashtags on the most public posts, for the Interests step of signing up. Only public posts of profiles anybody may see are counted, so a hashtag offered says nothing about what is kept from view; spellings that differ only in case count as one, under the spelling used most. A hashtag on fewer than two posts is not offered. It looks at the last three months first, then the last year, then all time, until it has found eight, so that a site that has been quiet lately still has something to offer. The web app leaves the step out when fewer than three come back, as on a site that has just been set up.\n\nThe numbers are constants in `recommendation-service/constants`. They are the service's own judgement of what makes a good suggestion, and each person can set their own in its place: see [Each person's own weights](#each-persons-own-weights)."},{"id":"events","heading":"Events","level":3,"markdown":"An event is suggested while it is still to come or on, and has not been called off. For an event, `followed_by_leaders` is people the viewer follows who have answered it, and following already is having answered it. An event is not asked to have a picture or a description, and is not marked down for when it was last visited: one that is still to come is as alive as anything is. It is sent with its `event` block, as it is in search results."},{"id":"people-on-other-networks","heading":"People on other networks","level":3,"markdown":"Somebody on Bluesky or on a server of the fediverse who is kept here is a\nperson like any other to whoever is asking who to follow, and is suggested\nfor the same reasons: people the viewer follows follow them, they follow\nthe viewer, they post about what the viewer does. They are among the\npeople, with their whole handle (`alice.bsky.social`,\n`@bob@mastodon.example`) to say where they are.\n\n- **Only the ones kept here.** This installation knows of nobody on those\n  networks but whoever somebody here looked up or follows, whoever they\n  reposted, and whoever did something to a post that went out from here.\n  It cannot suggest a good account that nobody here has met.\n- **Somebody on Bluesky is suggested to anybody.** Anybody here can follow\n  them.\n- **Somebody on the fediverse is suggested only to a person who is on the\n  fediverse themselves.** Anybody else's follow of them would be taken\n  away again, since the other server could not find out who was asking.\n- **How lately they were active** is what was kept of them here: the last\n  post of theirs that arrived."},{"id":"who-is-never-suggested","heading":"Who is never suggested","level":2,"markdown":"Whatever the reason, a profile is left out when it is:\n\n- the viewer, or somebody they follow already, or have asked to follow;\n- on either side of a block with the viewer;\n- one the viewer said they were not interested in;\n- somebody who asked not to be suggested;\n- disabled, archived or deleted;\n- an event that is over or was called off;\n- a person who has not confirmed their email address;\n- a person or group without a picture and with nothing said about itself;\n- one the viewer may not see.\n\nAll but the last are one clause every signal's query ends with (`eligibleCandidate`). The last is the graph's rule and is asked of the graph: the best hundred of what was found are put to `FilterVisible`, and only what it returns is sent. So is everything in `related`."},{"id":"what-each-person-chooses","heading":"What each person chooses","level":2,"markdown":"`GET` and `PUT /recommendations/preferences`:\n\n| Choice | Means |\n| --- | --- |\n| `suggest_people` | To be suggested people |\n| `suggest_groups` | To be suggested groups |\n| `suggest_events` | To be suggested events |\n| `use_activity` | That what they liked and wrote, and the photos they are in, may be used to choose for them. Off, `you_engaged`, `shared_interest`, `pictured_with_you` and `pictured_with_leaders` are not looked for. Who they follow is still used |\n| `discoverable` | That they may be suggested to other people |\n\nEvery choice is yes until its owner says otherwise. They are theirs to read and change and nobody else's."},{"id":"each-persons-own-weights","heading":"Each person's own weights","level":3,"markdown":"Somebody can set how much each reason counts for when choosing for them. It changes the order they are suggested things in, and nothing about what anybody else is suggested.\n\n- `GET /recommendations/preferences` sends `weights` (what each reason counts for, for them), `default_weights` (the service's own), `max_weight` (10) and `reasons` (the order to list them in).\n- `PUT` takes `weights`, by the reason's name. The ones sent are set and the others left. A weight is from 0 to 10; a reason there is not, or a number out of range, is answered `422` with `invalid_weight` and nothing is changed.\n- A reason set to 0 is not used for them at all.\n- What a reason may count for at most keeps its proportion: `followed_by_leaders` counts for up to five times what it counts for once, whatever that is set to.\n- `reset_weights: true` goes back to the service's own.\n\nKept as JSON in `recommendation_preferences.weights`, empty until set (migration `20261021000001_recommendation_weights`)."},{"id":"not-interested","heading":"Not interested","level":2,"markdown":"A profile somebody turns down is not suggested to them again. It is theirs alone: whoever was turned down is not told, and nobody else can read the list. It can be taken back from the list."},{"id":"running-it-and-not-running-it","heading":"Running it, and not running it","level":2,"markdown":"- It reads the installation's database for its signals and writes two tables of its own, `recommendation_preferences` and `recommendation_dismissals` (migration `20261020000001_recommendations`).\n- It takes the settings every service does (`DSN`, `GRAPH_SERVICE_ADDR`, `FILE_SERVICE_ADDR`, `AMQP_ADDR`, the OAuth settings) and has none of its own.\n- nginx sends `/recommendations/` to it. `make recommendation` builds and starts it on the development stack.\n- When an actor is removed for good, what it chose and what was chosen about it are removed with it (`node.actor.*.*.purged.v1`).\n- **Without it:** the gateway answers `/recommendations/` with an error, which the web app takes as there being no suggestions and shows nothing of them. On Kubernetes, leave `services/recommendation.yml` out of the kustomization.\n- **In its place:** another service answering the paths above. It should ask the graph which profiles the viewer may see, as this one does."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"- `recommendation-service/data/repositories/signals.go`: the queries, one a reason, and the clause that keeps each to profiles that may be suggested. Tested against a real database in `signals_integration_test.go`.\n- `recommendation-service/scoring`: the ranking. It reads nothing, so the same finds give the same answer.\n- `recommendation-service/cmd/api/handlers`: the endpoints."},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- **Locations somebody picks.** Only hashtags are offered in signing up and read from followed feeds; locations followed as feeds are not used yet.\n- **Nearby.** Nothing uses where people are.\n- **A cache.** Each answer is worked out when it is asked for, which on the development data takes under a tenth of a second.\n- **Suggested posts.** The same contract would take a second path for them."}]},{"source":"services","slug":"data-export","title":"Download your data","group":"People","url":"https://www.anahita.io/docs/services/data-export/","sections":[{"id":"","heading":null,"level":1,"markdown":"Anybody can have a copy of what they have put here: everything they wrote,\nthe pictures and videos they posted, who they follow and who follows them.\nThey ask for it in their settings; a worker gathers it into one zip file;\nthey fetch it within a week.\n\nIts routes are in the API reference: [export-service](https://www.anahita.io/api/export/)."},{"id":"the-parts","heading":"The parts","level":2,"markdown":"| Part | What it does |\n| --- | --- |\n| `export-service` (port 8104, `/exports`) | Takes a request, says how it is getting on, and gives the address the archive is fetched from |\n| `export-worker` | Makes each archive, stores it, and deletes the ones that have been kept their time. Answers no requests |\n| `data_exports` | One row for each request: the request, the work on it, and the record of it |\n| `graph-grpc-service` | `ListAuthored`, the list of everything one person wrote |\n| `file-grpc-service` | Keeps archives under `assets/v1/export-service/`, and only ever sends one as a file to save |\n\nOne module, `export-service`, with two programs and two images, as the\nvideo service is."},{"id":"asking-for-one","heading":"Asking for one","level":2,"markdown":"`POST /exports`, by somebody who has **just proved who they are** at\nauth-service's `/reauth/` endpoints, the same proof deleting a profile\nasks for. An archive holds what only its owner may see, and a session left\nopen on a shared computer must not be enough to take it. One proof makes\none export. Where the proof cannot be checked, because Redis is not\nreachable, the request is refused.\n\n| Refused with | When |\n| --- | --- |\n| `403 step_up_required` | No fresh proof |\n| `409 active` | One is already waiting or being made. One at a time for each person, which is a rule of the table (a unique index) and not of whichever request arrived first |\n| `429 too_soon`, with `next_at` | The last one was asked for less than `EXPORT_EVERY_DAYS` ago. One that failed or was given up does not count |\n\nWhere the request came from, its address and browser, is kept on the row."},{"id":"what-is-in-an-archive","heading":"What is in an archive","level":2,"markdown":"| File | What |\n| --- | --- |\n| `index.html` | Everything here as a page to read. It has its own styles, no scripts, and fetches nothing: it works with no connection |\n| `README.txt` | What each file is, and how many posts and files there are |\n| `data/profile.json` | The profile |\n| `data/posts.ndjson` | Everything written, one post on each line: notes, articles, topics, photos, videos, replies, reposts and custom feeds, wherever each was posted, **including what only its author may see** |\n| `data/following.json`, `followers.json`, `blocked.json` | Who they follow, who follows them, who they blocked |\n| `data/liked.json`, `saved.json` | What they liked and saved, by name and address |\n| `actor.json`, `outbox.json` | The profile and the posts as ActivityStreams, the form other fediverse software reads. What was not public is addressed to nobody |\n| `media/<post id>/` | Each post's pictures at the largest size kept, a video at its largest size, an article's cover |\n| `media/profile/` | The avatar and the cover |\n\nEvery name and every post is somebody's text, and is written into the page\nas text: the page is made with a template that escapes all of it."},{"id":"what-is-not","heading":"What is not","level":2,"markdown":"- **What other people wrote.** A reply names the post it answers and a\n  repost the post it reposts; neither holds it.\n- **What was removed.** It is gone for its author too.\n- **The account**: the email address, sign-ins, passkeys. Those are\n  auth-service's, which this does not read.\n- **Files storage does not have.** A post naming a picture that is not\n  there keeps its text; the file is left out and counted in `README.txt`,\n  so one missing picture does not cost somebody their archive."},{"id":"how-it-is-made","heading":"How it is made","level":2,"markdown":"1. A worker takes the oldest request waiting (`SELECT … FOR UPDATE SKIP\n   LOCKED`, so several workers each take a different one).\n2. It reads the person's data through the graph, as that person, a page of\n   100 at a time, and writes the archive **to a file of its own as it\n   goes**. Nothing is held whole in memory: an archive is as large as\n   everything somebody posted.\n3. Pictures and videos are copied in from storage as they are, not\n   compressed again.\n4. The file is stored under `assets/v1/export-service/<person id>/<export id>.zip`, and the\n   request is marked `ready`.\n\nBetween each page and each file the worker asks whether the request has\nbeen given up, and stops if it has. One given up in the moment it was\nfinished is deleted and not marked ready.\n\nA worker that dies leaves its request taken; another takes it after two\nhours. A request is tried three times, then marked `failed`.\n\nNothing that is in an archive is logged: only that one was made, how big\nit is and how many posts and files it holds.\n\nOn the development data, an account with 8,410 posts and 840 MB of\npictures took five and a half minutes."},{"id":"keeping-and-deleting","heading":"Keeping and deleting","level":2,"markdown":"An archive is kept for `EXPORT_KEEP_DAYS`, a week unless said, and then\ndeleted from storage by the worker, the file first and the row marked\n`expired` after. Its owner can delete it sooner.\n\nIt is fetched from an address signed for a few minutes, made afresh each\ntime the export is read, which sends it as a file to save. `file-grpc-service`\nsends anything under that folder that way and no other, whatever is in it."},{"id":"endpoints","heading":"Endpoints","level":2,"markdown":"All of them are the viewer's own: an export belongs to whoever asked for\nit, and anybody else is told there is none.\n\n| Method and path | What |\n| --- | --- |\n| `POST /exports` | Ask for one |\n| `GET /exports` | The latest ten, with `next_at` when it is too soon to ask again, `every_days` and `keep_days` |\n| `GET /exports/:id` | One, with `download_url` while it is ready |\n| `DELETE /exports/:id` | Give it up while it is being made, or delete its archive |\n\n`status` is `pending`, `building`, `ready`, `failed`, `cancelled` or\n`expired`."},{"id":"settings","heading":"Settings","level":2,"markdown":"| Variable | Value |\n| --- | --- |\n| `EXPORT_EVERY_DAYS` | Days somebody waits between one export and the next. `7` unless said; `0` for no wait |\n| `EXPORT_KEEP_DAYS` | Days an archive is kept. `7` unless said |\n| `PUBLIC_WEB_URL` | Worker: the site's address, for the links in an archive back to where each thing is |\n| `SITE_NAME` | Worker: the site's name, for the page's heading. `Anahita` unless said |\n| `EXPORT_WORK_DIR` | Worker: where an archive is made before it is stored. `/work` in the image. Needs room for the largest one |"},{"id":"running-it-and-not-running-it","heading":"Running it, and not running it","level":2,"markdown":"`make export` builds and starts both containers. They need the database,\nRedis, `graph-grpc-service` and `file-grpc-service`.\n\nBoth are optional. Without them the web app's setting says the download is\nnot available here."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Asking for one, and the endpoints | `export-service/cmd/api/handlers/exports.go` (`Add`), over `anahita-libs/sessions/step_up.go` (`RequireStepUp`) |\n| The requests | `export-service/data/repositories/exports.go` (`Claim`, `MaxAttempts`), table `data_exports` in `migration-job/schema/init.sql` |\n| The worker: making, storing, deleting | `export-service/worker/worker.go` (`Tidy`), `export-service/cmd/worker/main.go` |\n| Reading as the person | `export-service/worker/source.go` |\n| What is in an archive | `export-service/archive/archive.go` (`Write`), `export-service/archive/page.go` (`writePage` for `index.html`, `readme`, `actorOf` and `outboxOf`) |\n| Everything one person wrote | `ListAuthored`: `graph-grpc-service/cmd/server/handlers/saved.go`, `anahita-libs/data/services/media_authored.go` |\n| Sent only as a file to save | `file-grpc-service/data/services/uploads.go` (`ExportsPrefix`) |\n| Settings | `export-service/config/config.go`: `EXPORT_EVERY_DAYS`, `EXPORT_KEEP_DAYS`, `PUBLIC_WEB_URL`, `SITE_NAME`; `EXPORT_WORK_DIR` in `export-service/cmd/worker/main.go` |\n| Tests of the rules | `export-service/cmd/api/handlers/exports_test.go`, `export-service/archive/archive_test.go`, `export-service/worker/worker_test.go`, `export-service/data/repositories/exports_integration_test.go` |\n| The web app | `src/containers/actors/Settings/DataExport.jsx`, `src/api/exports.js` (anahita-web) |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- Telling the person, by a notification or an email, that their archive is\n  ready. The settings page says.\n- The account's own data, from auth-service.\n- A Bluesky repository file, which comes with federation.\n- Deleting somebody's archives when their account is purged. They are\n  deleted when their week is up."}]},{"source":"services","slug":"photos","title":"Photo posts","group":"Posts","url":"https://www.anahita.io/docs/services/photos/","sections":[{"id":"","heading":null,"level":1,"markdown":"A photo post holds up to twenty images, or fewer where the installation\nsays so. This page covers how they are uploaded,\nstored, changed and removed.\n\nIts routes are in the API reference: [photo-service](https://www.anahita.io/api/photo/)."},{"id":"one-post-several-images","heading":"One post, several images","level":2,"markdown":"A post with several images is one post: one caption, one audience, one set of\nlikes and comments. It is not an album of separate posts. That is also how\nMastodon and Bluesky send them, as one post with attachments.\n\nTwenty is the limit, as on Instagram and Pixelfed (decided 2026-10-19). An\ninstallation may hold fewer with `PHOTO_MAX_FILES` (see\n[Configuration](https://www.anahita.io/docs/services/configuration/#photos)), never more. It is published in\nNodeInfo as `metadata.photoMaxFiles`, so that a client does not have to carry\nthe number (`constants.PhotoMaxFiles()`).\n\nA stock Mastodon server shows four pictures of a post, and Bluesky takes four.\nSo a post of more than four goes out with every picture to the fediverse and\nthe first four to Bluesky, and its words end with the address of the post\nhere, where they all are. Pixelfed and Misskey show them all. The composer\nsays so before it is posted.\n\nEach image can have a description of what it shows, up to 2,000 characters,\nfor people who cannot see it. One that comes with a post from another server\nis kept up to 10,000, Mastodon's own limit."},{"id":"posting","heading":"Posting","level":2,"markdown":"In two steps, so that no request carries more than one image however many the\npost has. A **reply with pictures** is posted the same way, its images\nuploaded for the person replying (their own id as `owner_id`), then\n`POST /photos/:id/replies` with `{ body, language, uploads }`, where `:id` is\nwhat it answers. Words are optional. It is a photo post in the thread: owned\nby its author, with what it is open to, who may answer it and quoting all the\nthread's; its language, unless given, is that of what it answers.\n\n**1. Upload each image.** `POST /photos/:owner_id/uploads`, a form with the\nimage in `file`. `owner_id` is the profile the post will be on. Whoever may\nnot post there may not upload there. The answer:\n\n```json\n{ \"upload_id\": 31, \"width\": 2048, \"height\": 1365, \"urls\": { \"small\": { \"url\": \"…\" }, \"…\": {} } }\n```\n\n**2. Make the post from the uploads.** `POST /photos/:owner_id/` as JSON:\n\n```json\n{\n  \"title\": \"Herons\",\n  \"content\": \"At the lagoon this morning.\",\n  \"access\": \"followers\",\n  \"language\": \"en\",\n  \"uploads\": [\n    { \"upload_id\": 31, \"alt_text\": \"A heron standing in shallow water\" },\n    { \"upload_id\": 30 }\n  ]\n}\n```\n\nThe order of `uploads` is the order of the images. The first is the cover.\n\nAn upload can be used only by whoever uploaded it, and only on the profile it\nwas uploaded for: that profile is part of where the image is stored.\n\n**One image in one request still works.** `POST /photos/:owner_id/` as a form\nwith the image in `file` (and optionally `alt_text`) makes a post with that\none image, as it did before a post could hold more.\n\nWhen the images are refused the answer says why, and how many a post holds:\n\n| Status | `error` | |\n| --- | --- | --- |\n| 422 | `files_required` | No image |\n| 422 | `too_many_files` | More than a post may hold (`max` says how many) |\n| 422 | `file_repeated` | The same image twice |\n| 422 | `alt_text_too_long` | A description over 2,000 characters |\n| 404 | `file_not_found` | An upload that is gone, or an image the post does not have |\n| 403 | `upload_not_yours` | Somebody else's upload, or one made for another profile |\n| 415 | `file_unsupported` | Not a JPEG or a PNG |\n| 413 | `file_too_large` | Over 10 MB, or over 50 megapixels |\n| 400 | `file_unreadable` | It could not be read as an image |\n\n**An upload nobody uses is removed after a day,** with everything stored for\nit, by the sweeper (`graph-grpc-service/sweeper/uploads.go`)."},{"id":"what-is-stored","heading":"What is stored","level":2,"markdown":"What arrives is never stored as it is. Each image is turned the right way up\nand written out again at each size, so nothing a file carries besides its\npicture reaches storage. What a file is, is decided from its bytes, not its\nname.\n\nEach size is measured by the picture's longest side, its height when it\nstands up and its width when it lies down, as Bluesky and Pixelfed measure\nthem:\n\n| Size | |\n| --- | --- |\n| `original` | The picture at its own size, up to 2,048 pixels on its longest side |\n| `xxlarge`, `xlarge`, `large`, `medium`, `small` | 2,048, 1,920, 1,440, 1,080 and 640 pixels on the longest side |\n| `square` | The middle of the picture, 640 pixels square |\n\nAn image is never enlarged: one smaller than a size is stored at its own\nsize under that size's name. A JPEG is written at quality 85. A PNG with\nnothing see-through in it, a screenshot as often as not, is kept as a JPEG;\none that is see-through somewhere stays a PNG. Pictures copied from posts\non other servers are kept the same way. Photos kept before 2026-10-16 were\nmeasured by their width, up to 2,560, and their files are as they were.\n\nImages are stored under the profile the post is on, not under the post:\n`assets/v1/photo-service/photo/<owner id>/<name>_<size>.<ext>`. That is what\nlets an image be stored before its post exists.\n\nTwo tables, both owned by `graph-grpc-service`:\n\n- **`photo_files`**: the images of each post, in order, with their\n  descriptions and dimensions.\n- **`photo_uploads`**: images uploaded and not in a post yet.\n\n`nodes.filename` and `nodes.mimetype` stay, as a copy of the first image, so\neverything that shows one image for a photo (search, notifications, hashtag\nand location pages) goes on working without knowing about the list."},{"id":"reading","heading":"Reading","level":2,"markdown":"A photo post carries its images as `files`, in order:\n\n```json\n\"files\": [\n  { \"id\": \"9f2c….jpg\", \"position\": 0, \"alt_text\": \"A heron standing in shallow water\",\n    \"width\": 2048, \"height\": 1365, \"urls\": { \"medium\": { \"url\": \"…\" }, \"…\": {} } }\n]\n```\n\n`portrait_urls` is still there and is the first image's sizes.\n\n`files` is sent by `photo-service` (a photo and a profile's photo list) and by\n`feed-service` (feeds, including a photo that was reposted). The other\nservices that list posts send `portrait_urls` only, so a post found through\nsearch shows its first image until it is opened.\n\nA photo posted before this existed has one entry, with no description and no\ndimensions.\n\n**In preview mode** (`SITE_READ_ACCESS=preview`) a visitor is sent the first\nimage only, at its smallest size, and `files_held_back` says how many more\nthere are. See [Privacy](https://www.anahita.io/docs/services/privacy/)."},{"id":"changing-a-posts-images","heading":"Changing a post's images","level":2,"markdown":"`PATCH /photos/:id/files`, by whoever may edit the post. The request is the\nwhole list as it is to be:\n\n```json\n{ \"files\": [\n  { \"id\": \"b41e….jpg\", \"alt_text\": \"Now first\" },\n  { \"upload_id\": 44 },\n  { \"id\": \"9f2c….jpg\" }\n] }\n```\n\nAn image the post has is named by its `id`; a new one by its `upload_id`. One\nleft out is removed, and its stored copies are deleted. A description left out\nis cleared. The rules and refusals are the same as when posting.\n\nOne list, and not separate requests to reorder, add and remove, so there is\none rule to explain and no order of changes to get wrong."},{"id":"downloading","heading":"Downloading","level":2,"markdown":"Anybody who can see a photo can save what their browser was shown. The\ndownload is something else: a proper file, at the largest size kept, with a\nsensible name, **where whoever posted it has said so**.\n\n- **The switch.** `allow_download` on a photo post, off unless turned on.\n  Sent when the post is made, and changed with\n  `PATCH /photos/:id/download` `{ \"allow\": true }` by whoever may edit the\n  post: its author, and the admins of the group it is on. Every photo\n  posted before this existed is off: nobody is taken to have agreed.\n- **What a new post starts with**, when its author does not say, is what\n  the profile it is posted on says: a person's own choice for their\n  profile, a group's for what is posted in the group\n  (`PATCH /people/:id/download-default`, `PATCH /groups/:id/download-default`).\n  That is where a post starts and no more. Changing it changes nothing\n  already posted.\n- **The download.** `GET /photos/:id/download?file=<position>` answers with\n  a redirect to a signed address that makes a browser save the file. The\n  first image when no position is given, and one image at a time. The file\n  is the largest copy kept, which was encoded here from what was uploaded;\n  it is named for the post's title, or `photo-<id>`.\n- **Who may.** Somebody who can see the post, when the switch is on; and\n  its author always, whatever the switch says. Somebody who cannot see the\n  post is told it is not there (`404`), as for the post itself; somebody\n  who can and may not download is told so (`403`). A visitor on a site in\n  `preview` mode downloads nothing. 60 a minute for each person.\n- **What a client is told.** A post that holds a file says\n  `authorized.download` for the viewer, wherever it is sent; the post's\n  own page also says `allow_download`, and a profile says its default.\n\nNothing is counted and nobody is told who downloaded what.\n\nVideos work the same way, with `/videos/` in place of `/photos/`; see\n[Videos](https://www.anahita.io/docs/services/videos/#downloading)."},{"id":"removal","heading":"Removal","level":2,"markdown":"- **Deleting a post** deletes every stored size of every one of its images.\n- **Purging an account** does the same for every photo the account owned or\n  posted. See [Account lifecycle](https://www.anahita.io/docs/services/account-lifecycle/).\n- **Unused uploads** go after a day, as above."},{"id":"limits","heading":"Limits","level":2,"markdown":"| | |\n| --- | --- |\n| Images in a post | 20, or fewer with `PHOTO_MAX_FILES` |\n| One uploaded file | 10 MB, which is what the gateway lets one request carry (`client_max_body_size` in `nginx.conf`). The two services that take images, photo-service and identity-image-service, allow a request of 11M for it (`constants.ImageRequestBodyMaxSizeLimit`); until 2026-10-10 they used the general 2M, which refused anything larger before this limit was reached |\n| Pixels in one image | 50 million |\n| Formats | JPEG, PNG. An animated PNG is kept still, as its first picture; a GIF or an animated PNG that is to move is posted as a video, which loops (see [Videos](https://www.anahita.io/docs/services/videos/#gifs-and-animated-pngs)) |\n| A description | 2,000 characters written here (what Bluesky's own app takes); 10,000 kept from another server (Mastodon's) |\n\nBluesky takes images of up to 2 MB (1 MB until 2025). A picture sent there is\nmade small enough before it goes. Every length people write is listed in\n[The API](https://www.anahita.io/docs/services/api/#lengths)."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Posting, uploads and changing a post's images | `photo-service/cmd/api/handlers/photo.go` (`Add`), `photo_files.go` (`Upload`, `EditFiles`) |\n| Checking the images of a post, and the refusals | `photo-service/cmd/api/handlers/photo_plan.go` (`planFiles`), tested in `photo_plan_test.go` |\n| Turning an image the right way up and writing it out again | `anahita-libs/utils/images.go` (`Accept`, `CorrectOrientation`, `FitLongest`, `AsPhoto`, `SquareImage`) |\n| The sizes | `anahita-libs/data/entities/node_portrait.go` |\n| Limits | `photo-service/constants/constants.go` (`MaxFileSize`, `MaxPixels`, `OriginalMaxSide`); `anahita-libs/constants/photo.go` (`PhotoMaxFiles`), `limits.go` (`AltTextMax`, `AltTextKeptMax`) |\n| The images of each post, and uploads not in a post yet | `graph-grpc-service/data/repositories/photo_files.go`, tables `photo_files` and `photo_uploads` |\n| Removing unused uploads | `graph-grpc-service/sweeper/uploads.go` |\n| Downloading | `photo-service/cmd/api/handlers/photo_download.go` (`EditDownload`, `Download`), over `anahita-libs/permissions/download.go` (`CanDownload`) |\n| In preview mode | `anahita-libs/middleware/preview.go` |\n| `photoMaxFiles` in NodeInfo | `auth-service/cmd/api/handlers/node_info.go` |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- **Drag to reorder** in the web app. Images are moved with arrow buttons,\n  which also work from the keyboard.\n- **Images in the lists of search, hashtags, locations and likes.** They show\n  the first image.\n- **Albums, and collections of other people's posts.** Decided against; see\n  [the plan](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/photo-albums.md).\n- **Photos and video in one post.** For the video service."}]},{"source":"services","slug":"videos","title":"Videos","group":"Posts","url":"https://www.anahita.io/docs/services/videos/","sections":[{"id":"","heading":null,"level":1,"markdown":"A video post is a post like any other: it has an audience, replies, likes,\na place in feeds. What is different is how it gets there. A video is too\nbig to be sent to the API, and nothing somebody uploads is ever shown as it\narrived, so a browser sends the file straight to storage, a worker makes\nthe files that are shown, and only then is there a post.\n\nIts routes are in the API reference: [video-service](https://www.anahita.io/api/video/)."},{"id":"the-parts","heading":"The parts","level":2,"markdown":"| Part | What it does |\n| --- | --- |\n| `video-service` (port 8103, `/videos/`) | Begins and finishes uploads, says how each is getting on, and serves video posts: read, edit, delete, audience, replies, pin. Sends viewers to a video's files |\n| `video-worker` | Takes each finished upload, checks it, makes its sizes and poster with ffmpeg, stores them, and makes the post. Answers no requests |\n| `file-grpc-service` | Signs the addresses the browser sends parts to and the worker reads and writes at, and holds each to its place in storage |\n| `video_uploads` | One row for each upload: the post as it will be, where the file is, how far it has got, and how many times it has been tried |\n\nThe two video containers are one module, `video-service`, with two\nprograms (`cmd/api`, `cmd/worker`) and two images. The worker's image has\nffmpeg in it and runs as a user of its own."},{"id":"sending-a-video","heading":"Sending a video","level":2,"markdown":"1. `POST /videos/:owner_id/uploads` with what the post will say (`title`,\n   `content`, `access`, `language`, `reply_access`, `quote_policy`) and the\n   file's `size` and `content_type`. Everything about the post is settled\n   now, by the rules for any post: whether this person may post on that\n   profile, and to that audience. The answer has the upload's `id`, a\n   `part_size` (8 MB) and a `part_count`.\n2. `POST /videos/uploads/:id/parts` `{ \"numbers\": [1, 2, 3] }` answers with\n   an address for each part. The browser `PUT`s each part to its address,\n   in any order, again if one fails. Addresses are asked for a few at a\n   time and are good for an hour.\n3. `POST /videos/uploads/:id/complete` makes one file of the parts. **How\n   much arrived is asked of storage, not of the browser**: a file past the\n   limit is thrown away whatever size was given in step 1.\n4. `GET /videos/uploads/:id` says how it is getting on: `uploading`,\n   `processing`, `ready` with the post's `node_id`, or `failed` with why.\n\nAn upload can be given up at any point (`DELETE /videos/uploads/:id`). One\nthat is begun and never finished is thrown away after 24 hours.\n\nThere is no post until step 4 says `ready`. Nothing has to be hidden from\nfeeds in the meantime, and an upload that goes nowhere leaves nothing\nbehind."},{"id":"what-the-worker-does","heading":"What the worker does","level":2,"markdown":"For each upload that is all there, one at a time:\n\n1. Reads the file from storage, stopping at the largest a video may be.\n2. Decides what it is; see [below](#what-a-file-has-to-be).\n3. Makes each size, never larger than the video is: H.264 and AAC in an\n   MP4 arranged so that it starts playing before all of it has arrived.\n4. Makes the poster, a JPEG of a frame one second in.\n5. Stores them under `assets/v1/video-service/videos/<profile id>/<upload id>/`.\n6. Makes the post, as the person who uploaded it, and marks the upload\n   `ready`.\n7. Deletes the upload.\n\nSeveral workers can run at once: each takes a different upload\n(`SELECT … FOR UPDATE SKIP LOCKED`). A worker that dies leaves its upload\ntaken; another takes it once the first must be dead, which is after longer\nthan the work on one video is allowed to last. That allowance grows with\nthe longest video allowed and the sizes made.\n\n| What went wrong | What happens |\n| --- | --- |\n| The file is not a video (`not_a_video`), or is too long (`too_long`), or too large (`too_large`), or is a GIF or PNG that does not move (`still`) | `failed`, for good, and the upload is deleted |\n| Anything else: storage, the network, ffmpeg itself | Tried again, up to 3 times, then `failed` as `encoding` |\n| The uploader gave it up meanwhile | What was made is deleted, and no post is made |"},{"id":"what-a-file-has-to-be","heading":"What a file has to be","level":2,"markdown":"The file is a stranger's and nothing has looked at it.\n\n- **Its first bytes say what it is**, and only these kinds are taken: MP4\n  or QuickTime (an `ftyp` box), WebM or Matroska (EBML), and the two\n  moving pictures, GIF (`GIF87a` or `GIF89a`) and animated PNG (a PNG that\n  says how it moves, `acTL`, before its picture begins). This is read\n  before ffmpeg is given the file. ffmpeg opens many kinds of file, and\n  some of them are lists of other files to read and addresses to fetch.\n- **ffmpeg is told which kind it is given** (`-f`), so it never guesses,\n  and may open files and nothing else (`-protocol_whitelist file`).\n- It must have a picture: sound alone, or a song with its cover, is not a\n  video.\n- It must be no longer than `VIDEO_MAX_SECONDS`, or, for a GIF or an\n  animated PNG, `ANIMATION_MAX_SECONDS`.\n- ffmpeg is run with nothing of the worker's environment but `PATH`.\n\n**Nothing of the upload is kept.** Every file shown is encoded afresh with\nno metadata copied over, so a title, a location or anything else written into\nthe upload stays in the upload, which is deleted.\n\nWhat the browser says the file is (`content_type`) only turns away what\ncould never be a video. It decides nothing."},{"id":"the-post","heading":"The post","level":2,"markdown":"A video post is a node of type `node.medium.video-service.video.v1`. Its\nfiles are described in its `meta` under `video`: the duration, each size\nwith its key in storage, and the poster. Nothing but the worker writes\nthat, and `anahita-libs/videos` is the one place that reads it.\n\nWherever a post is sent, a video post has:\n\n```json\n\"video\": {\n  \"duration\": 61.5,\n  \"width\": 1920,\n  \"height\": 1080,\n  \"poster\": \"/videos/170301/files/poster.jpg\",\n  \"files\": [\n    { \"height\": 1080, \"width\": 1920, \"url\": \"/videos/170301/files/1080.mp4\", \"type\": \"video/mp4\" },\n    { \"height\": 720,  \"width\": 1280, \"url\": \"/videos/170301/files/720.mp4\",  \"type\": \"video/mp4\" },\n    { \"height\": 360,  \"width\": 640,  \"url\": \"/videos/170301/files/360.mp4\",  \"type\": \"video/mp4\" }\n  ]\n}\n```\n\nIt is in feeds, search and custom feeds (kind `video`) like any post, and\nthe web shell describes a public one at `/videos/<id>`.\n\nDeleting the post deletes its files. So does purging the account that\nposted it."},{"id":"playing-it","heading":"Playing it","level":2,"markdown":"The addresses in `video` are paths under the API that stay the same. Each\nis answered with a redirect to a signed address in storage, where the file\nis read in ranges as a player asks.\n\n**Who may see the post is asked every time** one of those paths is\nfollowed. A file is never reached by somebody who may not see the post,\nand an address does not go on working after the post is made private or\ndeleted. A player sends the viewer's session cookie with the request, as a\nbrowser does for any picture or video on a page.\n\nThe signed address lasts 15 minutes. A player that asks for a new range\nafter that, on a video left paused, follows the path again."},{"id":"sizes","heading":"Sizes","level":2,"markdown":"`VIDEO_RENDITIONS` lists the sizes made, by height: `1080,720,360` unless\nsaid. Up to `2160` is taken, which is 4K. A video is never made larger\nthan it is: one filmed at 720p has a 720p and a 360p file and no 1080p.\nOne smaller than every size listed is made once, at its own size."},{"id":"gifs-and-animated-pngs","heading":"GIFs and animated PNGs","level":2,"markdown":"A GIF or an animated PNG is posted from the video composer, as Mastodon\ndoes with a GIF: it becomes a short clip with no sound that plays on a\nloop. It is a video post in every other way: who may see it, replies,\nlikes, downloading and deleting are the same.\n\n- **It is held to less than a video:** `ANIMATION_MAX_BYTES` (25 MB) and\n  `ANIMATION_MAX_SECONDS` (30), never more than a video's own limits.\n  Beginning an upload checks the size it says, and the worker checks what\n  the file really is and its real size again.\n- **A picture that does not move is refused** with the reason `still`,\n  which the web app turns into \"post it as a photo\". A GIF of one picture\n  is one; so is a PNG with no `acTL`, which the worker reads from the\n  file's chunks, since ffmpeg's reader of animated PNGs will not open it.\n  A picture less than ten pixels high or wide is refused as not a video.\n- **It is made once,** at its own size up to 720 high, in numbers that\n  divide by two: H.264 with no sound, each picture shown for as long as\n  it was (`-fps_mode passthrough`), and `+faststart`. A clip is a\n  fraction of the GIF's size: a two-second test GIF of 100 KB came to\n  16 KB.\n- **What was see-through is laid on white,** since an MP4 and a JPEG have\n  nothing see-through. That matters for stickers and not for the usual\n  clip.\n- **Its poster is its first picture,** on white.\n- **The post says it loops:** `\"loop\": true` in `video`, in its `meta` and\n  wherever it is sent. The web app plays it muted, on a loop, without\n  controls, and still where the reader asked for less motion.\n\nAn **animated WebP is not taken**: ffmpeg 8.1, in the worker's image,\nreads the file but not its pictures. A WebP is refused as not a video.\n\nMore and larger sizes cost in three places, and the settings go together:\n\n| To allow | Also raise |\n| --- | --- |\n| 4K (`VIDEO_RENDITIONS=2160,1080,720,360`) | `VIDEO_MAX_BYTES`: ten minutes of 4K is several gigabytes. And give the worker processor: 4K takes about nine times as long to encode as 720p |\n| Longer videos (`VIDEO_MAX_SECONDS`) | `VIDEO_MAX_BYTES`, and the worker's working space |\n\nThe player chooses between the sizes a video has. Nothing changes size\nwhile it plays: that needs a different way of storing video (HLS), which\nis not built."},{"id":"downloading","heading":"Downloading","level":2,"markdown":"As for a photo, and by the same rules: see\n[Photo posts](https://www.anahita.io/docs/services/photos/#downloading).\n\n- Whether a video may be downloaded is chosen when its upload begins\n  (`allow_download`, or what the profile says when it is left out) and is\n  carried to the post when the worker makes it. It is changed afterwards\n  with `PATCH /videos/:id/download`.\n- `GET /videos/:id/download` saves the largest size the video has, and\n  `?size=720` another. What is downloaded is what the worker made: the\n  upload itself was deleted."},{"id":"endpoints","heading":"Endpoints","level":2,"markdown":"| Method and path | Who | What |\n| --- | --- | --- |\n| `POST /videos/:owner_id/uploads` | signed in, may post there | Begin an upload. With `parent_id`, a reply with a video or a GIF: `owner_id` is the uploader's own, no title is needed, and audience, reply rule and quoting are the thread's. The thread is asked before anything is sent, and the worker asks it again before making the reply; a thread closed meanwhile fails the upload as `closed` |\n| `POST /videos/uploads/:id/parts` | the uploader | Addresses for some of its parts |\n| `POST /videos/uploads/:id/complete` | the uploader | All sent: hand it to the worker |\n| `GET /videos/uploads/:id` | the uploader | How it is getting on |\n| `GET /videos/uploads` | signed in | What they have in hand: being sent, being made, or failed in the last day |\n| `DELETE /videos/uploads/:id` | the uploader | Give it up |\n| `GET /videos/:owner_id/` | anybody | A profile's videos |\n| `GET /videos/:id` | whoever may see it | A video post |\n| `GET /videos/:id/files/:name` | whoever may see it | Redirect to `1080.mp4`, `720.mp4`, `360.mp4` or `poster.jpg` |\n| `PATCH /videos/:id`, `DELETE /videos/:id` | whoever may edit or delete it | Change its title and text; delete it and its files |\n| `PATCH /videos/:id/access`, `/comment-status`, `/reply-access`, `/quote-policy`; `PUT`/`DELETE /videos/:id/pin` | as for any post | As for any post |\n| `PATCH /videos/:id/download` | whoever may edit it | Whether it may be downloaded |\n| `GET /videos/:id/download` | whoever may see it, where it is allowed | Redirect to the file, to be saved |\n\nAn upload belongs to whoever began it. Anybody else is told there is none.\n\nRefusals when beginning an upload: `403 uploads_off`, `413 too_large`\n(with `max_bytes`), `415 not_a_video`, `429 too_many`; for a reply, `403` when\nthe thread does not take the viewer's replies and `410` when what it answers\nwas removed."},{"id":"settings","heading":"Settings","level":2,"markdown":"Read by `video-service`, `video-worker` and, for the first three,\n`auth-service`, which tells the web app. Set them once, where every\nservice reads its settings: `.env` for docker-compose, `config.env` on\nKubernetes.\n\n| Variable | Value |\n| --- | --- |\n| `VIDEO_UPLOADS` | `on` where people may post videos. Anything else, and unset, is off: no profile has the video feature (no composer, no Videos tab), and `video-service` refuses an upload. Videos already posted go on being shown in feeds and at their own addresses. Read by `graph-grpc-service` too, which is what leaves the feature off profiles |\n| `VIDEO_MAX_BYTES` | The largest file taken. 500 MB (`524288000`) unless said |\n| `VIDEO_MAX_SECONDS` | The longest video taken. `600`, ten minutes, unless said |\n| `VIDEO_RENDITIONS` | The sizes made, by height. `1080,720,360` unless said; see [Sizes](#sizes) |\n| `VIDEO_DAILY_UPLOADS` | How many videos one person may send in 24 hours. `20` unless said |\n| `ANIMATION_MAX_BYTES` | The largest GIF or animated PNG taken. 25 MB (`26214400`) unless said, and never more than `VIDEO_MAX_BYTES` |\n| `ANIMATION_MAX_SECONDS` | The longest one. `30` unless said, and never more than `VIDEO_MAX_SECONDS` |\n| `VIDEO_WORK_DIR` | Worker only: where a video is kept while it is worked on. `/work` in the image. Needs room for the largest upload and everything made of it |\n\nNodeInfo's `metadata` has `videoUploads`, `videoMaxBytes`,\n`videoMaxSeconds`, `animationMaxBytes` and `animationMaxSeconds`."},{"id":"storage","heading":"Storage","level":2,"markdown":"A browser sends the video to the store itself, from the web app's page,\nso **the bucket has to allow that**: a CORS rule for `PUT` from the web\napp's address. Without it every upload fails in the browser before\nanything is sent. On AWS S3, under the bucket's Permissions:\n\n```json\n[\n  {\n    \"AllowedOrigins\": [\"https://www.example.org\"],\n    \"AllowedMethods\": [\"PUT\"],\n    \"AllowedHeaders\": [\"*\"],\n    \"MaxAgeSeconds\": 3000\n  }\n]\n```\n\nOther stores have the same rule under their own name for it.\n\n`file-grpc-service` holds each kind of large file to its place:\n\n| Where | What | Who writes | Who reads |\n| --- | --- | --- | --- |\n| `assets/v1/video-service/uploads/<person>/<upload>/source` | What was uploaded, kept as `application/octet-stream` whatever it is | The browser, in parts | The worker. **Never a viewer**: no address is signed for one |\n| `assets/v1/video-service/videos/<profile>/<upload>/` | Each size and the poster | The worker | Viewers, as `video/mp4` or `image/jpeg` by its name, whatever it was stored as |\n\nA lifecycle rule on the bucket that aborts unfinished multipart uploads\nafter a few days is worth having: the worker aborts the ones it knows of,\nand the rule catches any it could not."},{"id":"running-it-and-not-running-it","heading":"Running it, and not running it","level":2,"markdown":"`make video` builds and starts both containers. Set `VIDEO_UPLOADS=on`.\n\nBoth are optional. Without them there are no video posts, and with\n`VIDEO_UPLOADS` unset the web app offers none. With `video-service`\nrunning and no worker, uploads are taken and wait.\n\nOne worker makes one video at a time. Run more replicas to make more at\nonce; each needs processor and working space of its own."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Beginning and finishing an upload, and how it is getting on | `video-service/cmd/api/handlers/video_uploads.go` |\n| Video posts: read, edit, delete, audience, replies, pin | `video-service/cmd/api/handlers/` (`video.go`, `video_access.go`, `video_reply.go`, `video_pin.go`) |\n| Sending viewers to a video's files | `video-service/cmd/api/handlers/video_files.go` |\n| Downloading | `video-service/cmd/api/handlers/video_download.go`, over `anahita-libs/permissions/download.go` (`CanDownload`) |\n| The worker | `video-service/worker/worker.go`, `video-service/cmd/worker/main.go`, tested in `worker_test.go` |\n| What a file is, how long it runs, and the sizes made | `video-service/encode/encode.go` (`Container`, `ParseProbe`, `Plan`, `PlanAnimation`), tested in `encode_test.go` and `encode_ffmpeg_test.go` |\n| The uploads | `video-service/data/repositories/uploads.go`, table `video_uploads`, tested in `uploads_integration_test.go` |\n| Where large files may be written and read | `file-grpc-service/data/services/uploads.go` (`UploadsPrefix`, `WorkerPrefix`) |\n| Settings | `video-service/config/config.go` |\n| The two images | `dockerfiles/video-service.dockerfile`, `dockerfiles/video-worker.dockerfile` |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- Telling the uploader, by a notification, that a video is ready or has\n  failed. The composer asks.\n- Changing size while playing (HLS), captions, and a choice of poster.\n- Videos from other networks: a video posted here goes to other servers\n  and Bluesky (see [Federation](https://www.anahita.io/docs/services/federation/)), but one posted there is not\n  brought in.\n- Animated WebP, until ffmpeg reads one; and keeping what is see-through in\n  a GIF (WebM with transparency, or keeping the picture as it was)."}]},{"source":"services","slug":"link-previews","title":"Link previews","group":"Posts","url":"https://www.anahita.io/docs/services/link-previews/","sections":[{"id":"","heading":null,"level":1,"markdown":"A note that has a link in it can carry a card for that link: the page's\ntitle, a line about it, the site it is on and a picture. The card is made\nwhen the note is written and is kept with it, so it reads the same later\neven when the page it points to has changed or gone.\n\nIts routes are in the API reference: [link-preview-service](https://www.anahita.io/api/link-preview/)."},{"id":"two-halves","heading":"Two halves","level":2,"markdown":"| Part | Where | What it does |\n| --- | --- | --- |\n| `link-preview-service` | port 8101, `/link-previews` | Fetches a page from the internet, reads its title and picture, and keeps the result in Redis |\n| `anahita-libs/linkpreviews` | a package | The preview itself, the Redis store, how an address is normalised, and how a card is written into and read out of a note's `meta` |\n| `text-service` | `POST` and `PATCH /notes` | Copies a kept preview onto the note |\n\nOnly `link-preview-service` connects to addresses people type. It is a\nservice of its own for that reason: it has no database access, and what it\nmay connect to is decided in one file, `fetch/safe.go`.\n\n`text-service` never fetches anything and never takes a title or a picture\nfrom the client. It is given an address, looks it up in the store, and\ncopies what `link-preview-service` put there. A client cannot write a card\nthat says something the page did not."},{"id":"fetching-a-page-safely","heading":"Fetching a page safely","level":2,"markdown":"The address comes from a person, so it is treated as hostile.\n\n- `http` and `https` only, on ports 80 and 443 only.\n- The name is resolved, and the connection is refused when the address it\n  is about to be made to is private, loopback, link-local, multicast,\n  unspecified, carrier-grade NAT, or a cloud metadata address. The check is\n  on the dialer (`Control`), so it is made on the address actually\n  connected to: a name that resolves to something else the second time\n  gains nothing.\n- The same check runs again for every redirect. At most 3 are followed.\n- No proxy from the environment is used.\n- It says it is `Anahita/1.0` and no more. Naming the installation's address\n  as well, as a crawler usually does, gets the connection dropped by some\n  large sites' edges.\n- 5 seconds for the whole request.\n- At most 1 MB of a page is read, and only `text/html`.\n- At most 5 MB of a picture is read."},{"id":"what-is-read-from-the-page","heading":"What is read from the page","level":2,"markdown":"In this order, the first that is there: Open Graph (`og:title`,\n`og:description`, `og:image`, `og:site_name`), then `twitter:` tags, then\n`<title>` and `<meta name=\"description\">`. Reading stops at the end of\n`<head>`.\n\nA page with no title has no preview. Titles are cut to 200 characters and\ndescriptions to 300."},{"id":"the-picture","heading":"The picture","level":2,"markdown":"The picture is fetched by the same fetcher, decoded, made no wider than\n800 pixels and encoded again, then stored through `file-grpc-service` under\n`assets/v1/link-preview-service/`, named by the SHA-256 of its bytes. What is stored is\nalways a picture this installation encoded itself.\n\nA reader's browser asks this installation for the picture, never the site\nthe link points to, so reading a note tells that site nothing.\n\nA preview whose picture could not be fetched or read is kept without one."},{"id":"how-long-a-preview-is-kept","heading":"How long a preview is kept","level":2,"markdown":"| What | Kept for |\n| --- | --- |\n| A preview | 7 days |\n| That a page could not be read, or said nothing of itself | 1 hour |\n\nBoth are in Redis under the SHA-256 of the normalised address (lower-case\nscheme and host, no fragment), so the same link pasted by two people is\nfetched once. The card on a note is a copy and does not expire."},{"id":"endpoints","heading":"Endpoints","level":2,"markdown":"| Method and path | Who | What |\n| --- | --- | --- |\n| `GET /link-previews?url=` | signed in | The preview: `url`, `title`, `description`, `site_name`, `image`. `204` when there is nothing to show, `400` when it is not an http or https address, `429` past 30 a minute |\n| `GET /link-previews/image/:name` | anybody | Redirects to the stored picture. `:name` is 64 hexadecimal characters and `.jpg`, `.png`, `.gif` or `.webp`; anything else is `404` |\n\n`image` is a path under the API, `/link-previews/image/<name>`."},{"id":"the-card-on-a-note","heading":"The card on a note","level":2,"markdown":"`POST /notes/:owner_id/` and `PATCH /notes/:id` take `link_preview_url`. When that\naddress is in the note's body and a preview of it is in the store, the\npreview is copied into the note's `meta` under `link_preview`. Sent empty,\nor with an address that is not in the body, a new note has no card.\n\nAn edited note keeps the card it has for as long as its link is still in\nthe body, whatever is sent, and is given another only when the address sent\nis another link's and has a preview in the store. With the link taken out\nof the note, the card goes too, and `link_preview_remove` takes it off\nwhile the link stays.\n\nA reply with words alone is a note: `POST /notes/:id/replies` takes `link_preview_url` the\nsame way, and a reply is edited like any note.\n\nWherever the note is sent, it has `link_preview` with the same fields as\nabove. A repost carries the card of the note it reposts."},{"id":"cards-in-long-writing","heading":"Cards in long writing","level":2,"markdown":"An article or a topic can show several pages, each an address on a line of\nits own (see [Embeds and citations](https://www.anahita.io/docs/services/architecture/#embeds-and-citations)).\nThe composer asks for a preview of each while it is written. When the post\nis added or edited, text-service copies the previews it finds in the store\ninto the post's `meta` under `link_previews`, in the order the pages come,\nat most 10. A card already kept for a page still in the text stays as it\nwas. A page with no preview in the store is shown as a link.\n\nWherever the article or topic is sent, it has `link_previews`: a list with\nthe same fields as a note's card."},{"id":"running-it-and-not-running-it","heading":"Running it, and not running it","level":2,"markdown":"`make link_preview` builds and starts it. It needs Redis and\n`file-grpc-service`, and has no settings of its own.\n\nIt is optional. Without it, asking for a preview fails, the composer shows\nno card, and notes are written without one. Cards already on notes are\nstill shown; only their pictures need the service."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Fetching a page safely | `link-preview-service/fetch/safe.go`, `fetch.go`, over `anahita-libs/safehttp`; tested in `fetch_test.go` |\n| What is read from the page | `link-preview-service/fetch/parse.go` |\n| The picture, no wider than 800 pixels | `link-preview-service/fetch/fetch.go` (`Thumbnail`), `safe.go` (`ThumbnailWidth`), `link-preview-service/cmd/api/handlers/previews.go` (`keepPicture`, `Image`) |\n| The endpoints | `link-preview-service/cmd/api/handlers/previews.go` |\n| The preview, the Redis store, how long it is kept, normalising an address | `anahita-libs/linkpreviews/linkpreviews.go` (`FreshFor`, `FailedFor`, `Key`, `Normalise`) |\n| The card in a note's `meta` | `anahita-libs/linkpreviews/linkpreviews.go` (`IntoMeta`, `FromMeta`); `text-service/cmd/api/handlers/note.go` (`withLinkPreview`) |\n| Cards in long writing | `text-service/cmd/api/handlers/embeds.go` (`withEmbeddedPages`); `SnapshotsIntoMeta` in `anahita-libs/linkpreviews` |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- oEmbed, and a favicon for a page with no picture.\n- Cards on photo and video posts, and for any link in a note but the first.\n- Blocking previews of chosen domains. The list of servers the site refuses\n  for federation is not consulted."}]},{"source":"services","slug":"hashtags","title":"Hashtags and locations","group":"Posts","url":"https://www.anahita.io/docs/services/hashtags/","sections":[{"id":"","heading":null,"level":1,"markdown":"How a post gets its hashtags and its locations, how many it can have, and the\nhashtags offered to somebody while they write."},{"id":"how-a-post-gets-its-hashtags","heading":"How a post gets its hashtags","level":2,"markdown":"A hashtag is a `#` and the letters after it, at the start of the text or\nafter a space: letters of any language, digits, `_` and common emoji\n(`entities.HashtagRegexPattern`). `a#b` is not one.\n\nhashtag-service reads the text of a post each time it is added or changed,\nand files the post under each hashtag in it. A hashtag that does not exist\nyet is made then. Capitals don't matter: `#Anahita` and `#anahita` are one\nhashtag, found at `/hashtags/anahita`, and shown as the first post to use it\nwrote it.\n\nA post from another server gets its hashtags the same way, from its text; see\n[Federation](https://www.anahita.io/docs/services/federation/)."},{"id":"at-most-ten-hashtags","heading":"At most ten hashtags","level":2,"markdown":"A post is filed under **its first ten different hashtags**, counted in the\norder they are written, whatever their capitals. The rest stay in the text\nas plain words: not links, not filed, not sent as tags.\n\n| Where | What happens |\n| --- | --- |\n| hashtag-service | Files the post under the first ten |\n| The web app | Links the first ten in the text, and says so while writing past them |\n| ActivityPub | Sends the first ten as links and as the note's tags |\n| Bluesky | Marks the first ten as tags in the post's facets |\n\nNothing is refused. A post with more is saved whole, so an old post with\nfifteen hashtags can still be edited. A post from another server with more\narrives whole too, and is found under its first ten only.\n\nThe number is `constants.MaxHashtagsOfAPost` in anahita-libs, and\n`Medium.MAX_HASHTAGS` in the web app. Ten is also how many hashtags a custom\nfeed can filter on (`feeds.MaxHashtags`). For comparison: Mastodon, Pixelfed\nand the other fediverse servers set no limit in code; Bluesky allows eight\nextra tags outside a post's text, and no limit in it."},{"id":"at-most-five-locations","heading":"At most five locations","level":2,"markdown":"A post can be tied to **at most five locations**. Locations are added one at a\ntime (`POST /locations/:id/inbounds/`), so a sixth is refused with\n`422` and `{\"error\": \"too_many_locations\"}`. Tying a post to a location it\nalready has is not a sixth.\n\nThe number is `constants.MaxLocationsOfAPost` in anahita-libs, and\n`Medium.MAX_LOCATIONS` in the web app. Five is also how many locations a custom\nfeed can filter on (`feeds.MaxLocations`)."},{"id":"a-posts-locations-on-other-servers","heading":"A post's locations on other servers","level":2,"markdown":"A location chosen for a post in the composer is tied to it as it is made\n(`location_ids` on adding it, up to five) and goes with it to other servers,\nwhen the post goes: the first as its `location`, all as `Place`s in its\n`tag`. Adding or taking off a location on its page sends it again. See\n[Federation](https://www.anahita.io/docs/services/federation/#posts-going-out)."},{"id":"who-may-add-a-location","heading":"Who may add a location","level":2,"markdown":"A hashtag is made by writing it. A location is added by somebody, with a\nname and a point, and who may add one is the installation's choice:\n\n| `LOCATIONS_FROM` | Who may add a location |\n| --- | --- |\n| `administrators` (the default) | Administrators and super administrators |\n| `registered` | Anybody signed in |\n| `super-administrators` | Super administrators |\n\nlocation-service enforces it, and auth-service publishes it in NodeInfo as\n`locationsFrom`, from which the web app offers Add only to those it lets\nadd one; give both services the same value. Editing and deleting a location\nstay with administrators whatever it says. Anybody signed in may tie a post\nof theirs to a location that exists. See\n[Configuration](https://www.anahita.io/docs/services/configuration/#registration)."},{"id":"trending","heading":"Trending","level":2,"markdown":"`GET /hashtags/_browse/trending` and `GET /locations/_browse/trending` list\nwhat the most profiles used lately, and how many (`profiles`, shown as\n\"3 profiles\").\n\n- **Each profile once, of every kind.** A profile uses a tag by writing a\n  post that carries it, or by being tagged with it: a person, a group, an\n  event or a website, here or on another server. One profile posting a\n  hashtag twenty times counts as one, so nobody can make something trend\n  alone. Ties go to the one on more posts and profiles.\n- **By when a post was written,** not when it arrived, and a profile by\n  when it was tagged, not when it was made. A profile's older posts fetched\n  when it is followed don't trend that day.\n- **Public only:** posts anybody may see, on profiles anybody may see, and\n  public profiles, so what trends says nothing about what is kept from\n  view.\n- **Over a window** the installation chooses (`TRENDING_WINDOW`, see\n  [Configuration](https://www.anahita.io/docs/services/configuration/#trending)): daily where it federates,\n  monthly where only its own members post. A window with fewer than ten is\n  widened, to a week, a month, then a year, and the answer's `window` says\n  which it is of. The web app shows it under the tabs."},{"id":"suggestions-while-writing","heading":"Suggestions while writing","level":2,"markdown":"hashtag-service offers hashtags already in use to somebody signed in who is\nwriting. There are two ways to ask:\n\n| Request | For | Offers |\n| --- | --- | --- |\n| `GET /hashtags/_suggest?q=ana` | Typing a hashtag | Those that begin with `q` (what follows the `#`) |\n| `POST /hashtags/_suggest` with `{\"title\": \"…\", \"content\": \"…\"}` | Long content | Those that are words of the title and content |\n\nBoth take `limit`, 1 to 20, and offer 8 when it is not given. Both answer\nwith the best first:\n\n```json\n{\n  \"data\": [\n    { \"id\": 138587, \"alias\": \"anahita\", \"name\": \"anahita\", \"uses\": 1031, \"followed\": false }\n  ]\n}\n```\n\n- `alias` is the hashtag in lower case; `name` is how it is shown.\n- `uses` is how many posts it is on that anybody may see, plus the person's\n  own.\n- `followed` is true when it is in one of the person's custom feeds, which\n  is what following a hashtag is.\n\n**The order.** Hashtags the person follows come first. For a typed start,\nthe hashtag that is exactly what was typed comes next, then the most used.\nFor a text, the words the text uses most come next, then the most used.\n\n**What is counted.** Only posts anybody may see (public, published, switched\non, on a profile anybody may see) and the person's own. So a hashtag used\nonly where the person can't look is never offered to them, and what is\noffered says nothing about what is kept from view.\n\n**Words of a text.** The title and body are split into words of three\nletters or more, and each two words next to each other are also tried run\ntogether, so \"open science\" finds `#OpenScience`. Addresses, code,\nmentions and hashtags already written are not words. A word is offered only\nwhen it is a hashtag on more than one post, and hashtags the text already\nhas are left out. The first 100,000 characters are read.\n\nMatching ignores capitals and accents: `cafe` finds `#café`. `%` and `_`\nare matched as themselves."},{"id":"a-hashtags-and-a-locations-posts","heading":"A hashtag's and a location's posts","level":2,"markdown":"`GET /hashtags/:id/inbounds` and `GET /locations/:id/inbounds` list what is\ntied to a hashtag or a location, in one of two orders, by `sort`:\n\n| `sort` | Order |\n| --- | --- |\n| `recent` | Newest first. The web app's first tab, and its default |\n| `relevant` | What people have found useful lately, as search's [Most relevant](https://www.anahita.io/docs/services/search/#most-relevant) ranks it, without the words: likes, saves, replies and quotes, each fading by the operator's half-life, citations, and a bonus for what the viewer follows, with the viewer's own leanings. Newest first among equals |\n\n`popular`, an all-time sum of a post's counts, is still taken, and the web\napp no longer asks for it: an old post that did well stayed on top for good."},{"id":"what-the-web-app-does","heading":"What the web app does","level":2,"markdown":"- **While typing a hashtag**, in the short box, a reply and long content,\n  hashtags in use that begin with what was typed are offered under the line\n  being written, or above it when there is no room (on a phone, the\n  keyboard takes the bottom of the screen). They float over the page, so\n  nothing under them moves.\n  - On a phone: a row of chips; one tap puts one in.\n  - On a desktop: a short list with how many posts each is on; the arrow\n    keys move through it, Enter or Tab puts one in, Escape puts it away.\n- **Under the long content editor**, a row of suggested hashtags made from\n  the title and content, asked for a moment after writing stops once there\n  are 80 characters or more. One tap adds a hashtag at the end, on the line\n  of hashtags the text ends with, or on a line of its own.\n- **At ten hashtags**, both say \"Only the first 10 hashtags count\" instead\n  of offering more.\n- **At five locations**, a post's Locations card says a post can have up to 5\n  locations in place of its add button."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"- `anahita-libs/constants/tags.go`: the two caps.\n- `hashtag-service/cmd/api/handlers/hashtag_events.go`: filing a post under\n  its hashtags.\n- `hashtag-service/cmd/api/handlers/suggest.go`: the two suggestion\n  requests, and the words of a text (`wordsOf`).\n- `graph-grpc-service/data/repositories/hashtag_suggestions.go`: which\n  hashtags are offered, and in what order (`HashtagService.Suggest` in\n  `proto/graphpb/hashtag.proto`).\n- `location-service/cmd/api/handlers/inbound.go`: the cap on locations.\n- `anahita-libs/federation/content.go` and `atproto-service/bsky/outgoing.go`:\n  the cap in what is sent to other servers."}]},{"source":"services","slug":"post-language","title":"The language of a post","group":"Posts","url":"https://www.anahita.io/docs/services/post-language/","sections":[{"id":"","heading":null,"level":1,"markdown":"Every post can say which language it is written in. This page covers what is\nstored, how it is chosen, and what uses it.\n\nThe routes that take a language are in the API reference: [text-service](https://www.anahita.io/api/text/), [photo-service](https://www.anahita.io/api/photo/) and [video-service](https://www.anahita.io/api/video/)."},{"id":"what-it-is-for","heading":"What it is for","level":2,"markdown":"- **Screen readers.** A French post on an English page is read with French\n  pronunciation, because its text is marked with its language.\n- **Other fediverse servers.** Mastodon and Bluesky both file a post under its\n  language, and their users filter by it. Posts sent from here arrive\n  tagged with theirs.\n- **Custom feeds** can keep to posts in one language.\n- **Later:** a translate button, which needs to know what it is translating\n  from.\n\nThe author chooses. **Nothing guesses a language.** A post whose author did\nnot say has none, and that is deliberate: other servers show an untagged post\nto everybody, while a wrongly tagged one is hidden from the people who could\nread it.\n\nOne language per post. A post that mixes two is tagged with whichever the\nauthor picks."},{"id":"the-tags","heading":"The tags","level":2,"markdown":"BCP 47, kept simple: a language, and optionally a region.\n\n| Tag | |\n| --- | --- |\n| `en`, `fr`, `fa` | A language, by its two-letter ISO 639-1 code |\n| `fr-CA`, `pt-BR`, `es-419` | A language as written in a region |\n| `yue` | A three-letter code, for a language with no two-letter one |\n| `und` | Undetermined: the author would rather not say |\n\nA tag is stored in its usual spelling whatever case it arrived in (`FR-ca`\nbecomes `fr-CA`). A two-letter code that is not a language, a language's name\ninstead of its code, or anything longer than a language and a region is\nrefused with 400 `language_invalid`.\n\nThe rules are in `anahita-libs/language`."},{"id":"on-a-post","heading":"On a post","level":2,"markdown":"`language` is accepted when a note, article, topic, photo or video is created\n(`POST /notes/:owner_id/` and the others; for a video,\n`POST /videos/:owner_id/uploads`), when a reply is posted\n(`POST /…/:id/replies`), and when a post is edited (`PATCH /…/:id`). It is optional. Left out when creating, the post has no\nlanguage; left out when editing, it keeps the one it has.\n\nEvery post in a response carries `language` when it has one.\n\nIt is stored in `nodes.language`, which the PHP application's schema already\nhad and nothing wrote to."},{"id":"on-a-person","heading":"On a person","level":2,"markdown":"A person can say which language they usually post in: `language` on\n`PATCH /people/:id`. It is the same column on the person's own node, and it is\nwhat a composer should start on.\n\nThe signed-in viewer is told theirs: `/oauth/userinfo` carries `language` when\nit is set, and leaves it out when it is not.\n\nThis is the language somebody **writes** in. The language the interface is\nshown in is a separate thing, chosen in the web app."},{"id":"what-the-web-app-does","heading":"What the web app does","level":2,"markdown":"- **A language button in the composer**, beside the audience button, showing\n  the code (`EN`). It opens a list of about sixty languages, named in the\n  language the app is being read in.\n- **Where it starts**, in order: the posting language on their profile; the\n  browser's language, which is the computer's; English. A language chosen\n  for one post applies to that post only.\n- **Posts are marked with their language** where they are drawn, so a screen\n  reader reads each in the right one.\n- **Profile settings** have \"Language you post in\". Left on \"My browser's\n  language\", nothing is stored."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The tags | `anahita-libs/language/language.go` (`Normalize`), tested in `language_test.go` |\n| Reading a request's language, 400 `language_invalid` | `anahita-libs/handlers/language.go` (`RequestLanguage`, `RefuseLanguage`) |\n| On a post | `text-service/cmd/api/handlers/`, `photo-service/cmd/api/handlers/photo.go`, `video-service/cmd/api/handlers/video.go` |\n| Where it is stored | table `nodes`, column `language`; `graph-grpc-service/data/repositories/nodes_add.go`, `nodes_edit.go` |\n| On a person | `actor-service/cmd/api/handlers/person.go` |\n| In `/oauth/userinfo` | `auth-service/data/services/oauth_user_info.go` |\n| Sent to other servers | `activitypub-service/ap/note.go` (`NewNote`), `atproto-service/bsky/outgoing.go` (`NewOutPost`) |\n| Custom feeds in one language | `anahita-libs/feeds/definition.go`, `graph-grpc-service/data/repositories/feed_custom.go` |\n| The web app | `src/components/LanguageButton.jsx`, `src/utils/postLanguage.js` (anahita-web) |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"Decided on 2026-10-05, when the fuller plan was weighed against what uses a\nlanguage today:\n\n- **Detecting the language from the text**, as a fallback when a client sends\n  none or as a \"looks like French\" suggestion in the composer.\n- **Tagging posts written before this existed.** They have no language. It\n  could only be guessed, and guessing is what the point above leaves out.\n- **Changing a post's language in the web app** after it is posted. The API\n  accepts it; the edit form does not offer it yet.\n- **An index on the column.** Custom feeds filter by language, without\n  one; it is worth adding when feeds that do grow slow.\n- **More than one language per post.** Bluesky allows three.\n\nThey belong with federation, a translate button or custom feeds, whichever\nfirst needs them."}]},{"source":"services","slug":"feeds","title":"Custom feeds","group":"Posts","url":"https://www.anahita.io/docs/services/feeds/","sections":[{"id":"","heading":null,"level":1,"markdown":"A custom feed is a feed somebody builds: where its posts come from, what they are about, and what to leave out. They name it, decide who else may see it, and keep it among the tabs of their home page. Posts in every feed are newest first.\n\nIts routes are in the API reference: [feed-service](https://www.anahita.io/api/feed/)."},{"id":"the-home-page","heading":"The home page","level":2,"markdown":"The first tab of somebody's home page is always the feed of the people, groups and events they follow, newest first (`GET /feeds/leaders/`). It is not a custom feed and cannot be removed or reordered. Custom feeds are the tabs after it.\n\nEvery feed orders its posts by date, newest first. (Search is the one place with another order, \"Most relevant\": see [Search](https://www.anahita.io/docs/services/search/).) A feed ordered by weights of its owner's choosing is planned as one more kind of custom feed, and would be named by whoever made it like any other. See the plan, `docs/plans/done/feed-generators.md`."},{"id":"a-feed-is-a-node","heading":"A feed is a node","level":2,"markdown":"- Type `node.feed.feed-service.generator.v1`, owned by the person who made it (`owner_id`), with its name in `name`, what it is for in `body`, and its audience in `access`.\n- The node's `meta` is JSON holding what it shows, the `definition`, and its `icon`: one of sixteen pictures, by name (`feeds.Icons`), with `feed` for one given none. Meta that is a definition alone, as the first feeds were kept, is read as that.\n- Keeping a feed on the home page is an edge from the person to the feed, `edge.feed.pin.v1`, with its place among the tabs in the edge's `ordering`.\n- There is no table of its own and no migration. Deleting a feed deletes its node and, with it, every edge that kept it on somebody's home page. Removing an account removes its feeds as it removes everything else it owns."},{"id":"the-definition","heading":"The definition","level":2,"markdown":"```json\n{\n  \"sources\": {\n    \"following\": true,\n    \"actors\": [123, 456]\n  },\n  \"filters\": {\n    \"actor_types\": [\"group\"],\n    \"media_types\": [\"note\", \"photo\"],\n    \"hashtags_any\": [\"permaculture\", \"seeds\"],\n    \"keywords_any\": [\"compost\"],\n    \"locations_any\": [42],\n    \"language\": \"en\",\n    \"near\": { \"latitude\": 49.283, \"longitude\": -123.121, \"radius_km\": 10 },\n    \"origin\": \"elsewhere\"\n  },\n  \"exclude\": {\n    \"hashtags\": [\"ad\"],\n    \"keywords\": [\"giveaway\"],\n    \"actors\": [789]\n  },\n  \"include_replies\": false,\n  \"order\": \"newest\"\n}\n```\n\n**Sources** are added together.\n\n| Key | Posts from |\n| --- | --- |\n| `following` | Whoever the feed's owner follows: people, groups and events. The owner's, whoever is reading |\n| `actors` | Profiles chosen by hand, of any kind |\n| neither | The whole site, posts from the fediverse and Bluesky kept here included, which is allowed only with a hashtag, a keyword, a location or an area among the filters |\n\nThe web app offers this as one choice of three: **Everyone** (neither), **Profiles I follow** (`following`), or **Chosen profiles** (`actors`, with \"And profiles I follow\" for both). A new feed starts from the profiles its owner follows, so that it shows something; given a hashtag, a keyword, a location or an area before the owner chose, it switches to Everyone, since \"a #dweb feed\" almost always means everybody's.\n\n**Filters** narrow the sources. A post must match every kind of filter given; within a list, matching one entry is enough.\n\n| Key | Keeps |\n| --- | --- |\n| `actor_types` | Posts on these kinds of profile: `person`, `group`, `event`. `person` includes people on the fediverse and on Bluesky. Empty for any |\n| `media_types` | These kinds of post: `note`, `article`, `topic`, `photo`, `video`. Empty for every kind |\n| `hashtags_any` | Posts carrying any of these hashtags |\n| `keywords_any` | Posts with any of these words in the title or the text, in any case |\n| `locations_any` | Posts tagged with any of these locations, by id |\n| `language` | Posts their author marked as written in this language |\n| `near` | Posts within `radius_km` (1 to 500) of a point, `latitude` and `longitude`: tied to a location there, or carrying a point of their own there. Narrows as a location does. With `\"live\": true` the point is wherever whoever reads the feed is: none is kept, and each page is read with `near_latitude` and `near_longitude` (see below). Otherwise the point is kept with the feed, as the web app took it from the browser when it was made, to three decimals |\n| `origin` | Where a post was written: `here`, on this installation, or `elsewhere`, on the fediverse or Bluesky. Left out for anywhere |\n\n**Exclude** leaves posts out whatever else is true of them: by hashtag, by keyword, and by profile. An excluded profile's posts are left out wherever they were posted, and so is everything posted on it.\n\n**`include_replies`** is off unless asked for: a reply is read in its thread. **`order`** is `newest`, the one value there is. The key is kept so that another order is a value added and not a change of shape.\n\nA key the code does not know is refused, at any depth. A definition is tidied when it is read: lists lose their repeats and blanks, a hashtag loses its `#` and is lower-cased, and a keyword is trimmed, with each run of spaces inside it made one."},{"id":"a-feed-of-what-is-near-its-reader","heading":"A feed of what is near its reader","level":3,"markdown":"A feed whose area is live (`\"near\": {\"live\": true, \"radius_km\": 10}`) is\nread around wherever its reader is, which they send with each page:\n`GET /feeds/:id/items?near_latitude=49.283&near_longitude=-123.121`. The\npoint is rounded to three decimals, about a hundred metres, used for that\npage, and kept nowhere; whoever reads a shared feed of this kind sees what is\nnear them. Read without a point it is answered `422` with\n`location_needed`. A point saved with a live area is dropped. The web app\nasks the browser for the point each time, a reading up to five minutes old\nwill do, and says why when the browser will not tell it."},{"id":"limits","heading":"Limits","level":2,"markdown":"| What | Most |\n| --- | --- |\n| Profiles in `sources.actors`, and in `exclude.actors` | 50 each |\n| Hashtags, in the filter and left out | 10 each |\n| Keywords, in the filter and left out | 10 each, of 2 to 50 characters |\n| Locations | 5 |\n| An area's radius | 1 to 500 km |\n| Feeds one person owns | 10 |\n| Feeds on one person's home page | 10 |\n| Posts in a page | 20 by default, 40 at most |\n\nA definition that breaks a rule is answered `422` with the reason as a word: `too_broad`, `too_many_actors`, `too_many_hashtags`, `too_many_keywords`, `too_many_locations`, `keyword_length`, `invalid_hashtag`, `unknown_media_type`, `unknown_actor_type`, `invalid_actor`, `invalid_location`, `invalid_language`, `invalid_near`, `unknown_origin`, `unknown_order`, `unknown_field`, `malformed`."},{"id":"who-sees-a-feed-and-what-they-see-in-it","heading":"Who sees a feed, and what they see in it","level":2,"markdown":"These are two questions, and they have different answers.\n\n**Who may see the feed itself** is its `access`: `myself` (the default), `followers` (people who follow its owner), `registered` or `public`. It is judged as a post's is, by its own access and its owner's, and a block either way hides it. A feed somebody may not see is answered as if it were not there. An installation that takes no new public content (`MIN_CONTENT_ACCESS`) takes no new public feed.\n\n**What a reader sees in it** is what the definition selects, put through the same gate as every other feed, for the reader. Not for the feed's owner. So:\n\n- A public feed built on a group only its followers can read shows that group's posts to its followers and to nobody else.\n- \"Whoever I follow\" in a feed is whoever its owner follows, and a reader is shown, of those people's posts, the ones they could have seen on those people's profiles.\n- A post by somebody who has blocked the reader, or whom the reader has blocked, is not in it.\n- What is deleted, disabled or archived is not in it.\n\nA feed never widens what anybody may see. This is the rule the tests in `feed_custom_integration_test.go` are there to keep.\n\nWhoever may see a feed may read it, copy it and keep it on their home page. Only its owner, or a site administrator, may change or delete it."},{"id":"reading-a-feed","heading":"Reading a feed","level":2,"markdown":"`GET /feeds/<id>/items` returns a page: `data`, and `pagination` with `limit`, `has_more` and `next_cursor`. The next page is asked for with `?cursor=<next_cursor>`.\n\nA page starts after the last post of the page before, not at a count from the top. The cursor is the date and id of the last post of the page before, and the next page is whatever is older. A post written while somebody is reading neither repeats a post nor pushes one out of sight. A feed has no total: counting it would be reading all of it.\n\nA query that cannot finish in the time a query is given is answered `422` with `too_broad`."},{"id":"keeping-feeds-on-the-home-page","heading":"Keeping feeds on the home page","level":2,"markdown":"`PUT /feeds/<id>/pin` adds a feed after the ones already there, and `DELETE` takes it off. `PUT /feeds/pins` with `{ \"ids\": [...] }` replaces the whole list in that order, which is how the tabs are reordered. `GET /feeds/pinned` returns them in order.\n\nA feed is put on its maker's home page when it is made, or copied, after the ones there. A person owns as many feeds as the home page holds, so there is room unless they keep other people's feeds there. With the home page full the feed is made all the same, and waits on the list of their feeds.\n\nKeeping somebody else's feed on your home page is how a feed is followed. There is no separate following of feeds. A feed that is deleted, or that its owner stops sharing, drops out of the list.\n\nFollowing a hashtag is the same thing: the web app makes a feed of that one hashtag, seen by its owner alone, and puts it on their home page."},{"id":"in-search","heading":"In search","level":2,"markdown":"Search finds shared feeds like anything else: `GET /search/` with `scope=all` has them among the profiles and posts that match, and `scope=feeds` has them alone. A feed matches by its name and by what it is for. One kept to its owner is never found, by its owner either. Each is gated for the viewer as a post is, by its own audience and its owner's.\n\nA feed among search results is a node like the others, with a `feed` block: its `icon` and its `definition`."},{"id":"discover","heading":"Discover","level":2,"markdown":"The web app's search finds shared feeds by their words. This endpoint lists them without any: it is the **Feeds** tab of the web app's Recommendations page.\n\n\n`GET /feeds/discover` lists the feeds people have shared, newest first: public ones, ones for anyone signed in, and ones for the followers of somebody the viewer follows. Each is gated for the viewer by its own audience and its owner's, as a post is. A feed kept to its owner is in nobody's list, its owner's included; the viewer's own shared feeds are in theirs, as everybody else sees them.\n\nThe list is of feeds, not of posts: opening or adding one shows its reader only what they could see anyway."},{"id":"endpoints","heading":"Endpoints","level":2,"markdown":"All on feed-service. Writing needs the `media:write` scope.\n\n| Endpoint | Who | What |\n| --- | --- | --- |\n| `POST /feeds/` `{ name, body, access, icon, definition }` | signed in | Makes a feed, and puts it on the home page. `access` is `myself` unless given |\n| `GET /feeds/mine` | signed in | The feeds the viewer made |\n| `GET /feeds/pinned` | signed in | The feeds on the viewer's home page, in order |\n| `GET /feeds/discover?q=&start=&limit=` | anybody | The feeds people have shared that the viewer may see, newest first. `q` is part of a name. Paged by `pagination.next_start` |\n| `PUT /feeds/pins` `{ ids }` | signed in | Replaces the feeds on the home page |\n| `POST /feeds/preview` `{ definition }` | signed in | The first page of a feed not saved yet, as the viewer's own |\n| `GET /feeds/<id>` | whoever may see it | The feed, with `references`: the profiles and locations it names that the viewer may see |\n| `PATCH /feeds/<id>` | its owner | Changes it |\n| `DELETE /feeds/<id>` | its owner | Deletes it |\n| `GET /feeds/<id>/items?cursor=&limit=` | whoever may see it | A page of its posts |\n| `POST /feeds/<id>/copy` `{ name }` | whoever may see it | A feed of the viewer's own showing the same thing, seen by them alone. Called `name`, or \"Copy of\" the other's name |\n| `PUT`, `DELETE /feeds/<id>/pin` | whoever may see it | Keeps it on the home page, or takes it off |\n\nA feed is sent with its `icon`, `is_pinned` and `authorized` (`edit`, `delete`, `pin`, `copy`)."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The definition, its rules and limits, and the cursor | `anahita-libs/feeds` (`Parse`, `Check`). feed-service checks a definition with it when a feed is saved, and graph-grpc-service reads it again with the same code before building a query, so nothing reaches the database that was not read into its fields |\n| The query | `graph-grpc-service/data/repositories/feed_custom.go`. Every value is a parameter. It ends with `SetFeedAccessQuery`, `SetLifecycleQuery` and `SetArchiveQuery`, for the reader |\n| That a feed never widens what anybody may see | `graph-grpc-service/data/repositories/feed_custom_integration_test.go` |\n| A feed's own visibility | `graph-grpc-service/data/repositories/nodes_visible.go` (`FilterVisible`) |\n| The endpoints | `feed-service/cmd/api/handlers/custom_feed.go`. `custom_feed_test.go` runs them over a graph held in memory: who may see, change, copy and delete a feed, the limits, and the home page |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- **An order other than newest first.** See [The home page](#the-home-page).\n- **Reposts in a custom feed.** A custom feed holds posts, and replies when asked for. The feed of people you follow holds reposts as it always did.\n- **\"Posts by the followers of a group\"** as a source, which the plan had as `members_of`. A feed of the groups somebody follows is `following` with `actor_types: [\"group\"]`.\n- **\"With images\"** as a filter. `media_types: [\"photo\"]` is the photo posts.\n- **A cache of a public feed's first page.** Nothing is slow enough to need one yet.\n- **Feeds made for a new account.** The feed of people you follow is the first tab, and the rest are for each person to make.\n- **Emptying a feed's description.** A description can be changed and cannot be set back to nothing: the graph's node edit leaves an empty text as it was.\n- **Feeds across the fediverse.** A custom feed is a feature of the installation and is not sent to other servers. Posts from people followed on other servers appear in one like any others, and a feed can name such people among its profiles."}]},{"source":"services","slug":"search","title":"Search","group":"Posts","url":"https://www.anahita.io/docs/services/search/","sections":[{"id":"","heading":null,"level":1,"markdown":"Search finds people, groups, events, posts and shared feeds by what they are\ncalled or what they say. This page is about the order results come in, and\nhow an operator tunes it.\n\nIts route is in the API reference: [search-service](https://www.anahita.io/api/search/)."},{"id":"two-orders","heading":"Two orders","level":2,"markdown":"The search page offers two: **Most relevant**, the default, and **Most\nrecent**, newest first. Most recent is only that. The rest of this page is\nabout Most relevant, which also orders a hashtag's and a location's posts\n([hashtags.md](https://www.anahita.io/docs/services/hashtags/#a-hashtags-and-a-locations-posts)), without the\nwords."},{"id":"most-relevant","heading":"Most relevant","level":2,"markdown":"Results are ordered by three things, one after the other. The second only\ndecides between results the first puts level, and the third between those\nthe second puts level.\n\n1. **Where the words are.**\n\n   | Where | Points by default |\n   | --- | --- |\n   | The handle exactly (`rastin.example`, `ana`), with or without the `@` typed before it | 100 |\n   | The name exactly | 90 |\n   | The name begins with them | 70 |\n   | A word in the name begins with them (\"Jericho **Bea**ch\") | 60 |\n   | Somewhere inside the name, or inside the handle (`decaf`**`coffee`**`club`) | 50 |\n   | A hashtag on the post, or, on a photo or a video, the name or the handle of somebody in it, their tag approved | 40 |\n   | A whole word in the body | 20 |\n   | Only inside a word in the body (\"**coffee**house\") | 10 |\n\n   The first that holds is the one counted. Only the order of the points\n   matters: a profile called *Coffee* comes before a post that mentions\n   coffee however much the post is liked.\n\n2. **Its standing:** how useful people have found it, and whether the\n   searcher follows it.\n\n   ```\n   standing = scale × ln(1 + like × likes + save × saves\n                           + reply × (replies, reposts and quotes)\n                           + follow × followers\n                           + cite × citers)\n            + following, when the searcher follows it or the profile it is on\n            + pictured_with_followed, when somebody the searcher follows is\n              in it, or it is a person in public photos or videos with them\n   ```\n\n   **Pictured with whom the searcher follows** (roadmap row 104): a photo\n   or a video with somebody they follow in it, or a person in public\n   photos or videos with them, tagged or as the one who posted it.\n   Approved tags only. A person's own \"following\" step moves it with\n   following, and \"Profiles I follow first\" puts it after what they follow\n   themselves.\n\n   The logarithm keeps one post liked a thousand times from burying\n   everything: it comes first, not a hundred times first. Each like, save,\n   reply or follow counts by when it happened (below).\n\n   **Citers** are the people who show it within an article or a topic of\n   their own, each once however many of their posts do, and never its own\n   author; a profile's are those of its posts. Showing a profile is naming\n   it, a mention, and not a citation: `nodes.citation_count`, kept in step by the\n   graph (`repositories/citations.go`). Only a published post anybody may\n   read counts as citing. A citation counts as it stands, not halved with\n   time: citing is deliberate and lasting, and useful knowledge stays\n   useful. `cite` starts at 3, above a like (1) and a save (2), since citing\n   takes effort; it is a starting value, to be tuned once there are\n   citations to look at.\n\n3. **Its id,** the newest first, so that results level on everything keep\n   their places from one page to the next.\n\nWith a distance chosen, the nearest come first among results level on the\nfirst two."},{"id":"old-and-useful-counts-as-much-as-new","heading":"Old and useful counts as much as new","level":2,"markdown":"Anahita is for building knowledge as well as for news: a school's notes, a\nlibrary's guides, a research group's findings stay useful for years. So\nnothing is pushed down for being old. What may fade is **interest**: each\nlike, save, reply or follow is counted by when it happened, halved every\n`half_life_days`.\n\n- A guide written years ago that people saved this month stands as high as\n  a post from this week that was saved as often.\n- A post nobody has touched in a long while weighs only its old interest.\n- `half_life_days: 0` lets nothing fade: interest counts the same however\n  old.\n\nBy default it is 365 days. A newsroom may want a month; a library, a\nschool or a research group a year or more, or 0."},{"id":"tuning-it","heading":"Tuning it","level":2,"markdown":"Every number above is the operator's, in\n`graph-grpc-defaults/search_ranking.json`:\n\n```json\n{\n  \"match\": {\n    \"handle\": 100, \"name\": 90, \"name_starts\": 70, \"word_starts\": 60,\n    \"name_contains\": 50, \"hashtag\": 40, \"body_word\": 20, \"body_contains\": 10\n  },\n  \"following\": 15,\n  \"pictured_with_followed\": 8,\n  \"useful\": { \"scale\": 5, \"like\": 1, \"save\": 2, \"reply\": 2, \"follow\": 1, \"cite\": 3 },\n  \"half_life_days\": 365\n}\n```\n\n- graph-grpc-service reads it from `SEARCH_RANKING_PATH`, which is\n  `/app/defaults/search_ranking.json` in the image. docker-compose mounts\n  the working tree's copy there. On Kubernetes, mount a ConfigMap over that\n  path to change it.\n- **It is read again when it changes,** without a restart.\n- **What it leaves out keeps its default,** so a file with only\n  `{\"half_life_days\": 30}` changes that and nothing else.\n- **A file that cannot be read, or has a number below 0 or above 1000, is\n  not taken:** the default weights are used, and the service says so in\n  its log."},{"id":"each-persons-choices","heading":"Each person's choices","level":2,"markdown":"Two, off as they come, sent with a search as `followed_first=true` and\n`recent_first=true` and only with \"Most relevant\":\n\n- **Profiles I follow first:** what the searcher follows, or what\n  is on a profile they follow, before everything else, however it matches.\n  Nothing changes for a visitor, who follows nobody.\n- **Newer results first:** among results that match alike, the newest first, and\n  only then by standing. The match still comes first: a profile called\n  *Coffee* is still above a post that mentions coffee.\n\nThe web app keeps them in the searcher's browser, per person."},{"id":"their-own-weights","heading":"Their own weights","level":3,"markdown":"Each person who is signed in can also change how much each sign counts for\nthem, as they can for their suggestions (the \"Adjust\" chip by \"Most\nrelevant\"). There are six: likes, saves, replies, followers, citations,\nand who or what they follow (the people, groups and events, and what is\nposted on them). Each is set to a step that multiplies the operator's\nweight:\n\n| Step | Multiplies by |\n|---|---|\n| Off | 0 |\n| Less | 0.5 |\n| As is | 1 |\n| More | 1.5 |\n| Much more | 2 |\n\nSo with the default file, a citation set to Much more counts 6 for that\nperson, and a like set to Off counts nothing. The match always comes\nfirst; the steps only change the order among results that match alike.\n\n- `GET /search/preferences` answers with the person's steps, which may be\n  set (`adjustable`) and to what (`options`). A name not in `steps` is As\n  is.\n- `PUT /search/preferences` with `{\"steps\": {\"cite\": 2, \"like\": 0}}`\n  replaces them all. A name that may not be set, or a value that is not a\n  step, is refused with 422 `invalid_weight`, and nothing changes.\n- They are kept on the server, in `search_preferences`, so they follow the\n  person to any device. Only steps other than As is are stored, and the\n  row is removed when the profile is purged.\n- The names and steps are `constants.SearchAdjustable` and\n  `constants.SearchSteps` in anahita-libs. The graph applies them with\n  `ranking.Weights.Adjusted`."},{"id":"what-is-found-at-all","heading":"What is found at all","level":2,"markdown":"The order does not change what is found. A result has the words in its\nname, its handle (matched without a typed `@`), or its body, or is a photo\nor a video with somebody of that name or handle in it, and is one the\nsearcher may see; see [Permissions](https://www.anahita.io/docs/services/permissions/).\n\n**Where it looks** (`scope` on `GET /search/`), each a tab in the web app:\n\n| `scope` | Finds |\n| --- | --- |\n| `all` (the default) | Everything below |\n| `posts` | Every kind of post: notes, articles, topics, photos and videos |\n| `media` | Photos and videos alone, as other networks mean media (the user, 2026-10-20) |\n| `actors` | Profiles: people, groups, events and websites, here and elsewhere |\n| `feeds` | Custom feeds people have shared |\n\nUntil 2026-10-20 `media` was every kind of post; it is `posts` now.\n\n**What may be searched for** (`search_query`, `anahita-libs/validators`):\nletters, marks and numbers in any language (\"Montréal\", \"Zürich\", \"東京\"),\nspaces, and what people type in a name, a handle or an address: `-` `_` `.`\n`,` `+` `#` `@` `\"` `'` `’` `:` `/` `?` `&` `=` `~` `!` `(` `)`. Up to 100\ncharacters. It refused everything but unaccented Latin letters until\n2026-10-19. A search among names (`name_search_query`, the lists of a\nprofile's followers) takes the same letters with `-` `_` `.` `+` `\"` `'` `’`.\nNone of it is written into SQL: the words are bound parameters, `LIKE`'s\nwildcards are taken out, and a regular expression made of them is quoted."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"- `graph-grpc-service/data/repositories/search.go`: what is found\n  (`wordsMatch`), the two scores (`relevance`), and the order, ranking the\n  page's ids before reading their rows (`browseByRelevance`).\n- `graph-grpc-service/ranking`: the weights, the file, reading it again, and\n  each person's steps (`Adjusted`).\n- `graph-grpc-service/data/repositories/search_preferences.go` and\n  `search-service/cmd/api/handlers/preferences.go`: each person's steps,\n  kept and given.\n- `graph-grpc-service/data/repositories/search_ranking_integration_test.go`:\n  the order checked against MySQL, with searches as people make them."},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- **An index of words.** Search matches with `LIKE '%…%'`, which reads every\n  row and knows nothing of words or languages. MySQL's `FULLTEXT` index with\n  the `ngram` parser was tried and put off: it missed posts that `LIKE`\n  finds (two-letter stopwords, text indexed before the index existed,\n  Japanese). Search is fast enough without it at today's sizes; a search\n  engine beside the database is the likelier answer when it is not.\n- **Ranking by the citers' own standing,** as Google ranks the web, where\n  a citation from what is itself much cited counts for more. Citations\n  count today each as one (above); weighing them by who cites is for when\n  there are enough to tune it against. See the web app's\n  `docs/plans/done/search-improvements.md`."}]},{"source":"services","slug":"federation","title":"Federation","group":"Fediverse and Bluesky","url":"https://www.anahita.io/docs/services/federation/","sections":[{"id":"","heading":null,"level":1,"markdown":"How people here take part in the fediverse (ActivityPub: Mastodon and the\nservers like it) and in Bluesky (the AT Protocol), and what an installation\nhas to do for that to happen.\n\nIts routes are in the API reference: [activitypub-service](https://www.anahita.io/api/activitypub/) and [atproto-service](https://www.anahita.io/api/atproto/).\n\n**This is being built.** What is described here is what works today: a\nperson can choose to be found, people follow each other across servers\neither way round, their public notes travel with the follow, a person can\nleave again, a super administrator can refuse a server, and the web app has the\nswitch, the lookup, and shows people and posts from other servers.\nReplies, likes and reposts cross both ways. Posts cross with their\npictures, and arrive with a card for the link in them.\n\nBluesky is read for everybody, and written to for whoever connects the\nBluesky account they already have. See [Bluesky](#bluesky)."},{"id":"what-it-is","heading":"What it is","level":2,"markdown":"Mastodon, GoToSocial, Pixelfed and many others speak one protocol,\nActivityPub, and somebody on any of them can follow somebody on any other.\n`activitypub-service` makes this installation one of them. It is a service\nof its own because it is the one that reads what the internet sends it, and\nbecause an installation may not want it at all.\n\nBluesky speaks a different protocol, and is a different service:\n`atproto-service`, which reads Bluesky for everybody here, and writes to it\nfor whoever connects the Bluesky account they already have. See\n[Bluesky](#bluesky)."},{"id":"a-server-on-one-network-an-app-on-the-other","heading":"A server on one network, an app on the other","level":2,"markdown":"Anahita takes part in the two networks in two different ways, and what\npeople own, and where, follows from that.\n\n| | ActivityPub (Mastodon and the fediverse) | AT Protocol (Bluesky) |\n| --- | --- | --- |\n| Anahita is | a server, as a Mastodon server is | an app, as a Bluesky client is, with a small reader of its own |\n| Somebody's identity there | `@name@your-site.example`, made by Anahita when they turn federation on | the Bluesky account they already have, which they connect |\n| Who holds the keys that sign for them | Anahita, encrypted | their own Bluesky server; Anahita keeps only a token to post as them, encrypted |\n| Needed to take part | turning it on in their settings | a Bluesky account of their own |\n| Where the original of what they post lives | here | here, with a copy of their public posts in their Bluesky repository |"},{"id":"on-the-fediverse-a-server","heading":"On the fediverse: a server","level":3,"markdown":"Anahita is an ActivityPub server in its own right. Somebody who turns\nfederation on becomes `@name@your-site.example`: other servers find them by\nWebFinger, read their profile from Anahita at an address on the site's\ndomain, and deliver follows, replies, likes and reposts to its inbox, each\nrequest signed and checked. Anahita delivers their public posts, replies,\nlikes, reposts and follows to their followers' servers itself, signed with\na key it made for them.\n\nNobody needs an account anywhere else. The identity belongs to the site's\ndomain, as on any fediverse server: if the site goes, or changes its\ndomain, the identities go with it (moving an account to another server,\nActivityPub's `Move`, is not built). Other servers keep copies of what they\nreceived, as they do of any federated post; deletions are sent to them, and\nno server can be made to honour one."},{"id":"on-bluesky-an-app","heading":"On Bluesky: an app","level":3,"markdown":"On the AT Protocol each person's posts are records in their own repository,\nkept by their personal data server (PDS) and tied to their identity (a DID),\nwhich they can move from one server to another. Anahita is not a PDS. It\nhosts nobody's repository and gives nobody a Bluesky identity:\n\n- **Writing, as a client.** For somebody who connects their account,\n  Anahita signs in to their own PDS and writes their public notes and photo\n  posts there, and their replies, likes, reposts and follows, as Bluesky's\n  own app would. Their PDS signs the records and keeps them.\n- **Reading, as a small indexer.** It listens to Jetstream for the accounts\n  people here follow, reads profiles and posts from Bluesky's public\n  AppView, and keeps what it finds in its own database.\n\nSomebody with no Bluesky account is not on Bluesky through Anahita. Running\na PDS beside Anahita, so that members get an identity under the site's\ndomain, is written down for later (`docs/plans/atproto-server.md`)."},{"id":"who-owns-what","heading":"Who owns what","level":3,"markdown":"- **Everything posted here is kept here,** in the installation's database\n  and storage, run by whoever runs the site. That is the fediverse's sense\n  of owning: choosing one's host, or being it.\n- **A copy of a connected person's public posts goes to Bluesky.** Each\n  picture is uploaded, made small enough for Bluesky's limit of two\n  megabytes,\n  to their PDS: Bluesky's own hosting for most people, or their own server\n  if they run one. Those copies are theirs in the AT Protocol's sense: in\n  their repository, under their identity, moving with it. From there\n  Bluesky's relay and its image and video services spread and cache them,\n  and anybody reading the public firehose may keep a copy.\n- **What a post names on Bluesky is a file, not a server.** A picture is\n  named by its content's hash and the account it belongs to; the original\n  is fetched from that account's PDS. Bluesky's app shows its own resized\n  copies from its image servers (`cdn.bsky.app`) and plays videos from its\n  video service, so most viewing comes from Bluesky's caches even when the\n  original is on somebody's own server.\n- **Deleting a post here deletes it from their Bluesky account,** and\n  Bluesky takes it out of its app. Copies others already took from the\n  public network cannot be called back, there or on the fediverse.\n- **Disconnecting stops new copies and takes nothing back.** What was\n  written to Bluesky stays there, and can no longer be removed from here.\n- **Only what is public goes out,** on either network: followers-only and\n  members-only posts, a site kept to its members, and anybody who did not\n  turn federation on or connect an account send nothing.\n- **What comes in is copied in,** pictures included, into the\n  installation's own storage, so that a reader's browser never asks another\n  server for them. Pictures from elsewhere are removed after a while unless\n  somebody here holds to the post (see\n  [Pictures that are removed](#pictures-that-are-removed)).\n- **Each person can take a copy of everything,** with\n  [Download your data](https://www.anahita.io/docs/services/data-export/), including an archive shaped as\n  ActivityPub has it. Their Bluesky repository is not in it: they can\n  export that from Bluesky itself."},{"id":"groups-events-and-the-rest","heading":"Groups, events and the rest","level":3,"markdown":"What only Anahita has (groups, events, topics, followers-only\nposts, likes and follows between people here, custom feeds) stays here, on\neither network. An app built on the AT Protocol may keep such things as\nrecords of its own kinds in people's repositories; Anahita does not, since\na repository is public and much of this is not."},{"id":"setting-it-up","heading":"Setting it up","level":2,"markdown":"For whoever runs the installation. Federation is off until all of this is\ndone, and after that it is still off **for every person** until they turn\nit on for themselves: sharing with other servers is each person's own\nchoice, and nothing an operator sets makes it for them."},{"id":"1-choose-the-names-for-good","heading":"1. Choose the names, for good","level":3,"markdown":"Two names are told to other servers and kept by them for ever:\n\n| | Example | What it is |\n| --- | --- | --- |\n| The handle domain | `example.org` | The part of `ana@example.org` after the `@`. The host people know the site by, which is the web app's |\n| The API's address | `https://api.example.org` | Where other servers fetch people and posts, and send what they have for people here |\n\n**Change either afterwards and everybody here becomes somebody new on\nevery other server, with no followers.** Settle them before the first\nperson turns federation on. Try things out under a name you will throw\naway, never the one you mean to keep (see step 8)."},{"id":"2-be-a-site-that-can-federate","heading":"2. Be a site that can federate","level":3,"markdown":"- **Reachable over https at those names,** with a certificate other\n  servers trust. [Deploying](https://www.anahita.io/docs/services/deploying/) covers the ingress and\n  cert-manager.\n- **Open to read:** `SITE_READ_ACCESS=public`, which is the default. A site\n  for members, or one that shows visitors a preview, does not federate:\n  federating would send out what it keeps in ([Privacy](https://www.anahita.io/docs/services/privacy/))."},{"id":"3-settings","heading":"3. Settings","level":3,"markdown":"On a site whose web app is at `https://DOMAIN_NAME` and whose API is at\n`https://api.DOMAIN_NAME`, with `ENV=prod`, **nothing has to be set**: the\nnames are worked out. Otherwise say them\n([Configuration](https://www.anahita.io/docs/services/configuration/#site-identity)):\n\n~~~\nPUBLIC_DOMAIN=example.org\nPUBLIC_WEB_URL=https://example.org\nPUBLIC_API_URL=https://api.example.org\n~~~\n\n`activitypub-service` also needs what the other services have, all of it\nalready in `config.env` and `secrets.env`:\n\n| Setting | Why |\n| --- | --- |\n| `DSN` | Its own tables |\n| `GRAPH_SERVICE_ADDR` | People, posts and follows are the graph's |\n| `FILE_SERVICE_ADDR` | The avatars of people on other servers are copied into this installation's storage |\n| `LINK_PREVIEW_URL` | Optional. Where link-preview-service is (`http://link-preview-service`), for the card of a link in a post from another server. Left out, such posts have none |\n| `AMQP_ADDR` | It hears there that somebody here followed, posted or changed their profile |\n| `REDIS_ADDR` | Limits on lookups and on what one address may send |\n| `SECURE_COOKIE_KEY`, `OAUTH_ISSUER` | Knowing who is signed in, for a person's own switch |\n| `CRYPT_BACKEND`, `CRYPT_LOCAL_KEY` | What each person's signing key is kept under. **The same values auth-service has** |\n\nAnd tell NodeInfo, which auth-service serves, that the installation speaks\nActivityPub, so that other servers and the directories that list servers\nknow:\n\n~~~\nFEDERATION_PROTOCOLS=activitypub\n~~~"},{"id":"4-run-it","heading":"4. Run it","level":3,"markdown":"- **The migrations,** as for any upgrade: the migration job adds the\n  service's tables.\n- **The service.** On Kubernetes it is in `k8s/base`\n  (`services/activitypub.yml`); with docker-compose it is started by\n  `make up`. One copy is enough to begin with, and any number may run.\n- **The gateway** already sends it what is its own: `/.well-known/webfinger`,\n  `/.well-known/host-meta`, `/inbox`, `/ap/` and `/activitypub/`. Rebuild the\n  gateway's image with the rest.\n\nAt its start the service says which it is:\n\n~~~\non the fediverse as example.org, reached at https://api.example.org\nthis installation is not on the fediverse: …\n~~~"},{"id":"5-have-the-web-apps-host-send-servers-on-to-the-api","heading":"5. Have the web app's host send servers on to the API","level":3,"markdown":"A server that meets `ana@example.org` asks `example.org`, the web app's\nhost, who that is. That host has to send the question on:\n\n| Asked of the web app's host | Sent on to |\n| --- | --- |\n| `/.well-known/webfinger?…` | `https://api.example.org/.well-known/webfinger?…`, with the same question |\n| `/.well-known/host-meta` | `https://api.example.org/.well-known/host-meta` |\n| `/.well-known/nodeinfo` | `https://api.example.org/.well-known/nodeinfo` |\n\n- **With the web shell,** this is done: it redirects all three\n  ([The web shell](https://www.anahita.io/docs/services/web-shell/#what-other-servers-ask-this-host)).\n- **On a static host,** add three redirects (302, keeping the query). In\n  nginx:\n\n  ~~~nginx\n  location /.well-known/ {\n      add_header Access-Control-Allow-Origin * always;\n      return 302 https://api.example.org$request_uri;\n  }\n  ~~~\n\n**Whatever is in front of the API must pass on the `Host` it was asked\nfor.** The host is part of what every request between servers is signed\nover; a proxy that replaces it makes every signature fail, both ways. The\ngateway and the manifests here pass it on."},{"id":"6-check-it-before-anybody-is-told","heading":"6. Check it, before anybody is told","level":3,"markdown":"From anywhere on the internet:\n\n~~~sh\n# The installation itself, which signs what it asks of other servers.\ncurl -s \"https://example.org/.well-known/webfinger?resource=acct:example.org@example.org\" -L\ncurl -s -H \"Accept: application/activity+json\" https://api.example.org/ap/actor\n\n# What it says it speaks.\ncurl -s https://api.example.org/nodeinfo/2.1 | grep protocols\n~~~\n\nThe first answers with a `subject` and a link to `/ap/actor`; the second\nwith an `Application` that has a `publicKey`. And that it can reach and be\nunderstood by another server, with any account you know of on one:\n\n~~~sh\ndocker compose exec activitypub-service /app/app-binary lookup @someone@mastodon.example\nkubectl exec deploy/activitypub-service -- /app/app-binary lookup @someone@mastodon.example\n~~~\n\nIt prints who was found. This is the one command that tries everything at\nonce: names, certificates, the signature, and the other server's opinion\nof all three."},{"id":"7-the-first-person","heading":"7. The first person","level":3,"markdown":"Signed in, somebody turns it on under **Settings › Privacy › Fediverse**.\nTheir profile has to be open to everybody. Then, from an account on\nanother server, search for their handle, `@ana@example.org`, and follow\nthem. A public note they post arrives there within seconds."},{"id":"8-trying-it-without-going-live","heading":"8. Trying it without going live","level":3,"markdown":"- **On one machine:** [the federation lab](https://www.anahita.io/docs/services/federation-lab/) runs this\n  installation and another server side by side, with names and\n  certificates of their own. Nothing leaves the machine.\n- **Against real servers, before a site is live:** a tunnel from your\n  machine to a hostname you will never use for anything else, with the\n  three `PUBLIC_` settings saying that name. Remember that the servers you\n  try it against keep the people they met under that name. Turning\n  federation off for each of them (below) before the tunnel is closed asks\n  those servers to remove them."},{"id":"keeping-it-running","heading":"Keeping it running","level":2,"markdown":"- **Back up `actor_keys`, and the key it is sealed with.** A person's\n  signing key is what makes them who they are to other servers. Lose the\n  table, or `CRYPT_LOCAL_KEY`, and every person here is somebody whose\n  followers' servers refuse what they send. Both are in the backups an\n  installation cannot do without.\n- **Watch what could not be sent:**\n\n  ~~~sql\n  SELECT target, attempts, last_error, created_at\n  FROM activitypub_deliveries\n  WHERE status = 'failed'\n  ORDER BY created_at DESC;\n  ~~~\n\n  A few are ordinary: servers close. Many to one server means it is down,\n  or refuses this installation. Rows that failed can be deleted when they\n  have been looked at.\n- **Refuse a server** that sends abuse: see\n  [Servers that are refused](#servers-that-are-refused).\n- **The service's log** says why anything that arrived was refused\n  (`inbox: … refused: …`), and what a lookup could not do.\n- **Clocks.** A request dated more than fifteen minutes from now is\n  refused, here and everywhere else. A server whose clock has drifted\n  federates with nobody."},{"id":"when-something-does-not-work","heading":"When something does not work","level":2,"markdown":"| What is seen | Likely why |\n| --- | --- |\n| Nobody here is found by their handle from another server | The web app's host does not send `/.well-known/webfinger` on to the API (step 5). Try the `curl` of step 6 |\n| One person is not found | They have not turned it on; or their profile is not open to everybody; or the other server asked earlier and is remembering the answer |\n| The Fediverse card says the site has no address other servers can reach | The `PUBLIC_` settings, or `DOMAIN_NAME` and `ENV`, say `localhost`, a private address, or `http` (step 3) |\n| The card says the site keeps what is on it to its members | `SITE_READ_ACCESS` is not `public` (step 2) |\n| A follow from another server stays \"requested\" there | This installation's answer did not arrive: look at `activitypub_deliveries`. Or their request was refused: look at the log |\n| Everything arriving is refused with \"the signature is not this key's\" | A proxy in front of the API is replacing `Host`, or rewriting the path |\n| Everything arriving is refused with \"date is missing or too far from now\" | This server's clock, or theirs |\n| `lookup` from the command line answers `lookup_failed` | The other server could not be reached, or refused the signed request. The log line above it says which |\n| A note did not go out | It is not one that does ([Posts going out](#posts-going-out)); or the bus was down when it was posted. Editing it sends it |"},{"id":"who-is-on-the-fediverse","heading":"Who is on the fediverse","level":2,"markdown":"Nobody, until they say so. What is sent to another server cannot be called\nback: it is copied by servers this one has never heard of.\n\nA person is on the fediverse when all of these are so:\n\n- they turned it on for themselves;\n- they are a person, made here, enabled, and not on their way to being\n  deleted;\n- their profile is open to everybody (`public`): not to members only, and\n  not to followers;\n- the graph agrees that a visitor may see them.\n\nThe moment one of them stops being so, they are not found: every address of\ntheirs answers `404`, in the same words as an address nobody has. A server\nis never told *why* somebody is not there.\n\nA **website**, a site's feed connected as a profile ([Websites](https://www.anahita.io/docs/services/websites/)),\nis on the fediverse once one of its administrators turns it on, off until\nthen as a person is, and only while it is enabled and not on its way to\nbeing deleted, on a site that federates. It is an `Organization`, found by\nits handle as a person is.\n\nGroups and events are not on the fediverse yet."},{"id":"what-a-person-is-called","heading":"What a person is called","level":2,"markdown":"| | Example | Made of |\n| --- | --- | --- |\n| Their handle | `ana@example.org` | Their username and `PUBLIC_DOMAIN` |\n| Their address | `https://api.example.org/ap/users/0962789f-…` | `PUBLIC_API_URL` and the node's UUID |\n\nThe handle is what people type. The address is what servers keep, and it is\nmade of the UUID so that it never changes: a username can be changed, and a\nperson who changed theirs must still be the same person to everybody who\nfollows them."},{"id":"what-other-servers-are-told","heading":"What other servers are told","level":2,"markdown":"No more than the person's public profile already shows a visitor.\n\n| Address | Answers |\n| --- | --- |\n| `/.well-known/webfinger?resource=acct:ana@example.org` | Where the person is: their address, and their page, `https://example.org/@ana`, which is also their `url`, the link Mastodon shows for their profile. Also answered for their address and for their page's, and for the older `/people/ana` |\n| `/.well-known/host-meta` | Where WebFinger is, for servers that ask the older way |\n| `/ap/users/<uuid>` | Who they are: name, username, what they say of themselves as HTML that is safe to show, their avatar and cover at [addresses that stay](https://www.anahita.io/docs/services/architecture/#what-a-node-is-called-elsewhere), when they joined, whether a follow waits to be approved, and the public half of their signing key |\n| `/ap/users/<uuid>/followers`, `/following` | How many, and never who |\n| `/ap/users/<uuid>/outbox` | How many posts, and not the posts |\n| `/ap/notes/<uuid>` | One public post, as other servers read it |\n| `/ap/actor` | The installation itself, which signs what it asks of other servers in nobody's name. It has a key and nothing else: nobody follows it and it posts nothing |\n\nWebFinger answers for a handle on the API's host as well as on the handle's\nown domain (`ana@api.example.org` as well as `ana@example.org`), and says in\nits answer which is the real one. A server that first met somebody by their\naddress asks the host in that address who they are."},{"id":"looking-somebody-up-on-another-server","heading":"Looking somebody up on another server","level":2,"markdown":"A person here types a handle, `@alice@mastodon.example`, or pastes the\naddress of a profile. The service asks that server who it is and keeps them\nas a person of another server: a node of type\n`node.actor.activitypub-service.person.v1`, with their address as its `uri`\n([architecture](https://www.anahita.io/docs/services/architecture/#what-a-node-is-called-elsewhere)), and a row\nin `activitypub_actors` with where to write to them and the key they sign\nwith.\n\n**What another server says is believed only about itself.** A description is\nkept only when the address it gives for itself is on the server it was\nfetched from, its key is its own, and its inbox is an https address. It is\nnever believed about this installation. What somebody says of themselves\narrives as HTML and is kept as Markdown with nothing in it that runs.\n\n**It goes only where it is safe to.** The service is told where to go by\npeople and by other servers, so it fetches through the same guarded client\nas link previews (`anahita-libs/safehttp`): https only, public addresses\nonly, decided at the moment of connecting, a megabyte at most.\n\n**Their pictures are copied here.** Their avatar, and the cover across the\ntop of their profile (what Mastodon calls the header, `image` in their\ndescription), are each fetched once, checked to be\na picture and nothing else, written again at this installation's own sizes,\nand kept in its storage like anybody's (`avatar.go`). They are shown from\nthere, never from the server it came from: a picture loaded straight from\nanother server tells that server who looked at whom, for every reader. Each\nis fetched again only when their server gives a different address for it\n(`activitypub_actors.icon_url`, `header_url`), and both are removed from\nstorage when the person is.\n\nA lookup is limited to 30 in ten minutes for each person."},{"id":"being-followed-from-another-server","heading":"Being followed from another server","level":2,"markdown":"Somebody on another server follows somebody here by sending a `Follow` to\nthe inbox.\n\n| The person here | What happens |\n| --- | --- |\n| Is on the fediverse | They are followed at once: an ordinary follow in the graph, from the node kept for the person elsewhere. The other server is sent an `Accept` |\n| Has blocked whoever asks | Nothing is made, and the other server is sent a `Reject` |\n| Is not on the fediverse | Nothing, and nothing is said |\n\n**Nobody on the fediverse approves their followers.** Only a profile open\nto everybody is on it, and such a profile is followed by whoever chooses\nto, from here or from elsewhere. Letting people *ask* to follow is for a\nprofile that is not open to everybody, and such a profile does not\nfederate. So a person's description always says a follow does not wait.\n\nTaking a follow back (`Undo` of the `Follow`) removes it, or the request.\nOnly whoever followed can take their own follow back.\n\nWhat the other server calls each follow is kept in `activitypub_follows`,\nbecause accepting it or undoing it means quoting it back.\n\nA person followed from elsewhere is told as they are of any follower, and\nthe notification leads to the follower's profile here."},{"id":"following-somebody-on-another-server","heading":"Following somebody on another server","level":2,"markdown":"Somebody here follows a person kept from another server the way they follow\nanybody: the same button, the same request to `socialgraph-service`.\nNothing there was changed. `activitypub-service` hears on the bus that it\nhappened and asks the other server, with a signed `Follow`.\n\n| Then | What happens here |\n| --- | --- |\n| The other server says yes (`Accept`) | The follow stands. It is recorded as accepted |\n| It says no (`Reject`), then or later | The follow here is taken away |\n| It says nothing yet (the person there approves their followers) | The follow is there and recorded as pending. What that person posts will not arrive until they say yes |\n| The person here unfollows | The other server is sent an `Undo`, quoting the follow |\n\n- **Only somebody who is on the fediverse can follow across it.** The other\n  server has to be able to find out who is asking. A follow of somebody\n  elsewhere by a person who is not, is taken away again as soon as it is\n  heard of; the web app is not to offer it.\n- **An answer is matched to the question.** A yes or a no does something\n  only when it is from the person who was asked, about a follow this\n  installation is waiting on, by the name that follow was sent under. Nobody\n  can be made to follow somebody by being told they were accepted.\n- **The bus can lose a message.** A follow the other server was never asked\n  about stays as it is here and carries nothing, until\n  [the sweep](#what-the-bus-lost) finds it, within a few minutes, and asks."},{"id":"posts-going-out","heading":"Posts going out","level":2,"markdown":"What is sent to another server cannot be called back, so what is sent is\nnarrow, and is asked of the post as it is at that moment:\n\n- a **note, a photo post, a video post or an article**, written here, by\n  somebody who is on the fediverse. A photo post is sent as a note with its\n  pictures, a video post as a note with its video, and an article as an\n  `Article`;\n- on **their own profile**: not in a group, not on somebody else's;\n- **open to everybody**: not to members, not to followers;\n- or **a reply**, by [the rule for replies](#replies);\n- and a visitor may see it, by the graph's own reckoning.\n\nSuch a note goes to every server that has a follower of its author, once\nfor each server. It is sent as HTML made from its Markdown, with nothing in\nit that runs; the people it names and its first ten hashtags are links, and\nare listed beside the text; its language is said when its author said one. It can also\nbe fetched, at `/ap/notes/<uuid>`.\n\n| Then | Other servers are sent |\n| --- | --- |\n| The note is changed | An `Update`, with the note as it is now |\n| A location is added to it or taken off, from its page | An `Update`, said to be changed then |\n| It stops being one that goes out (made for followers only, say) | A `Delete`: it is taken back |\n| It is deleted | A `Delete` |\n\nA note that never went out is deleted at nobody. Which notes went out is\nkept in `activitypub_objects`: a deleted note's node is gone by the time\nanybody hears of it, and that table is what still knows what it was called.\n\n**A photo post goes with its pictures.** Each is listed with the note at an\naddress of this installation's, `/ap/notes/<uuid>/pictures/<n>`, with what\nits author said it shows and how big it is. A server that asks is sent on\nto the largest copy kept, at a signed address in storage good for a short\nwhile, and makes its own copy and its own smaller sizes. The address gives\na picture only while its post is one that goes out, which is asked each\ntime: a post closed to its followers stops giving its pictures to anybody.\nA photo post is heard of a moment before its pictures are listed, and is\nnot sent without them: it is looked at again after two seconds, and failing\nthat is found by the sweep.\n\n**A video post goes with its video**, a GIF made into a clip included. Its\ntitle is the first line of the note, since a note has none of its own. The\nvideo is listed at `/ap/notes/<uuid>/video`, the largest size no taller\nthan 720 and no heavier than 40 MB (`ap.VideoMax`, which Mastodon and\nGoToSocial take of a video from elsewhere unless told otherwise), with its\nposter as its `icon` at `/ap/notes/<uuid>/poster`. Both send a server that\nasks on to storage, as a picture's address does, and only while the post\ngoes out. A video with no size that light goes as its words, linking to\nits page. Mastodon shows a video with no sound as a GIF, silently and on a\nloop, so a clip made of a GIF is one there too, with nothing said to make\nit so.\n\n**A post goes with its locations,** the ones its author chose for it (the\nuser, 2026-10-19: choosing one is their consent, for that post), and goes\nno further than the post does:\n\n| Where | What |\n| --- | --- |\n| `location` | The first location chosen, a `Place` with its `name`, `latitude`, `longitude` and its page here as `url`, as Pixelfed sends and reads one: ActivityStreams allows several, but servers that read it expect one |\n| `tag` | Every location, the first included, as a `Place` with its point and its page as `href`, beside the mentions and hashtags |\n\nThe composer sends the locations with the post (`location_ids`, up to five,\non adding a note, a photo post, an article or a topic), and the graph ties\nthem as it makes the post, before it is announced, so the `Create` carries\nthem. Mastodon shows neither; Bluesky has no field for one, and nothing goes\nthere. Nobody's own position is ever sent (roadmap row 94). See\n[Hashtags and locations](https://www.anahita.io/docs/services/hashtags/).\n\n**A photo or a video goes with who is in it** (roadmap row 103): each\nperson here tagged in it, their tag approved, as a `Mention` in its `tag`,\n`@alias@domain` with their address here, beside whoever its words name.\nOnly somebody whose profile is on the fediverse, as for a name in the\nwords; a tag tells another server of nobody who has not chosen to be known\nthere. A tag approved or taken off sends the post again as an `Update`.\nBluesky has no tags, and nothing goes there.\n\nA **website's notes**, the items of its feed and its replies, go out as a\nperson's notes do, the website's, to its followers ([Websites](https://www.anahita.io/docs/services/websites/)).\n\n**An article goes as an `Article`** at the same address a note has\n(`/ap/notes/<uuid>`), under the same rules:\n\n| Of the `Article` | From the article |\n| --- | --- |\n| `name` | Its title |\n| `summary` | Its first paragraph of words, as plain text, cut at a word near 300 characters; a heading, a picture, a quotation, a list or an address on a line of its own is passed over |\n| `content` | All of it, as HTML made from its Markdown, as a note's is |\n| `url` | Its page here, `/articles/<id>` |\n| `image` | Its cover, at `/covers/<uuid>/large` |\n\nMastodon shows an `Article` as its title and a link to its page here;\nWriteFreely, Ghost, Friendica and the like show the article itself.\nReplies, likes and reposts from elsewhere are taken on it, its changes and\nits deletion follow, as for a note. A post the article shows by its\naddress on a line of its own stays a link.\n\n**Not sent:** topics (the user, 2026-10-17: they stay here); anything in a group;\na quoted post (the note that quotes goes, without what it quotes);\nthe card for a link. Somebody who starts following is sent what is posted\nfrom then on, and nothing from before."},{"id":"posts-coming-in","heading":"Posts coming in","level":2,"markdown":"A note from another server is kept when all of these are so:\n\n- **somebody here follows whoever wrote it.** This installation is not a\n  copy of the fediverse: what nobody here asked for is not stored;\n- it is **its sender's own**, at an address on their own server;\n- it is **addressed to everybody**;\n- it has some text. A note that **answers another** is kept by\n  [the rule for replies](#replies).\n\nIt is kept as an ordinary note, owned by the node kept for its author, with\nits address as its `uri`. Its HTML is kept as Markdown with nothing in it\nthat runs, at most 20,000 characters, dated when its author says it was\nwritten, in the language it says. Because it is an ordinary note on the\nprofile of somebody followed, it is in the feed of whoever follows them\nwith nothing changed in the feeds.\n\n**With pictures, it is a photo post,** which is what a post with pictures\nis here: its words are the caption and its pictures, up to what a photo post\nholds here (`PHOTO_MAX_FILES`), are its images,\neach with what its author said it shows. So it is drawn, opened and stepped\nthrough like any photo post, and is in its author's photos. The pictures\nare copied into this installation's storage (`pictures.go`) and never shown\nfrom where they came from; each is checked to be a picture and nothing\nelse, held to the limits an upload is held to, and written out again at\nthis installation's own sizes. They are fetched after the post is kept and\nnot waited for, so a post is there a moment before its pictures are.\nTold of again, its pictures are not fetched again; taken away where it was\nwritten, what is stored of it is removed.\n\n- **A post its author marked as sensitive is kept without its pictures.**\n  Nothing here puts a picture behind a warning yet.\n- **What a post was first kept as, it stays:** a note that later gains a\n  picture is still a note.\n- **Only pictures.** Video and sound that come with a post are not kept.\n\n**A custom emoji** in a post, or in somebody's name or what they say of\nthemselves, is a word between colons with a picture listed beside it. The\npicture is not shown yet, so the word is taken out\n(`federation.WithoutCustomEmoji`): only a word that is listed as one.\n\n**A link in it is given a card,** as a link in a note written here is:\nwhat the page is called, what it says of itself and its picture, read by\nthis installation and kept in its own storage. `activitypub-service` asks\nlink-preview-service for it at an address the gateway sends nobody to\n(`GET /internal/link-previews`, `LINK_PREVIEW_URL`), and keeps the answer\nin the note's meta where a note keeps its card. Nothing the other server\nsays of the link is used, and no reader is sent to another site for a\npicture. The card is of the first link that is to a page, not of somebody's\nname or a hashtag. A post is kept without one where the page says nothing\nof itself, or does not answer within six seconds.\n\nA post that was kept before something about keeping posts was put right\ncan be read again from its own server:\n\n~~~sh\ndocker compose exec activitypub-service /app/app-binary reread https://mastodon.example/users/alice/statuses/1\n~~~\n\n**Its locations are tagged here** (roadmap row 82, part 4), unless the\ninstallation says `FEDERATION_LOCATIONS=off`. Every `Place` it names, its\n`location` (Pixelfed sends one with a post its author placed) and the\n`Place`s in its `tag`, at most five, is matched to a location here: one of\nthe same name within 25 km, since Pixelfed's places are mostly cities whose\npoint differs from server to server; else any within 100 m. When none\nmatches, one is made, with its name and its point kept to four decimals\n(about 11 m), put down to the post's author as a hashtag is to whoever first\nused it. The post then shows among that location's posts, in trending and\nnearby. When it is edited there, the locations it no longer names are taken\noff. A place's own address (places.pub gives OpenStreetMap places stable\nones) is not kept yet: the graph keeps addresses only for profiles and\nposts from elsewhere. Nobody's own position is read: `Arrive` and other\ncheck-ins are not (row 94).\n\n**Its hashtags are this installation's.** A hashtag arrives as a link to\nits page on the server the note was written on. It is kept as the hashtag\nalone (`federation.PostToMarkdown`), and the note is then given its\nhashtags by hashtag-service exactly as a note written here is: one that is\nnot here yet is made, and the note is found under it with every other post\nthat has it. So `#gardening` in a note from elsewhere leads to this\ninstallation's own page for it, as it does on Mastodon. Only its first ten\ndifferent hashtags are filed, as for a post written here ([Hashtags and\nlocations](https://www.anahita.io/docs/services/hashtags/#at-most-ten-hashtags)); the rest stay words. A hashtag in letters this installation does not take for one (see\n`entities.HashtagRegexPattern`) is left the link it came as; so is one in\nwhat somebody says of themselves.\n\nhashtag-service is told by an event of its own,\n`federation.medium.text-service.note.added|edited.v1`\n(`anahita-libs/event/remote.go`), and not by the one a note written here\nis announced with. Everything that listens for those would take the note\nfor one written here: whoever it names by `@name` would be told they were\nmentioned, though the name is somebody's on another server, and its\nauthor's followers would be notified as if by somebody here.\n\nChanged there, it is changed here; taken away there, it is taken away here,\nand only by its author: the graph removes a note from another server only\nin the name of whoever it belongs to\n(`graph-grpc-service/data/repositories/nodes_remote.go`).\n\n**What they posted before they were followed.** A server sends a follower\nwhat is posted from then on, so somebody followed today would have an\nempty profile here until they next post. When somebody is first followed,\ntheir most recent public notes, up to twenty, are read once from the list\ntheir server keeps (`backfill.go`), each held to the same rule as a note\nthat is sent. Only ever from their own server, and in no more than\ntwenty-five requests whatever it answers.\n\n**Not kept:** video and sound that come with a post, and polls."},{"id":"replies","heading":"Replies","level":2,"markdown":"A reply here is a post with a parent and a root, its author's own, seen by\nwhoever may see the root: a note, or a photo or video post when it carries\npictures or a GIF or a video. Between servers it is a `Note` with\n`inReplyTo`, with its pictures or its video attached as a photo or video\npost's are; to Bluesky, a reply with its embed. A reply of any kind may\nanswer a note, a photo post or a video post (tried against Mastodon and\nBluesky on 2026-10-14).\nNothing about replies was changed for this: what is added is which of them\ncross, and how."},{"id":"going-out","heading":"Going out","level":3,"markdown":"A reply written here is sent when its author is on the fediverse, it is\nnot hidden or removed, the root of its thread is open to everybody, and\n**what it answers is something other servers can see**: a note that came\nfrom another server, or a note here that itself went out. So a thread of\nsomebody's own notes goes out whole, each after the one before, and a reply\nunder a note that stayed here stays here.\n\n**A thread written in one go** (`POST /notes/…/thread`, roadmap row 101) is\nheard of in no set order: its notes are announced together. A note whose\npart before it, by the same writer and made within a minute of it\n(`federation.WrittenTogether`), has not gone out yet sends that part first,\nso the thread goes out at once and in order, on the fediverse and on\nBluesky, not at the next sweep. An older note of the writer's that never\nwent out still does not go out because of a reply. On Bluesky, the part\nbefore is read from the writer's own server (`com.atproto.repo.getRecord`)\nrather than the AppView, which may not have seen it yet; a part written\nbefore the one it answers is tried again shortly, and not given up on.\n\nIt is sent with `inReplyTo` set to what it answers, by the address that\nhas between servers, to its author's followers and to whoever wrote what\nit answers when they are on another server. They are named beside the text\nas mentioned (a `Mention`) and as somebody it is for, which is what has\ntheir server tell them; their name is not written into the reply. Where it\nwas sent beyond the followers is kept (`activitypub_objects.reply_inbox`),\nso that a change or a deletion goes there too, after the reply is gone."},{"id":"coming-in","heading":"Coming in","level":3,"markdown":"A note from another server that answers something is kept when what it\nanswers is known here (a note here that went out, or a note kept from\nelsewhere), it is its sender's own and addressed to everybody, the thread's\nroot is open to everybody, and:\n\n- **under a thread that began here**, its author may reply by the thread's\n  own rule, the one `permissions.CanReply` holds everybody to: whoever the\n  root's author lets reply (anyone, nobody, their followers, the people\n  they follow, the people mentioned), measured by the follows between them\n  in the graph, and the profile's rule for who may comment, where somebody\n  elsewhere counts as a registered reader. Somebody the thread's owner or\n  author has blocked is refused. Whoever wrote the reply need not be known\n  already: they are looked up and kept;\n- **under a thread that began elsewhere**, somebody here follows its\n  author, as for any note.\n\nMastodon begins a reply with the name of whoever is answered, as a link to\ntheir profile. Where that is somebody of this installation the name alone\nis kept (`@ana`), which the web app shows as any mention; a link that reads\nas one name and leads to another is left as it came.\n\nIt is kept under its parent like any reply, counted, and seen by whoever\nmay see the root and nobody more: the graph gives it its root and takes no\nword from outside for which thread it is in. Whoever it answers here is\nnotified, as for a reply written here (notification-service hears the\nevent for a note kept from elsewhere). A reply to something not known here\nis dropped; the rest of a thread is not fetched from its server.\n\nTaken away where it was written, it is taken away here, the thread is\ncounted again (one that people here have answered is emptied and kept, as a\nreply here is when it is removed after being answered: what was said under\nit is theirs, and nothing sent from outside removes what was made here), and the news of it goes with it: the service says on the bus\nthat it is gone (`federation.medium.text-service.note.deleted.v1`), and\nnotification-service removes what it told anybody of the reply."},{"id":"answering-from-here","heading":"Answering from here","level":3,"markdown":"Somebody here may reply to a note from another server that they can see.\nWho its author lets reply is its own server's to say and to hold to.\nWritten by somebody on the fediverse, the reply is sent; written by\nsomebody who is not, it stays here, as a conversation among the people of\nthis installation, and the web app says so before it is posted."},{"id":"likes-and-reposts","heading":"Likes and reposts","level":2,"markdown":"Both are made here as they always were, through like-service and\nfeed-service, and said on the bus. `activitypub-service` hears that and\ntells other servers; and what other servers send is kept through the same\ngraph calls those services use, so a like or a repost from elsewhere is\ncounted, shown and told to its author like any other.\n\n| | Going out | Coming in |\n| --- | --- | --- |\n| **A like** (`Like`) | Somebody on the fediverse likes a note or a reply that came from another server: its author's server is told. Taken back with an `Undo` | Of a note or a reply here that went out: kept as a like by the person kept for whoever sent it, and the author told. `Undo` takes it away |\n| **A repost** (`Announce`, Mastodon's boost) | Somebody on the fediverse reposts a note other servers can see, one from elsewhere or one here that went out: their followers' servers are told, and the note's author's when it is from elsewhere. Taken back with an `Undo` | Of a note here that went out, by anybody: kept as a repost and the author told. Of a note from elsewhere, only by somebody followed from here: the note is read from its own server and kept with whoever wrote it, if it is not here already, and the repost is on the reposter's profile and in the feeds of who follows them |\n\n- **By somebody who is not on the fediverse,** a like or a repost of\n  something from elsewhere stays here, as their reply does.\n- **Somebody the author has blocked** neither likes nor reposts what the\n  author wrote; a server that is refused is not listened to at all.\n- **A like is named from who liked and what** (`/ap/activities/like-…`), so\n  taking it back names it again with nothing kept. A repost that went out\n  is on record like a note (`activitypub_objects`, `kind` `repost`), with\n  where it went, because the repost is gone by the time its taking back is\n  heard of.\n- **Taken back elsewhere, the news of it goes here:** a repost of\n  something here that is undone on its own server is removed, and what its\n  author was told of it with it.\n- **Nobody elsewhere is notified here:** whoever wrote something from\n  another server is told by their own server.\n- **Not covered by the sweep:** a like or a repost whose word the bus lost\n  is not found again. Doing it again puts it right."},{"id":"what-can-be-done-with-what-came-from-elsewhere","heading":"What can be done with what came from elsewhere","level":2,"markdown":"Somebody from another server, and what they post, are looked at here and\nfollowed, and little else. The rules are the installation's own, in\n`anahita-libs/permissions`, so every service keeps them:\n\n| | Allowed? |\n| --- | --- |\n| Reading their profile, at `GET /people/<handle>` with the whole handle (`alice@mastodon.example`) | As for any public profile. It answers with `uri` and `origin_domain`, which nothing made here has |\n| Following and unfollowing them, blocking them | Yes, through the same routes as for anybody. A block ends every follow between the two, both ways, so that nothing more is delivered to their server, and their server is sent a `Block` by somebody on the fediverse, which Mastodon and what speaks to it keep to: they stop seeing the blocker's posts and answering them. Unblocking sends `Undo` of it; the follows are not made again. The sweep forgets any follow left across a block |\n| Changing their profile, their settings, their pictures | Nobody, an administrator included: every route that changes a person reads them as a person of this installation, and does not find them |\n| Posting on their profile | Nobody |\n| Changing a post of theirs | Nobody. An administrator may remove it from this installation |\n| Replying to a post of theirs | Yes: see [Replies](#replies) |\n| Liking a post or a reply of theirs | Yes: see [Likes and reposts](#likes-and-reposts) |\n| Reposting a post of theirs | Yes, likewise |\n| Saving a post of theirs | Yes; it stays here |"},{"id":"the-inbox","heading":"The inbox","level":2,"markdown":"`POST /inbox`, and `POST /ap/users/<uuid>/inbox` for each person: the same\ndoor. It is the one place in the installation where anybody on the internet\nmay send anything, so nothing that arrives is acted on, or read for what it\nsays, until it is known who sent it:\n\n1. **It must be signed,** and the signature must check against the key of\n   whoever it names, read from that person's own server (or kept from the\n   last time, and read again if the kept one no longer checks).\n2. **Whoever the message says did the thing must be whoever signed it.**\n   Nobody is believed about what somebody else did.\n3. **Only then is it read,** and only what this installation understands is\n   done: `Follow` and its `Undo`; `Accept` or `Reject` of a follow asked\n   for from here; `Create`, `Update` and `Delete` of a note; `Like` and\n   `Announce` (a repost) and their `Undo`; `Update` and `Delete` of the\n   person themselves. Everything else is answered `202` and ignored.\n\nA message of more than a megabyte is refused. What cannot be checked is\nanswered `401`, and nothing is kept of whoever sent it."},{"id":"what-is-sent-and-when-it-does-not-arrive","heading":"What is sent, and when it does not arrive","level":2,"markdown":"What is to be sent to another server is written to a table first\n(`activitypub_deliveries`) and sent from there, signed by the person it is\nsent for. If the other server is down it is tried again after 1, 4, 16, 64\nand 256 minutes and then every six hours, for two days; if the other server\nsays the inbox is gone, or that what was sent is wrong, it is given up at\nonce. What is given up is kept in the table with the reason.\n\n~~~sql\nSELECT target, attempts, last_error, created_at\nFROM activitypub_deliveries\nWHERE status = 'failed'\nORDER BY created_at DESC;\n~~~\n\nThe sending runs inside `activitypub-service`. Any number of copies of the\nservice may run: a row is handed to one of them at a time."},{"id":"what-the-bus-lost","heading":"What the bus lost","level":3,"markdown":"The service is told of what happens here (a follow, a post, a profile\nclosed) on a bus that can drop a message, and what it was not told of,\nother servers were not told of either. So every five minutes, and a minute\nafter it starts, it looks for itself (`cmd/api/handlers/sweep.go`,\n`data/repositories/missed.go`) at where the graph and its own records\ndiffer:\n\n| Found | Done |\n| --- | --- |\n| A follow of somebody elsewhere that their server was never asked about | It is asked. Or the follow is taken away, where it is one that cannot be: by somebody not on the fediverse, or from the lab to a real server |\n| A follow their server was asked about that is no longer in the graph | Their server is told it is over |\n| A public note by somebody on the fediverse, written in the last two days and since they joined, that was never sent | It is sent to their followers |\n| A note that was sent and is gone here, disabled, or no longer open to everybody | Servers that have it are told to drop it |\n| A note other servers have that was changed here since they were last sent it | They are sent the change |\n| Somebody whose profile was changed since their followers' servers were last sent it | They are sent it as it is now |\n| Somebody on the fediverse whose profile is no longer open to everybody, or is disabled or gone | They leave, as they would have |\n\nEach is handed to what would have handled the event, which decides again\nfrom the thing as it is now and does its work once however often it is\nasked. So a sweep that finds nothing new sends nothing, every copy of the\nservice may sweep at once, and an event that was only late does no harm.\nSomething is not taken for lost until two minutes have passed, and at most\n200 of each kind are put right in one sweep. A line in the log says what a\nsweep put right; one that found nothing says nothing.\n\n**A change is found by what is on record of what was last sent:**\n`activitypub_objects.told_updated_at` for a note and\n`activitypub_accounts.told_updated_at` for a person, each the thing's own\n`updated_at` when other servers were sent it. A change made later than that\nwas never told. A row from before these were kept has none, and is compared\nwith when the note was made, or when the person joined the fediverse.\n\nA note that went unsent for more than two days is not sent late, since\nfollowers would be shown it as new."},{"id":"signing-keys-and-signed-requests","heading":"Signing keys and signed requests","level":2,"markdown":"Whatever is sent in somebody's name is signed with a key of theirs, and the\nother server checks it against the public half, which it reads from their\ndescription. What the installation asks for in nobody's name, such as a\nlookup, it signs as itself (`/ap/actor`): many servers answer nobody who\ndoes not say who is asking.\n\nThe signature is over the request's target, its host, its date and, when it\nhas a body, a digest of the body (`activitypub-service/httpsig`). Each of\nthe four is one an attack goes through without, and each has a test. A\nrequest dated more than fifteen minutes from now is refused.\n\n**Behind a proxy, the `Host` the service is given must be the one that was\nasked for.** The host is part of what is signed. The gateway and the\nmanifests here pass it on; a proxy put in front that does not will make\nevery signature fail, in both directions.\n\n- A key is made when a person turns federation on (RSA, 2048 bits, which is\n  what every server understands), and for anybody on the fediverse who has\n  none the first time one is asked for: a website's, for one.\n- The private half is kept in `actor_keys`, encrypted by the means\n  auth-service keeps its secrets: `CRYPT_BACKEND` and `CRYPT_LOCAL_KEY`\n  ([Configuration](https://www.anahita.io/docs/services/configuration/)). **Losing that key loses every\n  person's signing key,** and with it their followers elsewhere: it is in\n  the backups an installation cannot do without.\n- Everybody is told the same key. A server that was told one will refuse\n  what is signed with another."},{"id":"the-api","heading":"The API","level":2,"markdown":"Signed in, for the person themselves:\n\n| Request | Does |\n| --- | --- |\n| `GET /activitypub/settings` | Whether they are on the fediverse, whether they could be, and what they are or would be called. An administrator may ask it of somebody else, with `?person_id=`, to read and not to change |\n| `PUT /activitypub/settings` `{ \"enabled\": true }` | Turns it on or off |\n| `GET /activitypub/lookup?q=@alice@mastodon.example` | Finds somebody on another server, by handle or by the address of their profile, and keeps them |\n\n~~~json\n{\n  \"available\": true,\n  \"enabled\": false,\n  \"handle\": \"ana@example.org\",\n  \"actor\": \"https://api.example.org/ap/users/0962789f-aeca-4850-ac9b-41129e3b6f46\"\n}\n~~~\n\nWhen `available` is false, `reason` says why:\n\n| `reason` | Means |\n| --- | --- |\n| `site_private` | The site is for members, or shows visitors a preview |\n| `site_unreachable` | The site has no address another server could reach |\n| `profile_not_public` | Their profile is not open to everybody |\n\nTurning it on when it is not available is refused with `409` and the reason.\nTurning it off is always allowed.\n\nA lookup answers with who was found:\n\n~~~json\n{\n  \"id\": 170292,\n  \"type\": \"node.actor.activitypub-service.person.v1\",\n  \"name\": \"Alice\",\n  \"alias\": \"alice@mastodon.example\",\n  \"body\": \"Grows **beans**\",\n  \"uri\": \"https://mastodon.example/users/alice\",\n  \"origin_domain\": \"mastodon.example\",\n  \"url\": \"https://mastodon.example/@alice\",\n  \"locked\": true\n}\n~~~\n\nor with why not: `400 lookup_unreadable` (not a handle or an https address),\n`404 lookup_is_here` (somebody on this installation), `404 lookup_not_found`,\n`404 lookup_not_a_person` (a group, a post), `502 lookup_failed` (the other\nserver could not be reached or understood), `429 too_many_lookups`.\n\n**From the command line,** for whoever runs the service and wants to know\nwhether it and another server can reach and understand each other:\n\n~~~sh\ndocker compose exec activitypub-service /app/app-binary lookup @alice@mastodon.example\n~~~"},{"id":"leaving-and-what-follows-a-persons-changes","heading":"Leaving, and what follows a person's changes","level":2,"markdown":"**Leaving.** A person leaves the fediverse when they turn it off, and also\nwhen they stop being somebody who may be on it: their profile is no longer\nopen to everybody, or they are disabled, archived, deleted or purged.\nEither way:\n\n- every server that knows them (the ones with a follower of theirs, and\n  the ones with somebody they follow) is sent a `Delete` of the person,\n  which is the one message the fediverse has for \"forget them\";\n- here, the follows that crossed between servers are removed, both ways\n  round, and the record of what they sent;\n- from that moment they are not found.\n\n**Leaving is for good, as far as other servers go.** A server told that\nsomebody is deleted removes them, their posts and their followers, and\nremembers that it did. In the lab, GoToSocial marked the account suspended\nand did not take the same person back when they turned federation on\nagain. So turning it on again makes a person findable here, and whether\nany server they met before will know them is that server's choice.\n\nThis is what the fediverse itself does: a `Delete` of the person is how\nMastodon and the rest say an account is gone, and there is no gentler\nmessage that means \"stop showing them\". Staying silent instead would leave\na person's profile and posts on other servers, in public, for good.\n\n**So that nobody leaves by accident,** the web app asks before the switch\nis turned off, and the card where a profile's audience is chosen warns as\nsoon as anything but \"everybody\" is picked by somebody who is on the\nfediverse, and asks once more before saving.\n\n**A profile changed.** When somebody on the fediverse changes their name,\nwhat they say of themselves or their pictures, their followers' servers are\nsent them as they are now (`Update`).\n\n**From the other side.** A profile changed elsewhere is read again from\nits own server, and not taken from the message that says so. An account\nclosed elsewhere is removed here: the person, what they posted, and every\nfollow between them and anybody here. Only they can say so of themselves,\nand a stranger saying they are gone is taken without their server being\nasked anything: a large server announces every closed account to every\nserver it knows."},{"id":"servers-that-are-refused","heading":"Servers that are refused","level":2,"markdown":"A list kept by super administrators (`federation_domain_blocks`). A server\non it:\n\n- is not listened to: what it sends is answered `403` before its key is\n  even fetched;\n- is sent nothing, including what was queued for it before it was refused;\n- cannot have anybody on it looked up.\n\nA name covers every name under it: `example.net` refuses\n`social.example.net` too.\n\n**It does not remove what already came from that server.** People from it\nwho are kept here stay, with their posts and the follows between them and\npeople here; nothing more arrives and nothing more is sent. They are\nremoved one by one, as any post or person is.\n\n| Request | Does |\n| --- | --- |\n| `GET /activitypub/blocks?q=&limit=&start=` | A page of the list, by name, with `pagination.total`. `q` narrows it to the names and reasons it is found in; `limit` is 50 unless asked, 200 at most |\n| `POST /activitypub/blocks` `{ \"domain\": \"spam.example\", \"reason\": \"…\" }` | Refuses a server, or changes why. The domain may be typed as a name, an address or a handle |\n| `DELETE /activitypub/blocks/spam.example` | Stops refusing it |\n\n**Super administrators only,** like the rest of what is set for the whole\ninstallation: refusing a server is a choice made for everybody on it, and\nan administrator who is not a super administrator is answered `403`. The\nreason is for the super administrators who come after, and is never sent to\nthe server refused. A change takes up to half a minute to take hold in\nevery copy of the service.\n\nIn the web app the list is in **Site settings, under Fediverse**: the\nservers refused with why, a search by name or reason, a button beside each\nto stop refusing it, and a dialog to refuse another. It is read a page at a\ntime as it is scrolled, so a list of thousands is no heavier than a list of\nten.\n\n**How much one address may send** is limited too: 600 messages a minute to\nthe inbox, counted before anything is read, and answered `429` beyond\nthat."},{"id":"pictures-that-are-removed","heading":"Pictures that are removed","level":2,"markdown":"A picture is nearly all of what a post from another server costs to keep:\nit is stored at every size a photo is. So the pictures of a post from\nelsewhere are removed from storage once the post has been left alone for\n`REMOTE_MEDIA_DAYS`, thirty unless said, and copied here again when\nsomebody asks to see them. It is what Mastodon and GoToSocial do with what\nthey hold of other servers, and it is on as the installation comes, since\nit loses nothing that is still there to be had. `0` keeps pictures for\ngood.\n\n- **What stays.** The post, with its words, and what each picture showed.\n  It says in its meta (`media_removed`) how many pictures it had, and the\n  API sends that with the post: `media_removed: { count, alts, gone }`.\n- **\"Left alone\"** is by when the post was last written here: when it\n  arrived, when its author changed it, and when its pictures were last\n  copied again. Pictures that were asked for are kept as long again.\n- **Never** the pictures of a post somebody here liked, saved, answered,\n  reposted or quoted, nor of one an open report is about. And nothing\n  written here is ever looked at: only what has an address on another\n  server.\n- **Asked for again:** `POST /activitypub/posts/:id/pictures` or\n  `POST /atproto/posts/:id/pictures`, by anybody signed in who may see the\n  post, sixty times in ten minutes. The post is read again from where it\n  came from and its pictures are copied as they were the first time. In\n  the web app it is a button where the pictures were, and it is asked at\n  once on the post's own page.\n- **No longer to be had.** When the post was taken away where it was\n  written, or what it is read from says it has no pictures now, the answer\n  is `410 media_gone`, and the post says so from then on (`gone`), keeping\n  what its pictures showed.\n- **Each service looks after what came through it,** every hour, two\n  hundred posts at a time, oldest first: an installation that runs one of\n  the two is covered by that one.\n\nAvatars and covers of people elsewhere are small, one of each, and are\nkept while the person is. The picture of a link's card is the link\npreview service's own.\n\nA cover a server shows for somebody who set none (Mastodon's grey pattern\nat `.../headers/original/missing.png`, NodeBB's\n`.../assets/images/cover-default.png`) is not copied: such a profile has no\ncover here."},{"id":"posts-and-people-that-are-removed","heading":"Posts and people that are removed","level":2,"markdown":"The words of a post cost very little, and are kept far longer than its\npictures. What goes is what nobody here has a reason to keep.\n\n| Setting | What goes | As it comes |\n| --- | --- | --- |\n| `REMOTE_UNFOLLOWED_DAYS` | The posts of somebody nobody here follows any more, once they have been left alone this many days | 30 |\n| `REMOTE_POSTS_DAYS` | Any post from elsewhere left alone this many days, whoever follows its author | Off |\n| `REMOTE_POSTS_KEPT` | All but the newest so many posts of one person elsewhere | Off |\n\nThe first is on because it removes only what nobody is reading: somebody\nlooked up once and never followed, or unfollowed a month ago. The other\ntwo are for an installation that has to hold its database to a size, and\nare off because **what they remove is not got back by asking**: a post\nthat went is read again only if its author is followed anew, and then\nonly their latest. `0` turns any of the three off.\n\n- **Never** a post somebody here liked or saved; one that anything stands\n  under or on, here or elsewhere (an answer, a repost, a quote); one in a\n  thread somebody here wrote in; one an open report is about.\n- **Never an answer.** Only a post that begins a thread is removed by a\n  rule. An answer from elsewhere is here because it is part of a\n  conversation somebody here is in.\n- **Never anything written here.** Only what has an address on another\n  server is looked at, and only what is owned by somebody of another\n  server. The operator's settings do not clip what the site's own people\n  wrote, at any age or number.\n- **\"Left alone\"** is as for pictures: by when the post was last written\n  here.\n- **It goes as if its author had taken it away:** with what is stored of\n  its pictures, and said on the bus, so that its hashtags and any news of\n  it go too.\n- **Then the person.** Somebody from elsewhere is removed, with their\n  avatar and cover, when all of these hold:\n  - no post of theirs is left here;\n  - nobody here follows them, and they follow nobody here;\n  - nobody here has blocked them, and nothing ties them to anything here\n    (no like, no mention);\n  - no open report is about them;\n  - they have been left alone for `REMOTE_UNFOLLOWED_DAYS`.\n\n  Looked up again, they are read again. With that setting at `0` nobody\n  is removed.\n- **Each service looks after what came through it,** every hour, two\n  hundred posts and two hundred people at a time, the longest left alone\n  first.\n\nNot built: the same for videos, which are not brought in from elsewhere\nyet (`docs/plans/remote-content-limits.md`)."},{"id":"bluesky","heading":"Bluesky","level":2,"markdown":"`atproto-service` lets somebody here look up an account on Bluesky and\nfollow it. Its posts are then kept here as they are written, and are in the\nfeed of whoever follows it, like the posts of anybody else they follow.\n\n**Everybody can read Bluesky; whoever connects their own Bluesky account\ncan also post, reply, like, repost and follow there.** Nobody is given a\nname on Bluesky by this installation. Somebody\nwho already has an account there can connect it, and from then on their\npublic notes are posted there, and a like, a repost or a reply they give\nhere to something from Bluesky is made there too, as that account: see [Answering on Bluesky](#answering-on-bluesky).\nA website's administrators connect the site's own account the same way,\nand its notes are posted there ([Websites](https://www.anahita.io/docs/services/websites/#on-bluesky)).\nFor everybody else such things stay here, which the web app says under the\nreply being written, and their follow of somebody on Bluesky is kept here\nwithout that account being told.\n\nSo it asks nothing of the installation but a way out to the internet: no\npublic name, no certificate. It works on a developer's machine as it is.\n**Only a site open to read writes to Bluesky** (`SITE_READ_ACCESS` left\nout or `public`), and only for somebody whose profile anybody may see. On\na site kept to its members, or one that shows visitors a preview,\nconnecting an account is not offered and nothing leaves; reading still\nworks."},{"id":"running-it","heading":"Running it","level":3,"markdown":"`make atproto`, or the `atproto-service` entry in `docker-compose.yml`; in\nKubernetes, `k8s/base/services/atproto.yml`. An installation that leaves it\nout follows nobody on Bluesky: the web app's lookup finds no service to\nask, and offers nothing.\n\n| Variable | Value |\n| --- | --- |\n| `ATPROTO_APPVIEW_URL` | Optional. The reader that is asked who an account is and what it posted. Left out, Bluesky's own, `https://public.api.bsky.app`, which asks for no account |\n| `ATPROTO_JETSTREAM_URL` | Optional. Where posts are heard as they are written: one address or several, with commas between. Left out, the four Bluesky runs (`wss://jetstream1.us-east.bsky.network/subscribe` and its three siblings). More than one is better than one: see [Hearing what is posted](#hearing-what-is-posted) |\n| `ATPROTO_CDN_URL` | Optional. Where pictures are fetched from, to be copied here. Left out, `https://cdn.bsky.app` |\n| `LINK_PREVIEW_URL` | Where `link-preview-service` is, for the card of a link in a post. Left out, such posts have no card |\n| `ATPROTO_PLC_URL` | Optional. The directory a DID is looked up in. Left out, Bluesky's, `https://plc.directory` |\n| `CRYPT_BACKEND`, `CRYPT_LOCAL_KEY` (or `AWS_KMS_KEY_ID`) | The same as auth-service's. The token of each connected account is kept encrypted under them; without them nobody can connect an account and nothing is written to Bluesky |\n| `SITE_READ_ACCESS` | The same as every service's. Anything but `public` (or left out) turns writing to Bluesky off |\n| `FEDERATION_PROTOCOLS` | Read by auth-service, not this service: add `atproto` (`activitypub,atproto`) so that NodeInfo says the site is on Bluesky and the web app's front page says so |\n\nThe rest is what every service has: the database, the graph and file\nservices, the bus, and the settings for checking who is signed in.\n\n`app-binary lookup alice.bsky.social`, run in the service's container,\nlooks somebody up with nobody signed in and prints who was found: the\nfirst thing to try when Bluesky seems not to be reached."},{"id":"looking-somebody-up","heading":"Looking somebody up","level":3,"markdown":"`GET /atproto/lookup?q=`, signed in. `q` is a handle with or without its\n`@` (`alice.bsky.social`), the address of a profile\n(`https://bsky.app/profile/…`), or an account's lasting name\n(`did:plc:…`). The answer is the same as for somebody on the fediverse:\nthe person, kept here, to be opened and followed. Thirty lookups in ten\nminutes for each person.\n\nIn the web app it is the same box as for the fediverse, on the search\npage. How a handle is written says which network is asked:\n`@alice@mastodon.example` has its server after a second `@`, and\n`alice.bsky.social` is a name of its own.\n\n| Refused with | When |\n| --- | --- |\n| `400 lookup_unreadable` | What was typed is not a handle, a DID or the address of a profile |\n| `404 lookup_is_here` | The handle is this installation's own name, or one under it |\n| `404 lookup_not_found` | Nobody is there: never made, closed, taken down, or put away by its owner |\n| `404 lookup_refused` | The handle is under a name on the list of [servers that are refused](#servers-that-are-refused), or `bsky.app` is on it, which refuses the whole of Bluesky |\n| `502 lookup_failed` | Bluesky could not be reached or understood |\n| `429 too_many_lookups` | The limit |"},{"id":"who-they-are-here","heading":"Who they are here","level":3,"markdown":"A node of type `node.actor.atproto-service.person.v1`, called by their\nhandle, with what they say of themselves, their avatar and their banner.\nBoth pictures are copied into this installation's storage, as for somebody\non the fediverse. Their address between servers is `at://` and their DID,\nwhich does not change when their handle does; `origin_domain` is\n`bsky.app`, where \"View on bsky.app\" leads.\n\nA handle can pass from one account to another. When it does, the new\nowner of the handle is shown here by their DID, written so it cannot be\nmistaken for a handle (`did-plc-….invalid`), until the old account is no\nlonger kept here; then the new owner gets the handle. An account whose\nhandle no longer leads to it is shown by its DID in the same way.\n\nTheir profile is at `/people/alice.bsky.social`: a username here can have\nno dot in it, so a name with one is somebody on Bluesky."},{"id":"their-posts","heading":"Their posts","level":3,"markdown":"A post is kept only when somebody here follows whoever wrote it.\nEverything on Bluesky is open to everybody, so there is no asking who a\npost is for.\n\n- **A reply is kept when what it answers is kept here,** and is put under\n  it: somebody followed saying a long thing in several posts, or answering\n  somebody else who is followed. A reply to something that is not kept\n  here is left, since it would stand without what it answers. Replies by\n  people nobody here follows are not heard at all.\n- **A repost by somebody followed is kept as their repost,** and is in\n  the feed as anybody's repost is. What was reposted is usually by\n  somebody nobody here follows: it is read from Bluesky, with whoever\n  wrote it, and kept for the repost's sake. It is the one way a post\n  arrives without its author being followed. A repost taken back goes\n  (`atproto_reposts` remembers which post each was of, since taking one\n  back says only its name).\n\nIt is kept the way a post from the fediverse is:\n\n- **Its words** become Markdown. Links stay links, a name leads to that\n  account's page on Bluesky, and a hashtag is a hashtag of this\n  installation, made if it is new.\n- **With pictures it is a photo post,** up to four (what Bluesky takes),\n  each with what its\n  author said it shows, copied into this installation's storage. What its\n  author marked as not to be shown plainly (nudity, graphic media) is kept\n  for its words, without its pictures.\n- **A link it carries is given a card** read by this installation itself,\n  whether or not the address is in its words.\n- **A post it quotes is led to,** with a line at its end; the quoted post\n  is not kept.\n- **A video is not kept.** A post with words and a video is kept for its\n  words; one that is only a video is not kept at all.\n\nWhen somebody is first followed, their twenty most recent posts are read,\nwith their answers to themselves, once, so that their profile here is not empty until they next post. A post\ntaken away on Bluesky goes from here, with what was stored of it. An\naccount that is closed goes too, with everything of theirs; one that is\nonly put away for a while is left as it is."},{"id":"hearing-what-is-posted","heading":"Hearing what is posted","level":3,"markdown":"One connection is held open to Jetstream, Bluesky's feed of everything\ndone on the network, and told which accounts are wanted: those somebody\nhere follows, ten thousand at most. With nobody followed, no connection is\nmade.\n\n- **One copy listens.** Any number of copies of the service may run, and\n  one of them holds the turn (a lock in the database). When it stops,\n  another takes over within half a minute.\n- **Nothing is missed while nobody listens.** The time of the last thing\n  dealt with is kept (`atproto_cursor`), and a connection that begins\n  again asks for what came after it. Jetstream keeps what was said for a\n  limited time; a service that was down for longer than that loses what\n  was posted in between.\n- **No one Jetstream is relied on.** One of them can fall behind, or stop\n  passing things on, while still answering: on 2026-10-13 one of Bluesky's\n  four had nothing of the last hour and the other three had all of it,\n  and that cannot be told from a quiet evening by listening. So each is\n  listened to for ten minutes and then the next, and each begins far\n  enough back to hear again what the last three were asked for. What one\n  of them kept back arrives from the next, some minutes late. With one\n  address only, there is no such cover.\n- **A follow that the bus lost** is found within a minute: the service\n  asks the graph who is followed, and does not rely on having been told."},{"id":"answering-on-bluesky","heading":"Answering on Bluesky","level":3,"markdown":"Somebody here who has a Bluesky account connects it in their settings,\nunder Privacy, with their handle and **an app password**: a password\nBluesky lets an account make for one app and take back without touching\nits own. The card says to use one and where to make it.\n\n- **What is kept.** The password is sent once to the server that holds\n  the account, found from the account's lasting name, and exchanged there\n  for two tokens. It is not kept. The longer-lived token is kept,\n  encrypted under the same key as auth-service's secrets\n  (`CRYPT_LOCAL_KEY`); the short one is held in memory. An installation\n  with no such key reads Bluesky and writes nothing: nobody is offered the\n  card.\n- **What goes.** A like or a repost of a post that came from Bluesky; a\n  reply under one, or under a reply that itself went there; and a note, a\n  photo or video post or an article of the person's own that everybody may\n  read, on their own profile. A topic does not go. Connecting an account is saying that such posts are\n  posted there, as being on the fediverse is. A post on somebody else's\n  profile or a group's does not go.\n- **Videos.** A video post goes with its video, a GIF made into a clip\n  included, and its title as its first line: the largest size no taller\n  than 720 and no heavier than 50 MB, read from storage at an address only\n  the services reach and given to the account's server\n  (`app.bsky.embed.video`), which Bluesky makes ready to play once the post\n  is written. That is the simpler of the two ways Bluesky's documents\n  describe; the other, through Bluesky's video service, has it ready\n  before the post appears. A video longer than three minutes or heavier\n  than that goes as a card that leads to its page here, with its poster;\n  so does one Bluesky would not take. An account Bluesky hosts must have\n  confirmed its email to post videos, and has a daily limit.\n- **Articles.** An article goes as its title, as the first line, then its\n  words, cut to what a post holds, with a card that leads to the article\n  here: its title, its opening under it, and its cover as its picture,\n  given to the account's server as a photo post's pictures are.\n- **Pictures and cards.** A photo post goes with up to four pictures and\n  what each shows; one with more goes with the first four, and its words\n  end with the address of the post here, where they all are. A note goes with the card of the link in it, picture\n  and all: Bluesky draws no card of its own from an address in a post, so\n  the card is this installation's, as it was kept when the note was made.\n  Each picture is given to the account's server first. Bluesky takes two\n  megabytes for each: one that weighs more is made smaller, and written\n  again as a JPEG when that is not enough. Words become\n  what Bluesky takes: Markdown's marks are taken out, links and the first\n  ten hashtags are marked as such, and what is longer than three hundred letters is cut\n  with a mark to say so; a note so cut carries a card of itself here,\n  where the rest is. A post with pictures or a video has no room for a\n  card, so when it is cut its words end instead with the address of the\n  post here, on a line of its own. A post or a reply that is changed here afterwards\n  is not changed there: Bluesky has no changing a post, and taking it down\n  to post it again would lose its likes, reposts and replies. Mastodon and\n  other servers are sent the change.\n- **Following.** Somebody connected who follows an account on Bluesky from\n  here follows it there too, as their account: it is the only kind of\n  follow Bluesky has, so whoever is followed is told and sees them among\n  their followers. An account that follows them already is left as it is,\n  and nothing is written. Unfollowing here takes back only a follow that\n  was written from here: one made on Bluesky itself stays until it is\n  removed there. Whoever has not connected an account follows here and\n  nowhere else, and nobody is told.\n- **Blocking.** Somebody connected who blocks an account on Bluesky from\n  here blocks it on Bluesky too: a block (`app.bsky.graph.block`) is\n  written to their account, as Bluesky's own app writes one, and taken back\n  when they unblock it here. A block made in Bluesky's app is not this\n  installation's to take back. Written whatever their profile here is open\n  to: a block keeps somebody away and tells nobody else.\n- **Hiding a reply.** Somebody connected who hides a reply from Bluesky\n  under a post of theirs that went there, or a reply of somebody's here\n  that went there, hides it on Bluesky too: it is added to the post's\n  threadgate (`app.bsky.feed.threadgate`, named as the post is), the list\n  of hidden replies every Bluesky app reads and tucks away. Shown again\n  here, it is taken out. Only that reply is changed: replies hidden in\n  Bluesky's own app, and who may reply when that was set there, are left\n  as they are. Whether it is hidden is read when the threadgate is\n  written, so hiding and showing again quickly writes what is so at the\n  end. A threadgate holds 300 hidden replies; past that the oldest go.\n  Deleting the post here removes its threadgate with it. A reply hidden in\n  Bluesky's own app is not hidden here yet.\n- **What comes back.** Each connected account is asked, once a minute,\n  what it was told of on Bluesky, as its own app would show it: who liked\n  or reposted something written from here, who answered it, and who\n  followed the account. Each is kept as the same thing from the fediverse\n  is: whoever did it becomes somebody of another server here, though\n  nobody here follows them, and their like, repost, reply or follow is\n  theirs. The person here is told as of anybody's. Somebody they have\n  blocked does none of it. What happened before the account was connected\n  is not gone back for.\n- **What is taken back there.** An account is told when somebody likes,\n  reposts, answers or follows, and not when they think better of it. So\n  every five minutes, and once soon after the service starts, what is\n  kept here of such things is looked for on Bluesky again: who likes and\n  has reposted each post that went out from here, whether each answer is\n  still there, and whether each follower still follows. What is no longer\n  so is removed here, with the news of it. Nothing is removed on a doubt:\n  not when Bluesky could not be asked, not from a list too long to read to\n  its end (more than five hundred likes), and not what arrived in the last\n  three minutes.\n- **What is taken back.** Taking a like or a repost back here, or deleting\n  the reply, removes it there.\n- **It arrives.** What is to be written waits in a table\n  (`atproto_writes`) and is tried until it is done, for two days, as what\n  goes to the fediverse is.\n- **It is not kept twice.** Bluesky tells of everything an account does,\n  and whoever follows their own account here would be told of their own\n  reply. Each thing written is remembered by the name it was written\n  under (`atproto_sent`) and is known for this installation's own.\n- **When it stops working.** An app password that is removed on Bluesky\n  ends the connection. The first thing that cannot be written marks the\n  account as needing to be connected again, which the card then says.\n- **One person, one profile.** A Bluesky account that somebody here\n  connected is that person. It is not also kept as somebody of another\n  server: what was kept of it before it was connected (its profile here,\n  its posts, and the follows of it) is removed when it is connected, and\n  looking it up afterwards finds the person here.\n- **What they post on Bluesky itself is theirs here.** A post made with\n  the connected account in Bluesky's own app is kept here as a post of\n  the person's own, on their profile, open to everybody as it is there,\n  with its pictures and the card of its link. It is a post like any they\n  wrote here: it is given its hashtags, and goes to the fediverse if they\n  are on it. It is not sent back to Bluesky; a like or an answer given to\n  it here reaches it there; deleted there, it goes from here. Only from\n  when the account was connected. A reply comes too when what it answers\n  is here, and sits under it: the rest of a thread posted there, or an\n  answer to a post from Bluesky that is kept here. One that answers\n  anything else is left, since it would stand without what it answers.\n- **Disconnecting** forgets the token. What was written stays on Bluesky,\n  and can then be removed only there.\n\n| Request | Does |\n| --- | --- |\n| `GET /atproto/settings` | Whether the viewer has connected an account: `connected`, `handle`, `did`, `broken`. `404 bluesky_writing_off` where the installation does not write to Bluesky |\n| `PUT /atproto/settings` `{ \"handle\": \"…\", \"app_password\": \"…\" }` | Connects it. `404 bluesky_not_found`, `403 bluesky_denied` for a password that is not taken, `409 bluesky_taken` when somebody else here connected that account, `502 bluesky_failed`. Ten tries in ten minutes |\n| `DELETE /atproto/settings` | Disconnects |\n| `GET /atproto/settings/import-follows` | What that would do, before it is done: `following`, how many accounts the viewer follows on Bluesky; `most`, the most followed here in one go; `posts_of_each`, how many posts of each are read |\n| `POST /atproto/settings/import-follows` `{ \"limit\": 25 }` | Follows here the accounts the viewer's Bluesky account follows already, the ones followed most lately first: `limit` of them, 25 when none is said and 500 at most, leaving out whoever is refused or blocked. Answered `202` at once and carried on with afterwards: each account is kept and has its recent posts read, which for a long list takes minutes. Nothing is written to Bluesky. Three times an hour |\n\n`ATPROTO_PLC_URL` is where the server that holds an account is looked up:\nleft out, `https://plc.directory`."},{"id":"what-is-not-there-yet","heading":"What is not there yet","level":3,"markdown":"Likes given on Bluesky to\nposts that came from there are not read, nor how many a post has; replies\nby people nobody here follows are not read; videos are not kept; a reply\nhidden in Bluesky's app is not hidden here.\nThe list of [servers that are refused](#servers-that-are-refused) is read\nby this service and still edited through `activitypub-service`, so an\ninstallation that runs this service alone cannot change it from the web\napp."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Bluesky | `atproto-service/`: `bsky/` (the reader, a post's words, Jetstream), `cmd/api/handlers/` (`lookup.go`, `posts.go`, `reposts.go`, `backfill.go`, `stream.go`, `events.go`, `own.go` (one's own Bluesky posts brought in), `import.go` (importing follows), `reactions.go` and `verify.go` (what people on Bluesky do in return, and what they took back), `media.go` and `retention.go` (pictures and posts removed after a while); writing: `settings.go`, `out.go`, `hidden.go` (hidden replies, by threadgate), `bsky/session.go`, `bsky/outgoing.go`), tables `atproto_actors`, `atproto_cursor`, `atproto_reposts`, `atproto_accounts`, `atproto_sent`, `atproto_writes` |\n| What both protocols share | `anahita-libs/federation/`: pictures (`pictures.go`), removing and restoring them (`media.go`), cards (`cards.go`), refused servers (`blocks.go`), a post's words (`content.go`) |\n| The service | `activitypub-service/` |\n| Who is on the fediverse | `cmd/api/handlers/handlers.go`: `siteFederates`, `mayFederate`, `federating` |\n| WebFinger and host-meta | `cmd/api/handlers/discovery.go` |\n| A person's description | `cmd/api/handlers/actor.go`, `ap/actor.go` |\n| The addresses of things | `ap/addresses.go` |\n| Keys | `keys/`, `cmd/api/handlers/keys.go`, table `actor_keys` |\n| Signing and checking requests | `httpsig/` |\n| Reading from other servers | `fetch/`, over `anahita-libs/safehttp` |\n| Who somebody elsewhere is, and what is believed of it | `remote/` |\n| What the bus lost | `cmd/api/handlers/sweep.go`, `data/repositories/missed.go` |\n| Replies | `cmd/api/handlers/replies_in.go`, `notes.go` (`answers`) |\n| Likes and reposts | `cmd/api/handlers/likes.go`, `reposts.go` |\n| Pictures and cards of posts from elsewhere | `cmd/api/handlers/pictures.go`, `cards.go`, over `anahita-libs/federation` |\n| Looking somebody up | `cmd/api/handlers/lookup.go`, table `activitypub_actors` |\n| Their picture, and their earlier posts | `cmd/api/handlers/avatar.go`, `backfill.go` |\n| The inbox, and being followed | `cmd/api/handlers/inbox.go`, table `activitypub_follows` |\n| Following from here | `cmd/api/handlers/events.go`, listening for `edge.follow.*` on the bus (`AMQP_ADDR`) |\n| Posts going out | `cmd/api/handlers/notes.go`, `ap/note.go`, listening for `node.medium.*`, table `activitypub_objects` |\n| Posts coming in | `cmd/api/handlers/inbox.go`: `received`, `withdrawn` |\n| Leaving, and profile changes | `cmd/api/handlers/leaving.go`; from the other side, `inbox.go`: `forget`, `refreshed`; the graph's `DeleteRemoteActor` |\n| Servers that are refused | `cmd/api/handlers/blocks.go`, `data/repositories/blocks.go`, table `federation_domain_blocks` |\n| What NodeInfo says is spoken | `auth-service/cmd/api/handlers/node_info.go`: `federationProtocols` (`FEDERATION_PROTOCOLS`) |\n| What may be done with what came from elsewhere | `anahita-libs/permissions` (`remote_test.go` holds the rule); `actor-service/cmd/api/handlers/person.go`: `personTypeOf` |\n| What is sent | `cmd/api/handlers/deliver.go`, `ap/activity.go`, table `activitypub_deliveries`, over `anahita-libs/federation` (`queue.go`) |\n| The installation's own actor | `cmd/api/handlers/instance.go` |\n| A person's choice | `cmd/api/handlers/settings.go`, table `activitypub_accounts` |\n| The gateway's routes | `nginx/nginx.conf`: `/.well-known/webfinger`, `/.well-known/host-meta`, `/inbox`, `/ap/`, `/activitypub/` |\n| `ACTIVITYPUB_LAB` | Lets the service fetch from private addresses. For [the lab](https://www.anahita.io/docs/services/federation-lab/) only; refused with `ENV=prod` |\n| What it shares with the Bluesky service to come | `anahita-libs/federation`, `anahita-libs/site` |"},{"id":"further-reading","heading":"Further reading","level":2,"markdown":"The standards behind each part, for anybody who wants them from the source."},{"id":"activitypub-and-the-fediverse","heading":"ActivityPub and the fediverse","level":3,"markdown":"| Read | For |\n| --- | --- |\n| [ActivityPub](https://www.w3.org/TR/activitypub/) | The protocol: actors, inboxes and outboxes, and the activities between them. A W3C recommendation |\n| [Activity Streams 2.0](https://www.w3.org/TR/activitystreams-core/) and [its vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) | The shape of what is sent: `Note`, `Article`, `Follow`, `Like`, `Place` and the rest |\n| [WebFinger, RFC 7033](https://www.rfc-editor.org/rfc/rfc7033) | How `@ana@example.org` is found ([What a person is called](#what-a-person-is-called)) |\n| [Mastodon's security notes](https://docs.joinmastodon.org/spec/security/) | HTTP signatures as the fediverse signs requests ([Signing keys and signed requests](#signing-keys-and-signed-requests)) |\n| [NodeInfo](https://nodeinfo.diaspora.software/) | How a server says what it is and what it speaks |\n| [Fediverse Enhancement Proposals](https://codeberg.org/fediverse/fep) | What servers agree on beyond the standard, one proposal at a time |\n| [activitypub.rocks](https://activitypub.rocks/) and [SocialHub](https://socialhub.activitypub.rocks/) | Where the people who build the fediverse write and talk |"},{"id":"the-at-protocol-and-bluesky","heading":"The AT Protocol and Bluesky","level":3,"markdown":"| Read | For |\n| --- | --- |\n| [The overview](https://atproto.com/guides/overview) | The ideas: identities, repositories, relays and app views |\n| [The specifications](https://atproto.com/specs/atp) | The protocol itself, among them [DIDs](https://atproto.com/specs/did) and [Lexicon](https://atproto.com/specs/lexicon), its schemas |\n| [Bluesky's API docs](https://docs.bsky.app/) | The app's own records and endpoints, which [Bluesky](#bluesky) here reads and writes |\n| [Bluesky and the AT Protocol: Usable Decentralized Social Media](https://arxiv.org/abs/2402.03239) | Why it is designed as it is, by the people who designed it |"}]},{"source":"services","slug":"websites","title":"Websites","group":"Fediverse and Bluesky","url":"https://www.anahita.io/docs/services/websites/","sections":[{"id":"","heading":null,"level":1,"markdown":"A website, as a kind of profile here, is a site's feed to follow, here and\nfrom the fediverse. Any site with an RSS, Atom or JSON feed on a domain its owner\nproves is theirs can be connected: a WordPress or other site, a static site,\nan app's changelog, a podcast. Each new item of the feed becomes a note of\nthe website, its title, an excerpt and a link to the original, and people\nreply, like, repost and quote it as any post. Nobody posts on a website; its\nadministrators answer under its posts as the website.\n\nWhy it is built this way is in the plan, [https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/website.md](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/website.md)."},{"id":"who-does-what","heading":"Who does what","level":2,"markdown":"| Piece | Does |\n| --- | --- |\n| `syndication-service` | Connecting a website: finds its feed, gives the token, checks the proof, makes the website. Takes WebSub hubs' calls. The only service besides link-preview-service that fetches addresses people type, and it goes only to public addresses on ports 80 and 443 (`anahita-libs/safehttp`) |\n| `syndication-worker` | Reads every website's feed and posts what is new; looks at every proof again once a day; pauses and starts websites again. Answers no requests |\n| `actor-service` | Serves the website as a profile under `/websites/`, as it serves groups: read, change, administrators, delete |\n| `text-service` | Under a website's post, makes a reply by one of its administrators the website's |\n| `activitypub-service` | The website as an `Organization` on the fediverse, once its administrators turn it on, and its notes |\n| `atproto-service` | Its notes on Bluesky, as its own Bluesky account, when one of its administrators connects one |\n\nA website's node is `node.actor.actor-service.website.v1`, its service\n`actor-service`. Both services are optional: left out, there are no websites,\nand nothing else changes."},{"id":"connecting-a-website","heading":"Connecting a website","level":2,"markdown":"\"Connect your website\", never \"create\". In the web app at `/websites/connect`,\nfor somebody `WEBSITES_FROM` allows (administrators unless the installation\nsays otherwise), in three calls:\n\n1. `POST /websites/_connect` with the site's address (`example.com`, or its\n   feed's). The feed is the address itself, or the first feed the page names\n   in its head with `<link rel=\"alternate\">`. It must be on the site's domain\n   or under it: `blog.example.com` is example.com's, `feeds.example.net` is\n   not. A feed that already has a website is refused. The answer is the token,\n   where to put it, and a name and handle to begin with: the feed's title,\n   and the domain's first part (`@example`).\n2. `POST /websites/_connect/:id/check` looks for the token, as often as asked.\n3. `POST /websites/_connect/:id` with a name, a handle and a description makes\n   the website: public, with whoever proved the domain as its first\n   administrator and first follower. The feed's ten newest items are posted\n   at once, each with its own date, so that a new website does not open empty;\n   the rest are marked seen, and from then on only what is published is\n   posted.\n\nA connection is the person's who began it: anybody else is told it is not\nthere.\n\n**A private feed is refused** (`private_feed`): one behind a login, which\nanswers 401 or 403, and one whose address carries a secret, a user and\npassword, or a parameter such as `token`, `key`, `secret` or `auth` (a paid\npodcast's feed, a private repository's). A website's posts are public and go\nto the fediverse, where they cannot be called back, so what a feed keeps for\nsome is never made public here."},{"id":"proving-a-domain","heading":"Proving a domain","level":2,"markdown":"The token, `anahita-` and 32 hexadecimal characters, goes in either place:\n\n| Where | What |\n| --- | --- |\n| A TXT record | `_anahita.<domain>`, holding the token |\n| A file | `https://<host>/.well-known/anahita-verification.txt`, the token on a line of its own |\n\nThe domain is the site's host without a leading `www.`; the file is read from\nthe host as given. The record is looked at first. The proof is\n`anahita-libs/domains`, built to be shared: people and groups are to prove\ndomains the same way ([roadmap row 63](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/verified-domains.md))."},{"id":"reading-the-feed","heading":"Reading the feed","level":2,"markdown":"Every 30 minutes (`WEBSITE_READ_EVERY`), with the `ETag` and `Last-Modified`\nthe server sent the last time, so an unchanged feed is not sent again. At\nmost 5 MB, 10 seconds and 3 redirects. A feed that fails is read again 15\nminutes later, then 30, an hour, and so on to once a day; how often it has\nfailed and why are kept in `websites`. A website is taken by one worker at a\ntime, so more than one may run."},{"id":"its-posts","heading":"Its posts","level":2,"markdown":"Each new item becomes a note of the website (the user, 2026-10-16: a site's\nitem, shown here, is a short post pointing elsewhere, as a person sharing a\nlink is, and not writing done here):\n\n| Of the note | From the item |\n| --- | --- |\n| Its words | The item's title in bold, as a note has no title of its own; its excerpt (the first 300 characters of its text, plain, cut at a word); the original's address; its categories as hashtags, one word each (`Open Science` is `#OpenScience`), five at most. What Markdown would read as marks in the title or the excerpt is kept as written |\n| Card | The original's, from link-preview-service: the one card a note carries |\n| Date | The item's, unless it says the future |\n| Audience | Public |\n\nIts author is the website, so it is shown and sent as the website's; it is\nmade as the website's first administrator, who is kept in it as\n`written_by`, as for its replies. At most 10 an hour (`WEBSITE_HOURLY_CAP`),\noldest first; the rest wait for the next hour. Items are known by their id,\nor their address when they have none, and none is posted twice. One whose\ntitle or excerpt changes changes its note; one that leaves the feed leaves\nits note as it is, since feeds seldom say that something was deleted.\n\n**A website's own notes follow its site.** Nobody changes their words or\nnarrows who sees them, its administrators and site administrators included:\nboth are refused with `follows_its_website`, and the web app offers neither.\nThey are deleted, and replies under them hidden, as any post's; who may\nreply and quote stays the administrators' to choose."},{"id":"replies-as-the-website","heading":"Replies as the website","level":2,"markdown":"Under a website's own post, a reply in words by one of its administrators is\nthe website's: its owner and author are the website, so the site answers its\nreaders as itself. Which administrator wrote it is kept in the reply as\n`written_by`, never sent with it; a report about it shows it to those who\nhandle reports. The website's replies are changed by whoever replies as it.\nAnywhere else, and by anybody else, a reply is its writer's."},{"id":"on-the-fediverse","heading":"On the fediverse","level":2,"markdown":"A website is on the fediverse once one of its administrators turns it on in\nits settings (Fediverse), and off until then, as a person is: sharing is\nchosen, never assumed. Turning it off asks first, and tells every server\nthat knows it to remove it. While it is on, WebFinger finds it by its handle\n(`@garden@example.org`), and it is an `Organization`, not a `Service`, which\nMastodon marks as automated.\nIts notes go out as `Note`s, the website's, with their card, which is how\nMastodon shows a post best: an `Article` would be shown there as its title\nand a link. Replies to them come back into its threads, and its own replies\ngo out, as for a person's notes ([Federation](https://www.anahita.io/docs/services/federation/)). Only what it\nposts after being turned on goes out. It is `GET` and\n`PUT /activitypub/settings?person_id=` with the website's id, for one of its\nadministrators, as for a person about themselves; anybody else, a site\nadministrator among them, is refused with `403`. A website switched off or\ndeleted is not on the fediverse."},{"id":"on-bluesky","heading":"On Bluesky","level":2,"markdown":"A website is followed on Bluesky through its own Bluesky account, which one\nof its administrators connects in its settings (Fediverse), with a password\nmade for an app, as a person connects theirs\n([Federation](https://www.anahita.io/docs/services/federation/#bluesky)). Its domain can be its handle there\n(`@example.com`), proved on Bluesky's side by a DNS record. From then on its\nnotes are posted there too, as that account, and people on Bluesky follow\nit there; its replies under posts from Bluesky go there as it. What the\naccount posts on Bluesky itself is not made a post of the website here: a\nwebsite's posts come from its feed alone. It is `PUT /atproto/settings`\nwith `profile_id`, for one of its administrators; anybody else is refused\nwith `bluesky_not_yours`.\n\nIts handle here has no dot (`@example`), on purpose: here, a dot in a\nhandle is somebody on Bluesky (`/@ana.bsky.social`), so `@example.com`\nwould name its Bluesky account, not it."},{"id":"websub","heading":"WebSub","level":2,"markdown":"A feed that names a hub (`rel=\"hub\"`) is subscribed to, its own address\n(`rel=\"self\"`) as the topic, for ten days and asked again before they end.\nThe hub calls back at `/websites/_websub/:id/:signature`, the signature an HMAC\nof the website's id with `SECURE_COOKIE_KEY`: a check is answered with its\nchallenge, and a push brings the next read forward to now, at most once a\nminute. What a hub sends is not read; the feed is always read from the site.\nOnly where a hub could call back: an installation reachable from the\ninternet over https, and open to read: a members-only site refuses hubs. WordPress names a hub only with a plugin."},{"id":"pausing-and-starting-again","heading":"Pausing and starting again","level":2,"markdown":"| Paused when | Started again when |\n| --- | --- |\n| The domain's proof has been gone a week (`proof_gone`) | It is found again, at the next daily look |\n| The website is switched off, deleted (in its grace period) or archived (`profile_gone`) | It is switched on or restored; looked at once an hour |\n\nA paused website's feed is not read."},{"id":"settings","heading":"Settings","level":2,"markdown":"| Variable | Read by | Default | Means |\n| --- | --- | --- | --- |\n| `WEBSITES_FROM` | syndication-service, auth-service (NodeInfo `websitesFrom`) | `administrators` | The least role that may connect a website: `registered`, `administrators` or `super-administrators`. Give both the same value |\n| `WEBSITE_HOURLY_CAP` | syndication-worker | 10 | The most posts a website makes in an hour |\n| `WEBSITE_READ_EVERY` | syndication-worker | 30 | How often a feed is read, in minutes; 15 at least |\n| `LINK_PREVIEW_URL` | syndication-worker | `http://link-preview-service:80` | Where the original's card is asked for; unset, notes have none |\n| `SECURE_COOKIE_KEY`, `DOMAIN_NAME`, `PUBLIC_*` | syndication-service, syndication-worker | | The key the WebSub signature is made with, and the address hubs call back at |"},{"id":"the-tables","heading":"The tables","level":2,"markdown":"All three are syndication-service's (migrations `20261120000001` and\n`20261121000001`).\n\n| Table | Holds |\n| --- | --- |\n| `verified_domains` | A domain being proved or proved: who proves it, for which profile, the token, how and when it was proved, last looked at, failing since |\n| `websites` | One row per website: its feed (unique), its site, its domain, its state, what the feed's server said last, the WebSub hub, when it is read next, failures, this hour's posts |\n| `website_items` | Every item seen, by its key, with the note it became and a hash of its title and excerpt: `seen`, `waiting` or `posted` |"},{"id":"endpoints","heading":"Endpoints","level":2,"markdown":"| Endpoint | Who | What |\n| --- | --- | --- |\n| `POST /websites/_connect` | `WEBSITES_FROM` | The site's feed, and the token |\n| `POST /websites/_connect/:id/check` | whoever began it | `{ proved, method }` |\n| `POST /websites/_connect/:id` | whoever began it | The website: `{ id, alias }` |\n| `GET`, `POST /websites/_websub/:id/:signature` | a hub | A check, and a push |\n| `GET /websites/`, `GET /websites/:id` | anybody | The websites, and one (actor-service) |\n| `PATCH /websites/:id` | its administrators | Its name and description; its handle and site stay as connected |\n| `DELETE /websites/:id`, `POST /websites/:id/restore`, `/admins/` | as for a group | |\n\nRefusals say why as `error`: `not_a_domain`, `no_feed`, `unreadable`,\n`feed_not_on_domain`, `private_feed` (400); `feed_taken`, `handle_taken`, `not_proved` (409).\nThe full reference is [`docs/api/`](https://www.anahita.io/api/syndication/)."},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- A reply with pictures, a GIF or a video by an administrator under a\n  website's post is still their own, since its files are uploaded for them.\n- An administrator is not told when their website is paused, or why.\n- A website is not suggested in recommendations."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Proving a domain | `anahita-libs/domains` |\n| Connecting a website, WebSub calls | `syndication-service/cmd/api` |\n| Finding and reading feeds | `syndication-service/feeds` |\n| The worker | `syndication-service/worker`, `syndication-service/cmd/worker` |\n| WebSub | `syndication-service/websub` |\n| The website as a profile | `actor-service/cmd/api/handlers/website.go` |\n| Replies as the website | `anahita-libs/permissions/reply.go` (`RepliesAs`), `anahita-libs/handlers/reply_owner.go` |\n| On the fediverse | `activitypub-service` (`isWebsite`) |\n| A website's notes follow its site | `anahita-libs/permissions/medium.go` (`IsWebsiteItem`) |\n| The web app | `src/containers/websites`, `src/api/websiteConnect.js` (anahita-web) |"}]},{"source":"services","slug":"federation-lab","title":"The federation lab","group":"Fediverse and Bluesky","url":"https://www.anahita.io/docs/services/federation-lab/","sections":[{"id":"","heading":null,"level":1,"markdown":"Trying federation on one machine: this installation and another server,\neach at a name of its own, speaking to each other over https."},{"id":"why-it-is-needed","heading":"Why it is needed","level":2,"markdown":"Servers that federate find each other by name and speak only over https, and\na server keeps what it is told another is called. None of that can be tried\nat `http://localhost`. The lab gives a development stack what a site on the\ninternet has: names, certificates, and a server it did not write to be\nunderstood by.\n\nThe names end in `.test`, which is set aside for this and can never be a\nreal site. Nothing in the lab is reachable from outside your machine,\nthough it can reach out: see [Real servers, from the lab](#real-servers-from-the-lab)."},{"id":"what-is-in-it","heading":"What is in it","level":2,"markdown":"| Name | What answers | Container |\n| --- | --- | --- |\n| `https://api.anahita.test` | This installation's API, as other servers reach it | the gateway, `nginx` |\n| `https://anahita.test` | The web app's host, where a handle such as `ana@anahita.test` is looked up | `web-shell`, when it is running |\n| `https://gts.test` | [GoToSocial](https://gotosocial.org), a small server that speaks ActivityPub as Mastodon does | `gotosocial` |\n\nWhile the lab is up, `activitypub-service` is this installation's part in\nit: `make fedi_lab` starts it again as the site `anahita.test`, reached at\n`https://api.anahita.test`, trusting the lab's authority and nothing else,\nand allowed to fetch from the private addresses the lab's servers are at\n(`ACTIVITYPUB_LAB=on`).\n`make fedi_lab_down` puts it back as it was.\n\n`fedi-lab-tls` is the front door: an nginx that holds the certificate and is\nknown on the Docker network by all three names, so each server reaches the\nother by name, as two servers on the internet would. The certificate is\nsigned by an authority made for the lab (`scripts/fedi-lab-certs.sh`, in\n`fedi-lab/certs/`, which Git ignores). GoToSocial is told to trust that\nauthority, and to fetch from the lab's private addresses, which it would\notherwise refuse as any careful server does."},{"id":"starting-it","heading":"Starting it","level":2,"markdown":"With the stack running (`make up`):\n\n~~~sh\nmake fedi_lab\n~~~\n\nIt is not started by `make up`, and takes port 443 on your machine while it\nruns."},{"id":"opening-it-in-a-browser","heading":"Opening it in a browser","level":2,"markdown":"Add the names to `/etc/hosts`:\n\n~~~\n127.0.0.1 anahita.test api.anahita.test gts.test\n~~~\n\nand, to be spared the warnings, trust `fedi-lab/certs/ca.crt` in your\nbrowser or keychain. It signs nothing but the lab; remove it when you are\ndone with the lab."},{"id":"somebody-to-talk-to","heading":"Somebody to talk to","level":2,"markdown":"GoToSocial starts with nobody on it. Make somebody:\n\n~~~sh\ndocker compose --profile fedi-lab exec gotosocial \\\n  /gotosocial/gotosocial admin account create \\\n  --username alice --email alice@gts.test --password '<a long password>'\n~~~\n\nShe is then `@alice@gts.test`, and can sign in at `https://gts.test` with\nany Mastodon-compatible client."},{"id":"having-gotosocial-look-somebody-up","heading":"Having GoToSocial look somebody up","level":2,"markdown":"The real test of whether this installation is understood: ask the other\nserver to find somebody here by their handle. `fedi-lab/gts-token.sh` signs\na GoToSocial account in and prints a token of theirs:\n\n~~~sh\nrun() { docker run --rm --network anahita-services_anahita-network \\\n  -v \"$PWD/fedi-lab:/lab:ro\" \"$@\"; }\n\nTOKEN=$(run --entrypoint sh curlimages/curl /lab/gts-token.sh alice@gts.test '<her password>')\n\nrun curlimages/curl -s --cacert /lab/certs/ca.crt -H \"Authorization: Bearer $TOKEN\" \\\n  \"https://gts.test/api/v2/search?q=@ana@anahita.test&resolve=true&type=accounts\"\n~~~\n\nGoToSocial then asks `anahita.test` who that is, is sent on to the API,\nfetches the person's description and their pictures, and answers with the\naccount as it understood it. Somebody who has not turned federation on is\nnot found. Until the web app has the switch, it is turned on for a person\nwith `PUT /activitypub/settings` ([Federation](https://www.anahita.io/docs/services/federation/#the-api))."},{"id":"turning-the-fediverse-on-and-off-for-somebody","heading":"Turning the fediverse on and off for somebody","level":2,"markdown":"Nobody is signed in when the lab is driven from a terminal, so the switch\nin a person's settings has commands that do the same:\n\n~~~sh\ndocker compose exec activitypub-service /app/app-binary lab-join <username here>\ndocker compose exec activitypub-service /app/app-binary lab-leave <username here>\n~~~\n\n**Leaving cannot be tried twice against the same GoToSocial.** It is told\nthe person is deleted, marks them suspended, and does not take them back\nwhen they join again: which is what leaving means, and was found out here.\n`make fedi_lab_reset` empties it; make its accounts again afterwards."},{"id":"following-somebody-here-from-gotosocial","heading":"Following somebody here from GoToSocial","level":2,"markdown":"With a token for alice and the id GoToSocial gave the person here when it\nlooked them up (the `id` in the search's answer):\n\n~~~sh\nG() { run curlimages/curl -s --cacert /lab/certs/ca.crt -H \"Authorization: Bearer $TOKEN\" \"$@\"; }\n\nG -X POST \"https://gts.test/api/v1/accounts/<id>/follow\"            # \"requested\": true\nG \"https://gts.test/api/v1/accounts/relationships?id[]=<id>\"        # a few seconds on: \"following\": true\nG -X POST \"https://gts.test/api/v1/accounts/<id>/unfollow\"\n~~~\n\nBetween the first and the second, GoToSocial sent a signed `Follow` to the\ninbox, this installation checked it, made the follow, and sent back a\nsigned `Accept`, which GoToSocial checked in its turn. If it stays\n\"requested\", the reason is in `docker compose logs activitypub-service`\n(a message refused) or in `activitypub_deliveries` (an answer not taken)."},{"id":"following-somebody-on-gotosocial-from-here","heading":"Following somebody on GoToSocial from here","level":2,"markdown":"Until the web app can show somebody from another server, the lab has a\ncommand that does what pressing Follow would. It is refused unless\n`ACTIVITYPUB_LAB` is on.\n\n~~~sh\ndocker compose exec activitypub-service /app/app-binary lab-follow <username here> alice@gts.test\n\nG \"https://gts.test/api/v1/follow_requests\"                         # she approves her followers: the request is waiting\nG -X POST \"https://gts.test/api/v1/follow_requests/<id>/authorize\"  # she says yes\n\ndocker compose exec activitypub-service /app/app-binary lab-unfollow <username here> alice@gts.test\n~~~\n\nThe person here has to be on the fediverse. After her yes,\n`activitypub_follows` has the follow as `accepted`; after the unfollow it\nis gone, and GoToSocial no longer lists them among her followers."},{"id":"posts-both-ways","heading":"Posts, both ways","level":2,"markdown":"With alice following somebody here, and somebody here following alice:\n\n~~~sh\n# From here to there.\ndocker compose exec activitypub-service /app/app-binary lab-post <username here> \"Hello **fediverse** #anahita\"\nG \"https://gts.test/api/v1/timelines/home?limit=5\"                 # it is in her timeline\ndocker compose exec activitypub-service /app/app-binary lab-unpost <the note's number>\n                                                                    # and gone from it\n\n# From there to here.\nG -X POST \"https://gts.test/api/v1/statuses\" --data-urlencode \"status=Bonjour\" -d visibility=public\nG -X DELETE \"https://gts.test/api/v1/statuses/<id>\"\n~~~\n\nWhat arrived is a note in the development database whose `uri` begins\n`https://gts.test/`. A note posted from the web app, by somebody who is on\nthe fediverse, goes out the same way while the lab is up: `lab-post` is\nonly for when nobody is signed in."},{"id":"looking-somebody-on-gotosocial-up-from-here","heading":"Looking somebody on GoToSocial up from here","level":2,"markdown":"The other direction, and the test that what this installation signs is\naccepted: GoToSocial answers for nobody without a signature it can check.\n\n~~~sh\ndocker compose exec activitypub-service /app/app-binary lookup @alice@gts.test\n~~~\n\nIt prints who was found, and keeps her as a person of another server in the\ndevelopment database."},{"id":"asking-as-another-server-would","heading":"Asking as another server would","level":2,"markdown":"From inside the network, trusting only the lab's authority:\n\n~~~sh\ndocker run --rm --network anahita-services_anahita-network \\\n  -v \"$PWD/fedi-lab/certs:/certs:ro\" curlimages/curl -s --cacert /certs/ca.crt \\\n  \"https://gts.test/.well-known/webfinger?resource=acct:alice@gts.test\"\n\ndocker run --rm --network anahita-services_anahita-network \\\n  -v \"$PWD/fedi-lab/certs:/certs:ro\" curlimages/curl -s --cacert /certs/ca.crt \\\n  https://api.anahita.test/nodeinfo/2.1\n~~~\n\nA service of ours that is to speak to the lab has to trust the same\nauthority: mount `./fedi-lab/certs` into it and set `SSL_CERT_FILE` to the\n`ca.crt` there, as the `gotosocial` entry in `docker-compose.yml` does."},{"id":"stopping-and-emptying-it","heading":"Stopping and emptying it","level":2,"markdown":"~~~sh\nmake fedi_lab_down     # stop it; what GoToSocial holds is kept\nmake fedi_lab_reset    # stop it and empty GoToSocial\n~~~\n\n**Empty it whenever this installation's identity changes** (a fresh\ndatabase, different keys). A server remembers who it was told somebody is,\nand will refuse the same name with a different key."},{"id":"real-servers-from-the-lab","heading":"Real servers, from the lab","level":2,"markdown":"The lab is not sealed off from the internet, and that is of use: a\ncontainers's requests go out as anybody's do.\n\n- **Reading works.** `lookup @somebody@mastodon.social`, or the same from\n  the search page, finds a real person on a real server, with their\n  picture. Mastodon answers for a public profile without being able to\n  check who asks.\n- **Writing does not, on purpose.** The lab calls itself `anahita.test`,\n  a name no real server can look up, so nothing it sent could be checked\n  there. `activitypub-service` therefore sends nothing from the lab to a\n  server whose name does not end in `.test`, and a follow of somebody on a\n  real server is undone as soon as it is made. Following and posting to\n  real servers is for a site with a real name: see\n  [Federation](https://www.anahita.io/docs/services/federation/#setting-it-up), step 8."},{"id":"what-it-is-not","heading":"What it is not","level":2,"markdown":"- **Not the internet.** Mastodon itself, Threads and the rest each behave a\n  little differently. Before a release, federation is tried against real\n  servers through a tunnel to a throwaway name of its own, never one that\n  will later be a real site's.\n- **Not for Bluesky.** Bluesky needs no lab: reading it and writing to it\n  for a connected account both work from a developer's machine."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The profile, its two containers and its volume | `docker-compose.yml`, `fedi-lab` |\n| The front door | `fedi-lab/nginx.conf` |\n| The certificates | `scripts/fedi-lab-certs.sh` → `fedi-lab/certs/` |\n| A token for a GoToSocial account | `fedi-lab/gts-token.sh` |\n| `fedi_lab`, `fedi_lab_down`, `fedi_lab_reset` | `Makefile` |"}]},{"source":"services","slug":"developing","title":"Developing","group":"Build on Anahita","url":"https://www.anahita.io/docs/services/developing/","sections":[{"id":"","heading":null,"level":1,"markdown":"The pages for writing your own app or service against an Anahita site.\n\n1. **The API.** How requests and answers are shaped, lists and paging,\n   lengths, what the viewer may do, and refusals: [The API](https://www.anahita.io/docs/services/api/). Every\n   route, with what it takes and sends, is in the API reference:\n   `docs/api/index.html` ([The reference](https://www.anahita.io/docs/services/api/#the-reference)).\n2. **Signing in.** An app signs people in with OAuth, and asks for the scopes\n   it needs: [OAuth scopes and clients](https://www.anahita.io/docs/services/oauth/), and\n   [Signing in](https://www.anahita.io/docs/services/api/#signing-in).\n3. **How the site is put together.** The services, the routes, the graph of\n   nodes and edges, and the shared library: [Architecture](https://www.anahita.io/docs/services/architecture/).\n4. **Services a site may not run.** What an app is told when one is missing:\n   [Services an installation may not run](https://www.anahita.io/docs/services/api/#services-an-installation-may-not-run).\n5. **A service you can replace.** Recommendations keep a contract that\n   another service can keep in their place:\n   [The contract](https://www.anahita.io/docs/services/recommendations/#the-contract).\n6. **An AI agent beside you.** The docs and the API reference in Claude,\n   Gemini, Cursor or VS Code, over MCP: [Anahita MCP](https://www.anahita.io/docs/services/ai-agents/).\n7. **The web app** as an example of a client:\n   [its architecture](https://www.anahita.io/docs/web/architecture/).\n8. **Other networks.** What an Anahita site sends to the fediverse and\n   Bluesky, and takes from them: [Federation](https://www.anahita.io/docs/services/federation/).\n9. **Over gRPC, inside the cluster.** A service of your own, in any\n   language, calling the graph, files and mail services directly:\n   [Services over gRPC](https://www.anahita.io/docs/services/grpc/), with examples in Go,\n   Node.js, Python, Ruby and PHP.\n10. **The licences.** An app or a service of your own may have any licence:\n   the API is open to any client, and the protocol buffer definitions are\n   Apache 2.0; the services and `anahita-libs` are AGPL-3.0:\n   [Licensing](https://www.anahita.io/docs/services/licensing/).\n\nNot written yet:\n\n1. **The events on the bus:** each event on the `anahita_events` exchange,\n   what it carries, and who listens to it. Today\n   [Architecture](https://www.anahita.io/docs/services/architecture/#components) says only that it is there.\n2. **Writing a service of your own,** from an empty folder to running beside\n   the others.\n3. **Writing a client,** from registering it to its first signed-in request."}]},{"source":"services","slug":"api","title":"The API","group":"Build on Anahita","url":"https://www.anahita.io/docs/services/api/","sections":[{"id":"","heading":null,"level":1,"markdown":"What is true of every route of Anahita's API. What each route takes and sends\nis in the reference beside this page, [`docs/https://www.anahita.io/api/`](https://www.anahita.io/api/), made from the code."},{"id":"the-reference","heading":"The reference","level":2,"markdown":"[`docs/https://www.anahita.io/api/`](https://www.anahita.io/api/) holds an [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0)\ndocument for each service whose routes are described (`hashtag-service.json`),\nand `anahita.json`, all of them in one. Any OpenAPI tool reads them: a\nreference viewer, a client generator, a test tool, an agent asked to call the\nAPI. Every service with routes is described.\n\n**For reading, open [`docs/https://www.anahita.io/api/`](https://www.anahita.io/api/) in a browser.** It\nis the same reference as a page: a section for each service, under the same\ngroups as these docs (People, Posts, Fediverse and Bluesky, Run a site), each route with\nwhat it takes and answers, and every shape by name, with a box that filters\nthem. It is one file and fetches nothing, so it opens with the network off. A\nroute is linked to by its `operationId` (`api/index.html#getHashtagsByAlias`),\na service by its name (`#hashtag-service`), a shape by `#schema-` and its\nname; each prose page links to its routes.\n\n**On [www.anahita.io/api/](https://www.anahita.io/api/)** the same reference\nis pages of the site, made from these files: a page for each service, each\nroute beside its request in curl, JavaScript and Python and an example of\nits answer, and the services in a sidebar under their groups.\n\nThey are made from the code, not written by hand, and `make test` fails when\nthey are out of date (see [Describing a route](#describing-a-route)). Each\noperation says:\n\n- who may call it, in words and as `x-viewer`: `anyone` (signed in or not),\n  `signed_in`, `admin` (a site administrator, or a super administrator) or\n  `super_admin`, with the OAuth scope its token must carry, and `x-step-up`\n  where the person must just have proved who they are again;\n- what it takes: values in the path, the query and the body, with what each\n  may be. A rule JSON Schema has no word for is kept as it is written in the\n  code, as `x-validate` (`hashtag` is a hashtag's own rule);\n- what it sends, and what it refuses, with why.\n\nThe documents describe the wire, as a client sees it. The web app turns what\nit is sent into camelCase and back (`src/api` in anahita-web), so its own code\nis no guide to the names."},{"id":"reading-and-writing","heading":"Reading and writing","level":2,"markdown":"- **Names are snake_case:** `created_at`, `likes_count`.\n- **A post has a `title` and `content`**, as WordPress and ActivityPub\n  call them: every kind of post, a reply among them, is sent and taken\n  with these. A profile (a person, a group, an event), a custom feed and a\n  hashtag have a `name`, and a profile or a feed says what it is in\n  `body`. A list that holds both sends each with its own words; the type\n  (`node.medium.…` for a post) says which it is.\n- **Ids are numbers.** A person is also found by their username\n  (`/people/ana`), and `GET /actors/<alias>` says what kind of profile an\n  alias is, with its id.\n- **Times** are RFC 3339 in UTC, and their names end in `_at`. A date with no\n  time ends in `_on`.\n- **A field that is empty is often left out** rather than sent empty: the\n  reference marks as required only what is always there. A list with nothing\n  in it may be `null`.\n- **Bodies** are JSON, except where a file is sent, which is\n  `multipart/form-data`. auth-service's sign-in, sign-up and recovery\n  pages are HTML, and their forms post `application/x-www-form-urlencoded`;\n  its OAuth routes take forms, as RFC 6749 has them."},{"id":"lists","heading":"Lists","level":2,"markdown":"A route that lists is paged by two values in the query:\n\n| Value | Means |\n| --- | --- |\n| `start` | How many to pass over. 0 unless asked |\n| `limit` | The most to send. 20 unless asked |\n\nand answers with what is on the page and where it is:\n\n```json\n{\n  \"data\": [ … ],\n  \"pagination\": { \"offset\": 40, \"limit\": 20, \"total\": 113 }\n}\n```\n\n`data` is `null` when the page is empty. A few lists are not paged and send an\narray alone (the hashtags used most lately, for one), or `data` alone; the\nreference says which. A few answers of one thing come in the same envelope,\n`{ \"data\": { … } }`.\n\n**Custom feeds are paged by what came before**, since counting one would mean\nreading all of it: `GET /feeds/<id>/items` takes `cursor` instead of `start`,\nand answers with `pagination.next_cursor` and `pagination.has_more`, and no\ntotal ([https://www.anahita.io/docs/services/feeds/](https://www.anahita.io/docs/services/feeds/)). The home feed and a profile's are paged by\n`start`, as other lists are."},{"id":"lengths","heading":"Lengths","level":2,"markdown":"How long what people write may be. The same in every service and in the\nweb app, and fit for the networks it goes to. Counted in characters; a\nlonger one is refused with `400`.\n\n| What | Most | Why |\n| --- | --- | --- |\n| A display name: a person's, a group's, an event's | 64 | Bluesky's limit. Mastodon writes 40 and accepts longer from other servers |\n| A username, and so a handle | 3 to 30 | Mastodon's |\n| What a person or a group says of itself | 500 | Mastodon's. Bluesky's is 256, but a profile here is never written there |\n| A post's title | 100 | |\n| What a post says: a note, a reply, an article, a topic, a photo's or a video's description; an event's details | 8,000 | Mastodon shows a post of any length from another server. Bluesky is sent the first 300 characters and, for a note, a link to the rest |\n| A location's name | 100 | |\n| An image's description, written here | 2,000 | Bluesky's own app. One from another server is kept up to 10,000, Mastodon's |\n\nThe numbers are `anahita-libs/constants/limits.go`, mirrored in the web\napp's `src/constants/limits.js`; `requests/limits_test.go` holds the shared\nrequests to them. A few older profiles and posts are longer than these: they\nshow as they are, and are shortened only when somebody next edits them."},{"id":"what-the-viewer-may-do","heading":"What the viewer may do","level":2,"markdown":"A post, a reply or a profile is sent with `authorized`: what the viewer may do\nwith it, as the server's own rules answer, so that a client offers only what\nwill work. `edit`, `delete` and `administration` are always there; the rest\nare there when they were worked out for this answer, and their absence is not\na no:\n\n| Field | On | Says whether the viewer may |\n| --- | --- | --- |\n| `comment` | a post, a reply | reply to it |\n| `like` | a post, a reply | like it |\n| `quote` | a post | quote it |\n| `pin` | a post | pin it to the profile it is on |\n| `hide` | a reply | hide it from its thread, or show it again |\n| `block_from_owner` | a post or a reply in a group or an event | block its writer from there, as the group |\n| `download` | a photo or video post | download what it holds |\n| `add_follower`, `invite` | a profile | add a follower, invite people to follow it |\n| `composers`, `audiences` | a profile; a post | what may be posted there, and who it may be shown to |\n\nWho may do what, and why, is in [https://www.anahita.io/docs/services/permissions/](https://www.anahita.io/docs/services/permissions/)."},{"id":"signing-in","heading":"Signing in","level":2,"markdown":"A route for somebody signed in takes an access token from auth-service:\n`Authorization: Bearer <token>`. How a person signs in is in\n[https://www.anahita.io/docs/services/signing-in/](https://www.anahita.io/docs/services/signing-in/); how a client is registered, and the scopes a\ntoken carries, in [https://www.anahita.io/docs/services/oauth/](https://www.anahita.io/docs/services/oauth/). A route for anyone also reads a token\nwhen there is one: the viewer decides what is seen."},{"id":"refusals","heading":"Refusals","level":2,"markdown":"| Status | Means here |\n| --- | --- |\n| `400` | The request is not well formed, or a value in it is not one it may be |\n| `401` | No token, or one that has expired |\n| `403` | The viewer may not do this, or may not see it |\n| `404` | There is no such thing |\n| `409` | It clashes with something there already: a name that is taken |\n| `410` | It was there and has been removed |\n| `422` | Well formed, and not something that can be done to it: pinning a reply |\n| `429` | Too many requests of this kind lately; try again later |\n| `501` | Registered, and not built |\n\nMost refusals have no body. Some say why, as `{ \"error\": \"<reason>\" }`, often\nwith `description`, the same in words, and the reasons are listed with the\nroute. The OAuth routes (`/oauth/token`, `/oauth/revoke`, `/oauth/introspect`,\n`/oauth/userinfo`) answer as RFC 6749 has it: `error` and\n`error_description`. What one route answers for something the\nviewer may not see is not always what another does: each route's refusals are\nin the reference."},{"id":"services-an-installation-may-not-run","heading":"Services an installation may not run","level":2,"markdown":"Some services are optional: recommendations, abuse reports, link previews,\nvideo, data export, websites, and the two for other networks\n([https://www.anahita.io/docs/services/architecture/](https://www.anahita.io/docs/services/architecture/)). Where one is not run, its routes are\nnot there, and a client takes a `404`, or a `501` or above from the gateway,\nfor there being nothing, not for a failure, as the web app does. What an installation speaks\nto other networks is in its NodeInfo (`/.well-known/nodeinfo`,\n[https://www.anahita.io/docs/services/federation/](https://www.anahita.io/docs/services/federation/))."},{"id":"describing-a-route","heading":"Describing a route","level":2,"markdown":"A route is described where it is registered, through `anahita-libs/apidoc`,\nwhich registers it with echo and keeps its description, so its path is\nwritten once:\n\n```go\napi := doc.On(e)\nhashtags := api.Group(\"hashtags\")\nhashtags.GET(\"/:alias\", r.handler.Hashtag.Read, r.setViewer).\n    Summary(\"One hashtag\").\n    Viewer(apidoc.Anyone).\n    Takes(requests.NewHashtagRequest().Read).\n    Sends(http.StatusOK, responses.HashtagNodeItem{}).\n    Refuses(http.StatusNotFound, \"there is no such hashtag\")\n```\n\n- **`Takes`** is the struct the handler binds, as the handler makes it: its\n  `param`, `query`, `header`, `json` and `form` tags say where each value\n  comes from, its `validate` tags what it may be, and a value set before\n  binding is the default.\n- **`Sends`** is what the handler answers with. A list's answer is\n  `responses.List[T]`, the one shape every list has.\n- **The first paragraph of the comment on each field is its description,**\n  read from the source, so nothing is worded twice. As in Go's own\n  documentation, the first paragraph says what the field is, for whoever\n  calls the API; what follows it (why it is never left out, what broke\n  before) is for whoever works on the code, and stays out of the reference.\n  A field with no comment has no description.\n- `SendsNothing`, `Describe`, `Scope`, `StepUp`, `Internal` and `NotBuilt`\n  say the rest. A handler that reads its values one by one is described\n  with a struct it does not bind, marked `Unbound`, and an upload's file is\n  an `apidoc.File`. `Media` is the type of a route's bodies where it is\n  not JSON (`application/activity+json`, `text/html`), `TakesBody` a body\n  that is one value and not the fields of a struct, and `Router.Static` a\n  directory of files.\n  `400`, `401` and `403` are added where a route of its kind can answer them.\n\n**Register each route on a line of its own,** beginning with its group\n(`hashtags.GET(\"/:alias\", …)`): the members-only guard in\nanahita-libs/middleware reads the routes files as text, and checks that each\nroute says who may call it. A route registered in a loop is not seen. Two\nroutes that share a description take it with `With`:\n`previews.GET(\"/\", h, r.authorize).With(readPreview)`.\n\nA named struct of anahita-libs is described once and named as it is\n(`NodeItem`); one of a service is named with its module\n(`hashtag.HashtagNodeItem`).\n\nThree checks run in `make test`, from a test beside each described service's\nroutes (`cmd/api/apidoc_test.go`), one in anahita-libs for `anahita.json`\nand the page, and the handler tests:\n\n1. **Every route registered is described, and every description is of a\n   route.** A route added with echo directly, past apidoc, fails by name.\n2. **What is committed in `docs/api/` is what the code makes now.** After\n   changing a route, or a struct it takes or sends, run `make api_docs` and\n   commit what it writes with the change.\n3. **What a handler answers in its tests is what the reference says.** The\n   status must be one the route lists, an answer it sends with nothing in it\n   must have nothing in it, and a body must be of its shape: no field\n   missing that is always there, none of the wrong type or not one of its\n   values, and none the reference does not name. A 500 is not checked. Each\n   service's handler tests keep their answers in `answers_test.go`\n   (`answers.Handler(h.Read, rec)` after calling a handler, or\n   `answers.Route(\"GET /notes/7\", rec)`), and they are checked once the\n   tests have run; a test with a `*testing.T` at hand can call\n   `apidoc.Conforms` itself. A new handler test should keep its answers too.\n\nEach operation also says, as `x-handler`, the Go function that answers it.\n\nA service is added to `make api_docs` when its routes are described, and to a\ngroup in `anahita-libs/apidoc/groups.go`: `anahita.json` carries the groups\nas `x-tagGroups`, which Redoc and Scalar show as headings, and `make test`\nfails while a service is in none."}]},{"source":"services","slug":"oauth","title":"OAuth scopes and clients","group":"Build on Anahita","url":"https://www.anahita.io/docs/services/oauth/","sections":[{"id":"","heading":null,"level":1,"markdown":"How scopes are defined and enforced, and how first-party clients are seeded.\n\nThe OAuth routes are in the API reference: [auth-service](https://www.anahita.io/api/auth/), from [`/oauth/authorize`](https://www.anahita.io/api/auth/#getOauthAuthorize) and [`/oauth/token`](https://www.anahita.io/api/auth/#postOauthToken) on."},{"id":"scopes","heading":"Scopes","level":2,"markdown":"Scope identifiers are defined **once**, in Go, at\n`anahita-libs/constants/oauth_scopes.go`. Every permission check must\nreference an `OAuthScope*` constant — never a string literal. `make test`\nruns a guard that walks the whole workspace and fails on any\n`HasScope(\"...\")` / `RequireScope(\"...\")` with a literal argument.\n\nThat guard exists because the fleet once carried three generations of\nscope names simultaneously. Since `HasScope` is a plain slice lookup, a\nliteral matching no constant fails closed and silently — the gate returns\n\"insufficient scope\" forever, for everyone, with nothing in the logs\nnaming the cause. The compiler catches a mistyped constant; only the\nguard catches a reintroduced literal.\n\nScopes describe **API surface**. Authority (admin / super-admin) is a\nseparate role check inside each handler's permission method, layered on\ntop of the write scope. There is deliberately no `admin:*` scope tier.\n\nA scope family covers its sub-resources rather than giving each one its\nown scope: `actors:*` covers avatars, covers, access, features and group\nadmins; `media:*` covers photos, articles, notes, topics and their\nhashtag/location tags. Reads of public content need no scope at all — a\nread scope gates the authenticated-visibility uplift, not the public\nsurface — which is why `taxonomy` has no read half.\n\nA service with no family of its own borrows the one its actions belong to.\nFiling an abuse report needs `account:write`: it is something a person does\non their own account's behalf. Reading and deciding reported cases needs\n`actors:read` and `actors:write`, as the signup queue does.\n\nThe current catalog: `openid`, `profile`, `email`, `offline_access`,\n`actors:read`, `actors:write`, `media:read`, `media:write`,\n`likes:write`, `socialgraph:read`, `socialgraph:write`,\n`notifications:read`, `notifications:write`, `taxonomy:write`,\n`account:read`, `account:write`, `oauth:read`, `oauth:write`.\n\nThere is no scope for replies. A reply is a post, written under `media:write`.\n`comments:write` was removed with comments: a client registered with it has to\nbe registered again without it, and the seeded clients are in\n`auth-defaults/oauth_seed_clients.json`.\n\n`oauth:*` is the sole super-admin-only family. A token carrying\n`oauth:write` is equivalent to owning the identity provider — its holder\ncan add a redirect URI to any client and harvest authorization codes for\nevery person on the site."},{"id":"seeding-clients","heading":"Seeding clients","level":2,"markdown":"`auth-defaults/oauth_seed_clients.json` bootstraps the first-party\nclients (`anahita-web`, `anahita-mobile`) on first boot.\n\n**The seed file is bootstrap-only, not a source of truth.** A client_id\nthat already exists is left untouched, so editing `scopes` in this file\nand restarting does *nothing* — the only signal is a single\n`already exists, skipping` log line. That is the right default: it\nguarantees a redeploy never silently reverts an edit made through the\n`/clients` admin endpoint.\n\nTo apply a changed shipped scope list:\n\n~~~sh\ndocker compose exec mysql mysql -uroot -proot anahita \\\n  -e \"DELETE FROM oauth_clients WHERE client_id = 'anahita-web';\"\ndocker compose restart auth-service\n~~~\n\nThis removes the client's row and nothing else: the schema has no foreign\nkeys, so its refresh tokens and authorizations stay. They name the client by\nits `client_id`, so when the restart seeds the client again, they work with it\nas before, and nobody is signed out. On a live site, `PATCH /clients/:id`\ninstead.\n\n`DELETE /clients/:id` is different: it revokes the client's refresh tokens and\ndeletes its authorization codes, then the client. Everybody signed in with it\nis signed out within 15 minutes, when their access token expires, and a client\nmade later under the same `client_id` starts with no tokens.\n\nScopes are validated against the catalog both at boot (a typo fails\nstartup) and at the `/clients` BREAD boundary (a bad scope returns 400\nnaming it)."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The scope catalog | `anahita-libs/constants/oauth_scopes.go` (`OAuthScopeDefinitions`, `OAuthScopeExists`) |\n| The guard against scope literals | `anahita-libs/constants/oauth_scopes_guard_test.go` (`TestNoScopeStringLiterals`, `TestOnlyOAuthScopesAreSuperAdminOnly`) |\n| Checking requested scopes | `auth-service/data/services/oauth_scopes.go` (`ValidateScopes`, `ValidateScopesForClient`) |\n| Seeding clients | `auth-service/data/services/oauth_seed.go` (`SeedDefaultClients`), `auth-defaults/oauth_seed_clients.json`; setting `OAUTH_SEED_CLIENT_PATH` |\n| The `/clients` endpoints | `auth-service/cmd/api/handlers/oauth_clients.go` (`DeleteClient`, tested in `oauth_clients_test.go`), `auth-service/cmd/api/permissions/oauth.go` |\n| Clients and what hangs off them | tables `oauth_clients`, `oauth_authorizations`, `oauth_refresh_tokens` |"}]},{"source":"services","slug":"grpc","title":"Services over gRPC","group":"Build on Anahita","url":"https://www.anahita.io/docs/services/grpc/","sections":[{"id":"","heading":null,"level":1,"markdown":"A service of your own, running in the same cluster as an Anahita site, can\ncall Anahita's three gRPC services directly: the graph of profiles, posts\nand the links between them, the files, and mail. It may be written in any\nlanguage that has gRPC, and be under any licence: the definitions it is made\nfrom are Apache 2.0 ([Licensing](https://www.anahita.io/docs/services/licensing/)).\n\nMost things built on Anahita should use [the API](https://www.anahita.io/docs/services/api/) instead: an app, a\nbot, or a service outside the cluster. The gRPC services are the inside of\na site, and trust whoever calls them (see [Checking access](#checking-access))."},{"id":"what-you-need","heading":"What you need","level":2,"markdown":"| What | Where |\n| --- | --- |\n| The definitions | `anahita-libs/proto`: 22 `.proto` files, imported as `proto/graphpb/node.proto` and the like, so code is generated from `anahita-libs` |\n| A certificate from the site's own authority | [A certificate](#a-certificate) |\n| The services' addresses, inside the cluster | `graph-grpc-service:50051`, `file-grpc-service:50051` and `mail-grpc-service:50051`, the `*_SERVICE_ADDR` settings in `k8s/base/config.env` |\n| gRPC for your language | Below, with the versions these examples were tried with |\n\nEvery example here is in [`examples/grpc/`](https://github.com/anahitasocial/anahita-services/blob/main/examples/grpc/), one folder\nfor each language, and reads the same two settings Anahita's services do:\n`GRAPH_SERVICE_ADDR`, and `GRPC_TLS_DIR`, the folder holding the\ncertificate (`/run/secrets/grpc` unless said). They are Apache 2.0, like the\ndefinitions, so that they can be copied."},{"id":"a-certificate","heading":"A certificate","level":2,"markdown":"Anahita's services accept only callers with a certificate from the site's\nown authority, `anahita-grpc`, which cert-manager keeps in the cluster\n([Certificates between the services](https://www.anahita.io/docs/services/deploying/#certificates-between-the-services)).\nAsk it for one for your service:\n\n```yaml\napiVersion: cert-manager.io/v1\nkind: Certificate\nmetadata:\n  name: anahita-grpc-my-service\n  namespace: anahita\nspec:\n  secretName: anahita-grpc-my-service\n  commonName: my-service\n  usages:\n    - client auth\n  duration: 2160h\n  privateKey:\n    algorithm: ECDSA\n    size: 256\n  issuerRef:\n    name: anahita-grpc\n    kind: Issuer\n```\n\nand mount its Secret where your service reads it:\n\n```yaml\n      containers:\n        - name: my-service\n          volumeMounts:\n            - name: grpc-tls\n              mountPath: /run/secrets/grpc\n              readOnly: true\n      volumes:\n        - name: grpc-tls\n          secret:\n            secretName: anahita-grpc-my-service\n```\n\nThe Secret holds `tls.crt`, `tls.key` and `ca.crt`. cert-manager renews it\nbefore it runs out, in place; a service that keeps running for longer than\nthe certificate's 90 days must read it again, or be restarted."},{"id":"generating-the-code","heading":"Generating the code","level":2,"markdown":"The code is generated from the definitions with\n[buf](https://buf.build/docs/installation/), with plugins it fetches itself,\nso no compiler or plugin is needed on your machine. Each folder has its own\n`buf.gen.yaml`, with the plugins' versions; run, in it:\n\n```sh\nbuf generate ../../../anahita-libs --path ../../../anahita-libs/proto/graphpb\n```\n\nThe generated code goes into `gen/`. Node.js reads the `.proto` files when\nit starts, so nothing is generated for it. The libraries each needs:\n\n**Go**\n\n```sh\n# go.mod: google.golang.org/grpc v1.76.0, google.golang.org/protobuf v1.36.10.\n# Inside a checkout of anahita-services, prefix go with GOWORK=off: its\n# go.work does not list the example.\nbuf generate ../../../anahita-libs --path ../../../anahita-libs/proto/graphpb\ngo mod tidy\n```\n\n**Node.js**\n\n```sh\n# package.json: @grpc/grpc-js 1.14.6, @grpc/proto-loader 0.8.1\nnpm install\n```\n\n**Python**\n\n```sh\n# requirements.txt: grpcio 1.84.0, protobuf 7.36.2\npip install -r requirements.txt\nbuf generate ../../../anahita-libs --path ../../../anahita-libs/proto/graphpb\n```\n\n**Ruby**\n\n```sh\n# Gemfile: grpc 1.84.0, google-protobuf 4.36.2. A compiler is needed for one\n# of their dependencies, as in the full ruby image, not ruby:slim.\nbundle install\nbuf generate ../../../anahita-libs --path ../../../anahita-libs/proto/graphpb\n```\n\n**PHP**\n\n```sh\n# PHP's grpc extension, as a client; serving gRPC from PHP needs a\n# long-running server such as RoadRunner.\npecl install grpc\n# composer.json: grpc/grpc 1.82.0, google/protobuf 5.36.2\ncomposer install\nbuf generate ../../../anahita-libs --path ../../../anahita-libs/proto/graphpb\n```"},{"id":"reading-a-node","heading":"Reading a node","level":2,"markdown":"Each example reads one node by its id, with `NodeService.Read`, and prints\nits type, alias and name:\n\n```text\n1 node.actor.person-service.person.v1 @dax Dax\n```\n\n**Go**\n\n```go\ncert, err := tls.LoadX509KeyPair(filepath.Join(tlsDir, \"tls.crt\"), filepath.Join(tlsDir, \"tls.key\"))\nif err != nil {\n\tlog.Fatal(err)\n}\nca, err := os.ReadFile(filepath.Join(tlsDir, \"ca.crt\"))\nif err != nil {\n\tlog.Fatal(err)\n}\npool := x509.NewCertPool()\npool.AppendCertsFromPEM(ca)\ncreds := credentials.NewTLS(&tls.Config{\n\tMinVersion:   tls.VersionTLS13,\n\tCertificates: []tls.Certificate{cert},\n\tRootCAs:      pool,\n})\n\nconn, err := grpc.NewClient(address, grpc.WithTransportCredentials(creds))\nif err != nil {\n\tlog.Fatal(err)\n}\ndefer conn.Close()\n\nctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)\ndefer cancel()\nreply, err := graphpb.NewNodeServiceClient(conn).Read(ctx, &graphpb.NodeReadRequest{Id: id})\nif err != nil {\n\tlog.Fatal(err)\n}\n\nnode := reply.GetNode()\nfmt.Println(node.GetId(), node.GetType(), \"@\"+node.GetAlias(), node.GetName())\n```\n\n**Node.js**\n\n```js\nconst definition = protoLoader.loadSync('proto/graphpb/node.proto', {\n  includeDirs: [protoRoot],\n  longs: String,\n  defaults: true,\n});\nconst { graph } = grpc.loadPackageDefinition(definition);\nconst read = (name) => fs.readFileSync(path.join(tlsDir, name));\n\nconst credentials = grpc.credentials.createSsl(read('ca.crt'), read('tls.key'), read('tls.crt'));\n\nconst nodes = new graph.NodeService(address, credentials);\nnodes.Read({ id }, { deadline: Date.now() + 5000 }, (error, reply) => {\n  nodes.close();\n  if (error) {\n    console.error(error.message);\n    process.exit(1);\n  }\n  const { node } = reply;\n  console.log(node.id, node.type, `@${node.alias}`, node.name);\n});\n```\n\n**Python**\n\n```python\ncredentials = grpc.ssl_channel_credentials(\n    root_certificates=read(\"ca.crt\"),\n    private_key=read(\"tls.key\"),\n    certificate_chain=read(\"tls.crt\"),\n)\n\nwith grpc.secure_channel(address, credentials) as channel:\n    nodes = node_pb2_grpc.NodeServiceStub(channel)\n    reply = nodes.Read(node_pb2.NodeReadRequest(id=node_id), timeout=5)\n\nnode = reply.node\nprint(node.id, node.type, \"@\" + node.alias, node.name)\n```\n\n**Ruby**\n\n```ruby\ncredentials = GRPC::Core::ChannelCredentials.new(read.('ca.crt'), read.('tls.key'), read.('tls.crt'))\n\nnodes = Graph::NodeService::Stub.new(address, credentials)\nreply = nodes.read(Graph::NodeReadRequest.new(id: node_id), deadline: Time.now + 5)\n\nnode = reply.node\nputs [node.id, node.type, \"@#{node.alias}\", node.name].join(' ')\n```\n\n**PHP**\n\n```php\n$nodes = new \\Graph\\NodeServiceClient($address, [\n    'credentials' => \\Grpc\\ChannelCredentials::createSsl($read('ca.crt'), $read('tls.key'), $read('tls.crt')),\n]);\n\n[$reply, $status] = $nodes->Read(\n    new \\Graph\\NodeReadRequest(['id' => $nodeId]),\n    [],\n    ['timeout' => 5_000_000],\n)->wait();\nif ($status->code !== \\Grpc\\STATUS_OK) {\n    fwrite(STDERR, $status->details . \"\\n\");\n    exit(1);\n}\n\n$node = $reply->getNode();\necho $node->getId(), ' ', $node->getType(), ' @', $node->getAlias(), ' ', $node->getName(), \"\\n\";\n```\n\nThe whole of each, with where it reads its settings and the node's id, is\nin its folder in `examples/grpc/`. The other calls are in the `.proto`\nfiles: each `service` block lists what it answers, and each message what it\ntakes and gives."},{"id":"checking-access","heading":"Checking access","level":2,"markdown":"**The gRPC services do not check who may see what.** A node read from the\ngraph comes back whoever it belongs to and whoever may see it: a post only\nits author's followers may read, a profile that is private, a person who has\nblocked another. Anahita's own services check before they answer anybody,\nwith the rules in `anahita-libs/permissions`\n([Permissions](https://www.anahita.io/docs/services/permissions/)). A service of yours that shows what it reads\nto people must make the same checks itself, or ask through [the API](https://www.anahita.io/docs/services/api/),\nwhich makes them for it, as the person who is asking.\n\nAny holder of a certificate from the authority may call any of the three\nservices, so keep the Secret as you would a password."},{"id":"trying-it-on-your-own-machine","heading":"Trying it on your own machine","level":2,"markdown":"On the [k3d cluster](https://www.anahita.io/docs/services/deploying/#with-k3d): ask for the certificate above,\ncopy its three files out of the Secret, forward graph-grpc's port, and run\nan example in a container that knows the service by its name in the\ncluster, so that its certificate is accepted:\n\n```sh\nkubectl --kubeconfig ~/.config/k3d/kubeconfig-anahita.yaml -n anahita \\\n  port-forward --address 127.0.0.1 svc/graph-grpc-service 50051:50051\n\ndocker run --rm --add-host graph-grpc-service:host-gateway \\\n  -v \"$PWD/certs:/run/secrets/grpc:ro\" -v \"$PWD:/example\" -w /example \\\n  python:3.12-slim sh -c 'pip install -r requirements.txt && python client.py 1'\n```\n\nThe examples were each tried this way, against a site's graph, on\n2026-10-26. Where `host-gateway` gives an IPv6 address that does not answer,\nas it can for PHP's gRPC, give the host's IPv4 address instead\n(`getent ahostsv4 host.docker.internal` in a container)."}]},{"source":"services","slug":"architecture","title":"Architecture","group":"Build on Anahita","url":"https://www.anahita.io/docs/services/architecture/","sections":[{"id":"","heading":null,"level":1,"markdown":"How the services, the gateway and the shared library fit together."},{"id":"components","heading":"Components","level":2,"markdown":"Three kinds of components:\n\n**HTTP services** (JSON, behind nginx) own an API resource family. They do\nnot talk to each other over HTTP.\n\n**gRPC services** are shared infrastructure the HTTP services call\ndirectly: `graph-grpc-service` owns the node/edge graph and every read\nagainst it, `file-grpc-service` owns file storage, `mail-grpc-service`\nowns outbound email. These calls use mutual TLS: each side presents a\ncertificate from the installation's own authority, so a gRPC service answers\nonly the other services (`anahita-libs/grpctls`; see\n[Internal calls](https://www.anahita.io/docs/services/configuration/#internal-calls)).\n\n**RabbitMQ** carries asynchronous fan-out. Services publish domain events\nto the `anahita_events` exchange; consumers (notifications, feed, search)\nreact. Event names are versioned constants in `anahita-libs/event`, shaped\n`node.<type>.<service>.<entity>.<action>.v1` and\n`edge.<type>.<service>.<action>.v1`."},{"id":"services","heading":"Services","level":2,"markdown":"| Service | HTTP port | gRPC port | Responsibility |\n| --- | --- | --- | --- |\n| `nginx` | 80 | — | Gateway, routing, CORS, token validation subrequest |\n| `auth-service` | 8081 | — | OAuth 2.0 / OIDC provider, server-rendered sign-in and sign-up, email verification, registration applications, the administration list of accounts, agreements, password, TOTP, WebAuthn, invites, clients, NodeInfo |\n| `actor-service` | 8082 | — | The profiles of people, of groups and of events. One service for every kind of actor; see [below](#one-service-for-every-kind-of-actor) |\n| `text-service` | 8084 | — | Text media: articles, notes, topics |\n| `photo-service` | 8085 | — | Photos |\n| `hashtag-service` | 8086 | — | Hashtags |\n| `location-service` | 8087 | — | Locations and geocoding |\n| `notification-service` | 8091 | 50057 | Notifications |\n| `socialgraph-service` | 8092 | — | Follow, block, subscribe relationships |\n| `search-service` | 8093 | — | Search |\n| `like-service` | 8095 | 50058 | Votes / likes |\n| `identity-image-service` | 8096 | — | The avatars and covers of profiles and posts; see [Naming images](#naming-images) |\n| `feed-service` | 8098 | 50059 | Feeds and reposts |\n| `recommendation-service` | 8100 | | Suggestions of who to follow. Optional |\n| `link-preview-service` | 8101 | — | Fetches the page a link points to and keeps its title and picture, for the card on a note. The only service that connects to addresses people type. Optional. See [Link previews](https://www.anahita.io/docs/services/link-previews/) |\n| `syndication-service` | 8107 | — | Connecting a website: finds its feed and checks its domain's proof; takes WebSub hubs' calls. Goes only to public addresses. Optional. See [Websites](https://www.anahita.io/docs/services/websites/) |\n| `syndication-worker` | — | — | Reads every website's feed and posts what is new; looks at every proof again daily. Answers no requests |\n| `video-service` | 8103 | — | Videos: uploads sent straight to storage, and the posts made of them. Optional. See [Videos](https://www.anahita.io/docs/services/videos/) |\n| `video-worker` | — | — | Makes the sizes of each uploaded video with ffmpeg, then the post. Answers no requests |\n| `export-service` | 8104 | — | A person's copy of their own data: asking for one and fetching it. Optional. See [Download your data](https://www.anahita.io/docs/services/data-export/) |\n| `export-worker` | — | — | Makes each archive asked for, and deletes the ones kept their time. Answers no requests |\n| `web-shell` | 8102 | — | Sends the web app, with each page's own title, description and picture written into `index.html`, so a shared link shows as itself. On the web app's host, not the API's. Optional. See [The web shell](https://www.anahita.io/docs/services/web-shell/) |\n| `atproto-service` | 8106 | — | Bluesky: accounts there are looked up and followed from here, and their posts kept as they are written; for whoever connects their own Bluesky account, their public posts, replies, likes, reposts and follows are written there as them. The one service that holds a connection open to the outside. Optional. See [Bluesky](https://www.anahita.io/docs/services/federation/#bluesky) |\n| `activitypub-service` | 8105 | — | Makes the installation a server of the fediverse: people who choose to be found, and their posts, replies, likes, reposts and follows both ways; also the list of servers the site refuses. The one service that reads what other servers send. Optional. See [Federation](https://www.anahita.io/docs/services/federation/) |\n| `abuse-report-service` | 8099 | — | Reports of people and posts, and the cases administrators decide. See [Abuse reports](https://www.anahita.io/docs/services/abuse-reports/) |\n| `graph-grpc-service` | — | 50054 | Node/edge graph: actors, media, edges, traversal, trending |\n| `file-grpc-service` | — | 50055 | File storage on any S3-compatible object store; checks what each upload really is, and signs the addresses large files are sent to directly |\n| `mail-grpc-service` | — | 50056 | Email delivery over SMTP (50051 inside its container) |\n| `migration-job` | — | — | One-shot database migrations |\n| `account-sweeper` | — | — | What is done by the clock, hourly: purges accounts past their 30-day deletion grace period, and finishes purges a super administrator asked for; removes images uploaded for a post that was never made, and invitations nobody answered; reminds people of an event a day before, and archives one thirty days after it ended. Not started by `up`; run with `docker compose run --rm account-sweeper`. See [Account lifecycle](https://www.anahita.io/docs/services/account-lifecycle/) |\n\nBacking services in `docker-compose.yml`:\n\n- **MySQL** (3306) — all persistent data\n- **Redis** (6379) — sessions\n- **RabbitMQ** (5672) — the `anahita_events` exchange\n- **Garage** (3900) — object storage, S3-compatible, for local development"},{"id":"routing","heading":"Routing","level":2,"markdown":"nginx maps public paths to services. The full map lives in\n`nginx/nginx.conf`, and the same gateway runs in docker-compose and on\nKubernetes, where the Ingress sends everything to it\n(see [Deploying](https://www.anahita.io/docs/services/deploying/)):\n\n| Path prefix | Service |\n| --- | --- |\n| `/oauth/`, `/auth`, `/signup`, `/signup-requests`, `/accounts`, `/agreements`, `/onboarding`, `/password`, `/email`, `/username`, `/reauth`, `/totp`, `/webauthn`, `/invites`, `/clients`, `/auth-logs/`, `/oauth-signing-keys`, `/.well-known/`, `/nodeinfo/`, `/static/` | `auth-service` |\n| `/articles/`, `/notes/`, `/topics/`, `/bookmarks/` | `text-service` |\n| `/people/`, `/groups/`, `/events/` | `actor-service` |\n| `/photos/` | `photo-service` |\n| `/videos/` | `video-service` |\n| `/.well-known/webfinger`, `/.well-known/host-meta`, `/inbox`, `/ap/`, `/activitypub/` | `activitypub-service` |\n| `/atproto/` | `atproto-service` |\n| `/exports` | `export-service` |\n| `/avatars/`, `/covers/` | `identity-image-service` |\n| `/likes/` | `like-service` |\n| `/hashtags/` | `hashtag-service` |\n| `/locations/` | `location-service` |\n| `/feeds/`, `/reposts/` | `feed-service` |\n| `/recommendations/` | `recommendation-service` |\n| `/link-previews` | `link-preview-service` |\n| `/websites/_connect`, `/websites/_websub/` | `syndication-service` (`/websites/_websub/` not signed in) |\n| `/websites/` | `actor-service` |\n| `/notifications/` | `notification-service` |\n| `/abuse-reports` | `abuse-report-service` |\n| `/socialgraph/` | `socialgraph-service` |\n| `/search/` | `search-service` |\n\n`/internal/oauth/validate` is an internal-only subrequest target used by\nnginx to validate bearer tokens against `auth-service`. The web app keeps its\ntokens server-side behind a session cookie; for the locations that need it,\nnginx asks auth-service to translate that cookie into a `Bearer` token before\nproxying, so the services only ever see bearer tokens.\n\nA list of posts is always one profile's: `GET /notes/<owner id>/`, and the same\nfor `/articles/`, `/topics/` and `/photos/`. There is no list of every post of\na type across the site, and a request without an owner is refused. Posts are\ndiscovered through feeds, search, hashtags and profiles."},{"id":"no-stories","heading":"No stories","level":3,"markdown":"There is no story service and there are no stories, and nothing records who\nlooked at what. A story service existed as a stub and was removed; so, after\nbeing built, was a row of \"who posted lately\" on the home page, which a new\ninstallation with few people on it has no use for yet. What may come later is\nin the web app's roadmap, under Kept for later."},{"id":"custom-feeds","heading":"Custom feeds","level":3,"markdown":"A custom feed is a node of type `node.feed.feed-service.generator.v1`, owned\nby whoever made it, with what it shows as JSON in the node's `meta`. Keeping\none on the home page is an edge, `edge.feed.pin.v1`. feed-service serves them\n(`POST /feeds/`, `GET /feeds/<id>/items` and the rest), and reads a feed's\nposts from the graph over `FeedCustomService.Browse`, which builds its query\nfrom the definition and gates it for whoever is reading. The definition and\nits rules are in `anahita-libs/feeds`, used on both sides. See\n[Custom feeds](https://www.anahita.io/docs/services/feeds/)."},{"id":"pinned-posts","heading":"Pinned posts","level":3,"markdown":"`nodes.pinned` says a post is pinned to the profile it is on, and\n`nodes.pinned_at` when, which is the order pins are shown in. A profile's\nposts (`GET /feeds/actor/<id>/`) and its lists of one kind\n(`GET /notes/<owner id>/` and the like) are led by what is pinned: one\nordering over the whole list, so a pinned post is on the first page and on no\nother. A profile has `constants.MaxPinnedPosts`, one. The rules are in\n[Permissions](https://www.anahita.io/docs/services/permissions/#pinned-posts).\n\nAn ordinary edit of a post neither pins nor unpins it. That is done through\nthe pin routes alone, which keep the limit and the date."},{"id":"quote-posts","heading":"Quote posts","level":3,"markdown":"A quote is a note with `quote_id`, the post it quotes. Nothing else about it\nis different from a note: it is made with `POST /notes/<owner id>/`, listed in\nfeeds and on profiles as a note, and gated as one. The rules are in\n[Permissions](https://www.anahita.io/docs/services/permissions/#quotes).\n\n| On `nodes` | Holds |\n| --- | --- |\n| `quote_id` | On the quoting note: the post it quotes. Set once, when the note is made |\n| `quote_detached_at` | On the quoting note: when the quoted post's author took their post out |\n| `quote_policy` | Who may quote: on a post, that post; on a person, their posts when a post does not say |\n| `quote_count` | On the quoted post: the notes that still quote it. Counted over the rows, on every quote, removal and detach, and after a purge |\n\n- **A note that quotes is sent `quote`**: `{ \"post\": … }` when the reader may\n  see the quoted post, or `{ \"state\": \"unavailable\" | \"detached\" }`. Each\n  service that sends notes fills it in with `handlers.SetQuotes`, which first\n  asks the graph which of the quoted posts this reader may see\n  (`FilterVisible`, the gate every listing uses) and reads only those.\n  Reading one post through the graph checks nothing: it is the filter that\n  decides. A response whose handler\n  did not look the post up carries `quote_id` and no `quote` at all.\n- **`authorized.quote`** on a post says whether the viewer may quote it. In a\n  list it is exact for a post on its author's own profile; for a post\n  somebody wrote in a group it does not know the author's own setting, and\n  making the quote asks again with everything.\n- **Routes**: `PATCH /{articles,notes,topics,photos}/<id>/quote-policy`,\n  `PATCH /people/<id>/quote-policy`, `DELETE /notes/<id>/quote`, and\n  `GET /notes/<id>/quotes` for the notes that quote a post."},{"id":"embeds-and-citations","heading":"Embeds and citations","level":3,"markdown":"An article or a topic shows what an address on a line of its own is to: a\npost here as itself, a page elsewhere by its card. An address inside a\nsentence stays a link. The text stays plain Markdown, so other servers are\nsent the link it is. How a line is read is `anahita-libs/embeds`, and the\nweb app reads it the same way.\n\n- **A post here** is not copied into the article. The reader's browser asks\n  for it as for any post, so somebody who may not see it gets the link and\n  nothing more.\n- **A citation** is the edge from the article to each post here it shows\n  (`edge.medium.citation.v1`). text-service writes the citations when the\n  article or topic is added or edited, and takes away those whose line is\n  gone. An address that could not be read, or is not a post, is not cited.\n- **A mention** is a post naming a profile, a person, a group or an event:\n  by its `@handle`, or by its address on a line of its own, which shows its\n  card. It is the edge from the profile to the post (`edge.tag.mention.v1`),\n  kept by actor-service; see [Permissions](https://www.anahita.io/docs/services/permissions/#mentions).\n- **Who cites what.** Each post and profile keeps how many people cite it,\n  a profile counting the citations of its posts,\n  `nodes.citation_count`, which [Search](https://www.anahita.io/docs/services/search/) and\n  [Recommendations](https://www.anahita.io/docs/services/recommendations/) weigh. `GET /notes/:id/cited_by`\n  lists the posts citing a post of any kind, newest first, as the viewer\n  may see them; the web app shows them in a post's Cited by tab, beside\n  Replies and Locations. What an article or a topic cites is listed at the\n  end of its own text.\n- **A page elsewhere** is shown by its card, kept in the post's `meta` under\n  `link_previews` and sent as `link_previews`: see\n  [Link previews](https://www.anahita.io/docs/services/link-previews/#cards-in-long-writing)."},{"id":"the-media-library","heading":"The media library","level":3,"markdown":"A photo or video post can be in its author's media library only\n(`nodes.library_only`): put up from inside the article editor, to be shown\nin an article. It is read by its address with the access any post has, so\nthe access gates do not know of it; the listings leave it out, each by\n`listedQuery` (graph-grpc-service `data/repositories/listed.go`): feeds, a\nprofile's lists, search, a hashtag's and a location's posts. It cannot be\npinned. Nothing announces it: the graph publishes no event for it, so no\nnotification, hashtag or mention is made of it, and neither federation\nservice sends it, its sweeper included.\n\n- **Made** with `library_only: true` on `POST /photos/<own id>/` or when a\n  video's upload begins (`video_uploads.library_only`, carried to the post\n  by the worker). Only on one's own profile; never a reply. Set once.\n- **Listed** by `GET /photos/<id>/?library=1` and `/videos/<id>/?library=1`:\n  all of the viewer's own photos or videos on their own profile, these\n  with them. Nobody else's.\n- A post saved before it was in a library, or quoted, is still found\n  where it was: those lists are the viewer's own doing."},{"id":"saved-posts","heading":"Saved posts","level":3,"markdown":"Somebody can save a post, of any kind, to find it again: `PUT` and\n`DELETE /bookmarks/<id>`, and `GET /bookmarks/` for their own list, most\nrecently saved first. A post they have saved carries `is_saved_by_viewer`.\n\n- **Private.** A saved post is an edge from the person to the post\n  (`edge.base.bookmark.v1`), and every question about it is asked with the\n  asker's own id. Nobody can ask what somebody else saved or who saved a\n  post; nothing is counted on the post, no event is published, and its author\n  is not told.\n- **Saving does not keep a post open.** The list goes through the gate a post\n  is read through. A post saved while public and since made private, or whose\n  author has since blocked the person, is left out, and is back if it is\n  opened up again. It can always be taken out of the list.\n- **No folders.** One list."},{"id":"a-posts-activity","heading":"A post's activity","level":3,"markdown":"Who liked a post, reposted it, quoted it and replied to it is four lists\nthat already exist or follow from what does:\n\n| List | From |\n| --- | --- |\n| Likes | `GET /likes/<id>/` |\n| Reposts | `GET /reposts/<id>/` |\n| Quotes | `GET /notes/<id>/quotes` |\n| Replies | `GET /notes/<id>/thread` |\n\nEach is for anybody who can see the post, and each lists only the people and\nnotes that reader may see, so a list can be shorter than the count on the\npost. There is no count of views and no list of who looked: nothing records\nthat. The plan that had both (`plans/post-insights.md`) was set aside for\nthis."},{"id":"a-profiles-three-lists","heading":"A profile's three lists","level":3,"markdown":"`GET /feeds/actor/<actor id>/` is everything a profile shows, in one of three\nlists chosen with `filter`. Replies and reposts are a person's: only people\nreply and repost, so for a group or any other kind of actor the last two are\nempty.\n\n| `filter` | Holds |\n| --- | --- |\n| `posts` (the default) | What was posted on the profile. Also what its owner reposted, when they have asked for that. Not replies |\n| `replies` | The replies its owner wrote, wherever the threads are. Each is sent with what it answers as `parent`, and with `root`: the post at the top of its thread and, as `root.owner`, the profile that thread is on |\n| `reposts` | What its owner reposted, and nothing else. Each is sent with the post it reposts as `parent`, with that post's author, owner and date |\n| `tagged` | The photos and videos a person is in, their tag approved, whoever posted them ([Permissions](https://www.anahita.io/docs/services/permissions/#people-in-pictures-and-videos)) |\n\n- **Reposts are not among the posts unless the profile asks for it.** The\n  choice is kept on the actor as `show_reposts_on_profile`, off until somebody\n  turns it on, and changed with `PATCH /people/<id>/reposts-on-profile` by\n  whoever may edit the profile. There is no such route for a group. A\n  caller cannot ask for them: the feed reads the profile and decides.\n  The choice is about the profile only. Followers are sent reposts in their\n  own feeds either way, and the list of reposts always has them.\n- **A reply is listed only to somebody who can see the post at the top of its\n  thread**, like everywhere else a reply turns up. Not one that was removed,\n  and not one that was hidden from its thread.\n- **On a site that shows visitors a preview**, the list of replies answers a\n  visitor 401 `members_only`: replies are not part of a preview.\n\nA reply read on its own (`GET /notes/<id>`) carries `root` too, with its\nowner, so a page can say what it is a reply to and whose profile it is on. The\nthread is the root's: `GET /notes/<root id>/thread`.\n\n**A thread written in one go** is up to ten notes made together, in order,\nall or none (roadmap row 101): `POST /notes/<owner id>/thread` begins a post,\n`POST /notes/<id>/replies/thread` answers one. Each note after the first is a\nreply to the one before. graph-grpc-service writes them in one transaction\n(`Medium.AddThread`) and announces them in order once all are made, each\nwith its place in the thread (`part` in the event).\n\nA photo post holds up to twenty images (`PHOTO_MAX_FILES`), each uploaded in a request of its own\nand kept in `photo_files`, a table beside the post. How that works, and what\nis stored for each image, is in [Photo posts](https://www.anahita.io/docs/services/photos/)."},{"id":"one-service-for-every-kind-of-actor","heading":"One service for every kind of actor","level":2,"markdown":"People and groups are both actors: each has a profile, an avatar and a cover,\nfollowers, an audience, permissions, and the same life from enabled to\narchived to deleted to purged. They were served by two services,\n`person-service` and `group-service`, that were the same service written\ntwice, and a fix made in one was regularly missing from the other. They are\nnow one, `actor-service`.\n\n- **Each kind keeps its own address.** `/people/` and `/groups/` are\n  unchanged, and the gateway sends both to `actor-service`. A client cannot\n  tell that anything moved.\n- **The old names are still in the data, as names.** A person's type is\n  `node.actor.person-service.person.v1` and a group's\n  `node.actor.group-service.group.v1`. Those strings are in every node and\n  edge ever stored and in where avatars and covers are kept, so they stay.\n  They no longer say where anything runs.\n- **What is shared is written once:** signing in the viewer, step-up before a\n  deletion, presigning images, the answers for a refused change, the\n  configuration, the start-up. **What differs stays apart:** a person is read\n  by username and cannot be created here (that is signing up, in\n  `auth-service`); a group can be created and restored and has\n  administrators.\n- **It has no database address.** It reads and writes through\n  `graph-grpc-service` and owns no tables, so it is given no database\n  credentials.\n- **A new kind of actor** is a handler and its routes in this service, not a\n  service of its own. The website, a website's feed as a profile, is built\n  that way ([https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/website.md](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/website.md)).\n- **An event is one.** Its type is `node.actor.actor-service.event.v1`, it\n  is served at `/events/` by the handler a group has, told a different kind,\n  and what only an event has is beside it: answering going or maybe, calling\n  it off, and its calendar file. Its start and end are on the node; its time\n  zone, how many may go, where to join online and when it was called off are\n  in the `events` table. A group hosts one through an\n  `edge.actor.host.v1` edge from the group to the event, and\n  `GET /groups/<id>/events/` lists them. Who may do what is in\n  [Permissions](https://www.anahita.io/docs/services/permissions/#events).\n- **An invitation is an edge,** `edge.actor.invite.v1`, from the person\n  invited to the group or event, so every kind of actor can have them. They\n  are made and answered through `socialgraph-service`\n  (`/socialgraph/<actor>/invites` and its `accept` and `decline`, and\n  `/socialgraph/invites/` for the ones waiting for the viewer), and each is\n  announced on the bus so that the notification for it can be answered where\n  it is read. The rules are in [Permissions](https://www.anahita.io/docs/services/permissions/#invitations)."},{"id":"naming-images","heading":"Naming images","level":2,"markdown":"There are three kinds of image, and the names for them have not always\nlined up. This is what each is, and what it is called where.\n\n| What it is | Who has one | Column | In the API | Served by |\n| --- | --- | --- | --- | --- |\n| **Avatar**: the picture that stands for an actor | People, groups | `nodes.avatar_filename` | `avatar_urls` | `identity-image-service`, at `/avatars/` |\n| **Cover**: the wide picture across the top | People, groups, and posts such as articles | `nodes.cover_filename` | `cover_urls` | `identity-image-service`, at `/covers/` |\n| **A photo post's images**: the photo itself | Photo posts | `photo_files`, with the first copied to `nodes.filename` | `files`, and `portrait_urls` for the first | `photo-service`; see [Photo posts](https://www.anahita.io/docs/services/photos/) |\n\n- **Avatars and covers are identity images:** what a profile or a post is\n  recognised by. A photo post's images are content. That is the line between\n  the two services.\n- **`nodes.filename` is the file a node *is*:** a photo post's photo today,\n  a document's file if file sharing is built. An actor has none. Until\n  2026-10-05 the same column also held an actor's avatar, so its meaning\n  depended on the kind of node; the avatar now has `avatar_filename`, beside\n  `cover_filename`, and each of the three names one thing.\n- **\"Portrait\"** in the code and in `portrait_urls` means a photo post's\n  image, not a picture of a person. It is not to be used for anything new,\n  and is to be retired. That is a change to the API and the web app, not\n  to the schema, and was left out when the schema was put in order\n  ([https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/migration-baseline.md](https://github.com/anahitasocial/anahita-services/blob/main/docs/plans/done/migration-baseline.md)).\n- **\"Avatar\"** stays, in the code and the API. It is the word Mastodon and\n  Bluesky use for the same field.\n- **Where they are stored** is named for the service that looks after\n  them: `assets/v1/identity-image-service/avatars/person/<id>/…` and\n  `…/covers/…`. `v1` is the layout of the first release. The PHP\n  application's (`assets/public`) is v0 in all but name, and while this\n  platform was being built its files were under `assets/v2`, with avatars\n  and covers in folders named `avatar-service` and `cover-service`.\n  `scripts/s3-migration` moves a bucket from either.\n\nEvery uploaded image, of any of the three kinds, goes through one check\n(`images.Accept` in `anahita-libs/utils`): it is judged from its bytes and\nnot its name, limited in bytes and in pixels before it is opened, and turned\nthe right way up. Nothing is stored as it arrived; each stored size is\nwritten out again, never enlarged, and a square is cut from the middle and\nnot squeezed."},{"id":"anahita-libs","heading":"anahita-libs","level":2,"markdown":"The shared module every service imports. Like the services it is under the\nAGPL-3.0, except `proto`, the definitions of the calls between services,\nwhich is under Apache 2.0 so that services of others' own can make them\n([Licensing](https://www.anahita.io/docs/services/licensing/)):\n\n| Package | Contents |\n| --- | --- |\n| `constants` | OAuth scopes, shared enums |\n| `proto` | Generated gRPC stubs: `graphpb`, `filepb`, `mailpb`, `typespb` |\n| `event` | RabbitMQ connect/publish/consume, event name constants |\n| `sessions` | Redis-backed sessions and cookies |\n| `crypt` | Encryption with pluggable local / AWS KMS backends |\n| `grpctls` | Mutual TLS for the calls between services |\n| `ratelimit` | A counter in Redis: so many of something in a window, for each person |\n| `safehttp` | An HTTP client for going where somebody else said to: public addresses only, decided at the moment of connecting. For link previews and for speaking to other servers |\n| `policy` | The installation's settings on who may see what, which several services must agree on |\n| `federation` | What the services that speak to other servers share: the text of a post there and back (Markdown to HTML that is safe to send, HTML that arrived to Markdown that is safe to keep); the queue of what is to be sent; copying pictures, avatars and covers in, removing them after a while and copying them back when asked (`media.go`); the cards of links; the limits on what is kept from elsewhere (`retention.go`); custom emoji; and the servers the site refuses |\n| `site` | Where the installation is, as the internet sees it: its domain, and the addresses of its web app and API. See [What a node is called elsewhere](#what-a-node-is-called-elsewhere) |\n| `language` | The language a post is written in |\n| `feeds` | What a custom feed is made of, and the rules a definition keeps |\n| `linkpreviews` | What a link's preview is, and the copy of one a note keeps |\n| `videos` | What a video post is: its files and where they are |\n| `data` | Connecting to MySQL (`Open`, with a service's own pool), and the entities and the clients of the gRPC services (`data/entities`, `data/services`, with `BatchPresignNodes` for a list's pictures) |\n| `requests`, `responses`, `validators` | Request/response shapes and validation |\n| `permissions` | Who may do what to which node, the rules every service applies |\n| `handlers` | The HTTP handlers several services share, such as threads and a post's audience and language |\n| `middleware`, `cors` | HTTP middleware, and `ClientIPExtractor`, which every service sets so that the visitor's address is never one a client typed |\n| `getenv` | Reading a setting from the environment, with a fallback |\n| `geocoding`, `utils` | Supporting helpers |\n\n**The calls between services** are made by the clients in\n`data/services` (`GraphNodeGRPC`, `MailGRPC` and the rest). A process\nkeeps one gRPC connection to each service it calls, made the first time\nand shared by every client and every call (`data/services/connections.go`):\na connection is safe to share and reconnects by itself. Before roadmap row\n98, each call opened a connection of its own, with a new handshake, and\nleft it open."},{"id":"what-a-node-is-called-elsewhere","heading":"What a node is called elsewhere","level":2,"markdown":"Inside the installation a node is its number, and a person is also their\nusername. Neither will do for another server. A number says how many came\nbefore, and means nothing anywhere else; a username can be changed. What\nanother server is told a person or a post is called is kept there for good,\nby servers this one may never hear from again.\n\n- **Every node has a UUID,** `nodes.uuid`: random (version 4), unique, and\n  never changed. The database gives it when the row is added, so nothing that\n  adds a node can forget to, and the nodes there were before it were given\n  one each when the column was added.\n- **It is read with the node** (`Node.uuid` in `graphpb`, `UUID` on\n  `entities.AnahitaNode`), and a node is found by it with the graph's\n  `ReadByUUID`, which applies no access rule, as `Read` does not. It is not\n  in what the API sends a browser: the web app has no use for it.\n- **The addresses it goes into are made in one place,** `anahita-libs/site`,\n  from one reading of the settings (`PUBLIC_DOMAIN`, `PUBLIC_WEB_URL`,\n  `PUBLIC_API_URL`; see [Configuration](https://www.anahita.io/docs/services/configuration/#site-identity)). Two\n  services that disagreed about the host would make one person two.\n  `CanFederate` says whether other servers could reach those addresses at\n  all: names on the internet, over https. A site on a developer's machine is\n  neither.\n\n- **What came from another server is a node too,** so that feeds, follows\n  and blocks need nothing new. It has a `uri`, what it is called where it\n  lives, and an `origin_domain`; a node without a `uri` was made here. A\n  person elsewhere has a type of their own, one for each protocol\n  (`node.actor.activitypub-service.person.v1`,\n  `node.actor.atproto-service.person.v1`), so whatever lists people here\n  leaves them out; a post of theirs is an ordinary note. Such nodes are\n  written only by the graph's `UpsertRemote`, which refuses anything that is\n  not a person elsewhere or a note of theirs, and anything under one of this\n  installation's own addresses (`graph-grpc-service/data/repositories/nodes_remote.go`).\n\n- **A public profile's pictures have an address that stays,**\n  `/avatars/<uuid>/<size>` and `/covers/<uuid>/<size>` on the API, which\n  sends whoever asks on to a freshly signed one. Another server keeps the\n  address of an avatar for weeks; the signed one lasts a quarter of an hour.\n  Only what somebody not signed in may see is answered for\n  (`identity-image-service/cmd/api/handlers/public.go`).\n- **What is to be sent to another server waits in a table** until it has\n  been sent, or tried for two days (`anahita-libs/federation`, `queue.go`).\n  The bus cannot promise that a message arrives, and another server may be\n  down for an afternoon. Each protocol's service has a table of its own and\n  any number of workers on it; a row is handed to one at a time.\n- **The web app's host answers what other servers ask it under\n  `/.well-known`** by sending them on to the API\n  ([The web shell](https://www.anahita.io/docs/services/web-shell/)).\n\nFederation is built on this; see [Federation](https://www.anahita.io/docs/services/federation/), and to try\nit on one machine, [The federation lab](https://www.anahita.io/docs/services/federation-lab/)."},{"id":"naming-dates-and-times","heading":"Naming dates and times","level":2,"markdown":"One rule, in the database, in the API and in the web app:\n\n- A date **with a time** ends in `_at`: `created_at`, `updated_at`,\n  `starts_at`, `ends_at`, `last_visited_at`, `deleted_at`.\n- A calendar date **with no time** ends in `_on`. Nothing uses one yet.\n\nThe same name is used all the way through: the column `created_at` is the\nfield `created_at` in a response and `createdAt` in the web app, and a list is\nsorted with `ordering=created_at`. The last change to a row is `updated_at`,\nnever `modified_at`.\n\nA test, `TestTimestampColumnsEndInAt` in graph-grpc-service, reads\n`migration-job/schema/init.sql` and fails on a date or time column that does\nnot follow."},{"id":"the-database","heading":"The database","level":2,"markdown":""},{"id":"how-it-is-made","heading":"How it is made","level":3,"markdown":"`migration-job` runs once each time the stack starts, and does one of three\nthings:\n\n| The database has | It runs | Then |\n| --- | --- | --- |\n| No tables | `schema/init.sql` | records goose's baseline |\n| The PHP application's tables (`<prefix>_nodes`, `<prefix>_edges`) | `legacy/upgrade.sql` | records goose's baseline |\n| This schema | `goose up`, the migrations in `migrations/` | |\n\n`schema/init.sql` and `legacy/upgrade.sql` each make the whole schema, and\n`make schema_test` checks that they make the same one: column by column and\nindex by index, on a throwaway MySQL. Until 2026-10-17 there were 70 goose\nmigrations as well; they were folded into the two files (roadmap row 27),\nand `migrations/` holds only what is written after. How to change the schema\nis in [`migration-job/migrations/README.md`](https://github.com/anahitasocial/anahita-services/blob/main/migration-job/migrations/README.md).\n\nWhat a site's own data needs beyond the upgrade (avatars set by hand,\nposts moved between profiles) is that site's, and lives with its own\ndeployment, not here."},{"id":"null-only-where-it-means-something","heading":"NULL only where it means something","level":3,"markdown":"A column says what it holds, in every table. NULL is kept for five things,\nand for nothing else:\n\n| NULL means | Columns |\n| --- | --- |\n| No other row: a reference with nothing at its end | `owner_id`, `parent_id`, `root_id`, `quote_id`, `created_by`, `modified_by`; a notification's `subject_id`, `object_id`, `target_id` |\n| Made here: no address on another server | `uri`, `origin_domain` |\n| No point | `geo_latitude`, `geo_longitude`, NULL together |\n| Not happened, or does not apply | `deleted_at`, `archived_at`, `pinned_at` and the like; `starts_at` and `ends_at`, which only an event has |\n| None, where a unique index would make `''` collide | an account's `email`; the delivery queues' `dedupe_key` |\n\nEverything else is `NOT NULL`: `''` for no text, `0` for no count, false for\na switch not set, and `created_at` and `updated_at` default to the time of\ninsert. A reference is never 0 for none, and a point never 0,0.\n\nSo the code reads a column as it is, and uses `COALESCE` only for:\n\n- what may be NULL by the table above, read as `''` or `0`;\n- a row from a `LEFT JOIN`, which may not be there;\n- an aggregate over no rows (`SUM` gives NULL, not 0);\n- a choice between two values (`COALESCE(root.access, note.access)`, the\n  first date a domain was proved).\n\nA date NULL for \"never\" is read as the zero time, not as now, so a check of\nhow recent it is fails closed (`optionalTime` in the graph and auth\nservices)."}]},{"source":"services","slug":"web-shell","title":"The web shell","group":"Build on Anahita","url":"https://www.anahita.io/docs/services/web-shell/","sections":[{"id":"","heading":null,"level":1,"markdown":"A link to a profile or a post, pasted into Mastodon, Bluesky, Slack or a\nmessage, is shown with a title, a line of text and a picture. The site it\nis pasted into gets those by fetching the page and reading the tags in its\n`<head>`. It runs no JavaScript, and the web app is nothing but JavaScript:\nevery page of it is the same `index.html`, so every link looked the same.\n\n`web-shell` is what sends the web app. For every page it sends that same\n`index.html` with the page's own tags written in. Everybody gets the same\npage: a browser starts the app from it, and a crawler reads the tags. Nobody\nis told apart by what they say they are."},{"id":"what-it-sends","heading":"What it sends","level":2,"markdown":"| Asked for | Sent |\n| --- | --- |\n| 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 |\n| 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 |\n| `/_shell/image/<name>` | A redirect to a page's picture; see [Pictures](#pictures) |\n| Anything else | `index.html` with the page's tags, kept by browsers for a minute at most |\n\nThe tags are `<title>`, `description`, `og:title`, `og:description`,\n`og:type`, `og:site_name`, `og:url`, `og:image`, `og:image:alt`,\n`twitter:card` and `<link rel=\"canonical\">`. Whatever the build had of these\nis taken out first, so each is said once. Everything written is escaped: a\nname or a post is somebody's text.\n\nThe site's own name and description, used for every page that has nothing\nof its own to say, are read from the build's `index.html`, where\n`REACT_APP_NAME` and `REACT_APP_DESCRIPTION` put them. They are not set\ntwice."},{"id":"which-pages-are-described","heading":"Which pages are described","level":2,"markdown":"| Page | Title | Description | Picture |\n| --- | --- | --- | --- |\n| `/@<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 |\n| `/people/<alias>` and its tabs | The person's name | Their bio | Their avatar |\n| `/groups/<id>`, `/events/<id>`, `/websites/<id>` and their tabs | Its name | What it says of itself | Its avatar |\n| `/photos/<id>` | The photo's title | Its text | The photo |\n| `/videos/<id>` | The video's title | Its text | Its poster: for a GIF, its first picture |\n| `/articles/<id>`, `/topics/<id>` | Its title | The start of its text | Its cover |\n| `/notes/<id>` | Its writer's name | The start of the note | The picture of the link it carries a card for |\n| `/hashtags/<tag>`, and `/tags/<tag>`, the address other servers are given | `#tag` | The site's | The site's |\n| Any other | The site's name | The site's | `/statics/media/ogimage.jpg` |\n\nText is made plain, with Markdown and HTML taken out, and cut to 200\ncharacters. A profile's address in its tags (`og:url`, the canonical link)\nis always its alias, `/@ana`, however its page was reached, so a link\nshared from an old `/people/ana` or `/groups/12-garden` is taken by the\nnetworks that read it as `/@ana` or `/@garden`."},{"id":"what-is-never-described","heading":"What is never described","level":2,"markdown":"A tag is copied by every site the link is pasted into and cannot be taken\nback, so the rule is stricter than who may read the page.\n\n- The page is read as somebody who is **not signed in**, through the same\n  calls and access rules as the API.\n- What comes back is described only if it says of itself that it is\n  `public`, **and** the profile it is on says the same, and it is not\n  deleted, archived or disabled. A public note on a profile for registered\n  people is not described.\n- A reply is described only if the post at the top of its thread passes\n  the same test.\n- Settings and notification pages are nobody's to describe.\n- With `SITE_READ_ACCESS=registered`, a site for members, no page is\n  described and nothing is read at all.\n\nEverything else is sent with what the site says of itself, exactly like a\npage that does not exist. The answer does not tell the two apart.\n\nWhat a page says is read again after a minute, so something made private\nstops being described within that time. What another site already copied is\nthat site's to forget."},{"id":"pictures","heading":"Pictures","level":2,"markdown":"A post's picture is its photo, an article's cover, a video's poster (for a\nGIF made into a clip, its first picture), or the picture of the link card a\nnote carries; failing those, the site's own.\n\nStored pictures are read through signed addresses that stop working after a\nfew minutes, and a crawler keeps a picture's address for days. So a page's\npicture is given as an address on the shell that does not change,\n`/_shell/image/person-5` or `/_shell/image/post-170203`, which redirects to\na freshly signed one each time it is asked for.\n\nThe redirect is made by the same rules as the page: for what is not public\nit answers `404`. A picture is never easier to reach than the page it is\nthe picture of."},{"id":"what-other-servers-ask-this-host","heading":"What other servers ask this host","level":2,"markdown":"Somebody here is known elsewhere as `ana@example.org`, so a server that\nwants to find them asks `example.org`, the web app's host, where they are. The\nshell knows nothing of people; the API does. So three addresses are answered\nwith a redirect to the same path on the API, with whatever was asked:\n\n| Address | What it is for |\n| --- | --- |\n| `/.well-known/webfinger` | Finding somebody by their handle |\n| `/.well-known/host-meta` | The older way of asking where WebFinger is |\n| `/.well-known/nodeinfo` | What software this is and how many use it |\n\nThe API's address is `PUBLIC_API_URL`, or `https://api.DOMAIN_NAME` when\nthat is not set. activitypub-service answers WebFinger there."},{"id":"running-it","heading":"Running it","level":2,"markdown":"It needs the web app's build, the `build/` folder that `yarn build` makes\nin anahita-web, and reaches `graph-grpc-service` and `file-grpc-service`.\nIt has no database access and takes no session: it reads as a visitor.\n\nThe web app and the API have different hosts (`www.example.org` and\n`api.example.org`), because their paths overlap: `/people/alice` is a page\non one and a call on the other. The shell answers on the web app's host,\nbehind whatever ends TLS for it, and the gateway goes on answering on the\nAPI's.\n\n**docker-compose.** It is not started by `up`, having nothing to send\nwithout a build:\n\n```sh\nmake web_shell WEB_BUILD=/path/to/anahita-web/build\n```\n\nIt listens on port 8102. `WEB_BUILD` copies the build into\n`web-shell/web/`. To have it read anahita-web's folder in place, so a new\nbuild of the app is picked up by a restart, set `WEB_BUILD_PATH` in `.env`\nto that folder and run `make web_shell`.\n\n**Kubernetes.** Build the image with the app in it, `make build_web_shell\nWEB_BUILD=<folder>` then `docker build -f dockerfiles/web-shell.dockerfile`,\nadd `k8s/base/services/web-shell.yml` to the overlay's resources, and send\nthe web app's host to the `web-shell` Service in the Ingress.\n\n`index.html` is read once, when it starts. Start it again after a new build."},{"id":"settings","heading":"Settings","level":2,"markdown":"| Variable | Value |\n| --- | --- |\n| `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 |\n| `PUBLIC_API_URL`, `DOMAIN_NAME`, `ENV` | Where the API is, for what other servers ask this host. See [Site identity](https://www.anahita.io/docs/services/configuration/#site-identity) |\n| `WEB_BUILD_PATH` | docker-compose only: the folder mounted as the build. Defaults to `web-shell/web` |\n| `WEB_BUILD_DIR` | Where the build is inside the container. Defaults to `/app/web` |\n| `SITE_READ_ACCESS` | As for every service; see [Configuration](https://www.anahita.io/docs/services/configuration/#privacy-of-the-installation) |"},{"id":"not-running-it","heading":"Not running it","level":2,"markdown":"The web app is a static site and can still be put on any static host, as\nanahita-web's `docs/deploying.md` describes. It works the same; links to it\nall show the site's own title and picture.\n\n**A site that federates and does not run the shell** has to have its host\ndo what the shell does for other servers: redirect the three addresses\n[above](#what-other-servers-ask-this-host) to the same paths on the API.\nWithout that, nobody here can be found by their handle."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| What it sends, and the `/.well-known` redirects | `web-shell/shell/server.go` (`ServeHTTP`, `file`, `page`, `image`, `wellKnown`) |\n| Which pages are described | `web-shell/shell/pages.go` (`Resolve`, `ResolveImage`) |\n| What is never described | `web-shell/shell/pages.go` (`IsPublic`), with `anahita-libs/policy/policy.go` (`MembersOnly`) |\n| Writing the tags into `index.html` | `web-shell/shell/template.go` (`NewTemplate`, `Render`) |\n| Reading as a visitor, and signing picture addresses | `web-shell/shell/graph.go` |\n| Settings | `web-shell/config/config.go` (`PUBLIC_WEB_URL`, `WEB_BUILD_DIR`), `anahita-libs/site/addresses.go` (`FromEnv`) |\n| 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` |\n| Tests of the rules | `web-shell/shell/shell_test.go` (`TestWhatIsNotPublicIsNotDescribed`) |"},{"id":"what-is-not-built","heading":"What is not built","level":2,"markdown":"- A location's name on `/locations/<id>`, and a custom feed's on `/feeds/<id>`.\n- `og:image:width` and `height`.\n- The `alternate` link to a page's ActivityPub form."}]},{"source":"web","slug":"README","title":"Documentation","group":null,"url":"https://www.anahita.io/docs/web/","sections":[{"id":"","heading":null,"level":1,"markdown":"Documentation for Anahita Web, the web app for Anahita. The server side,\nincluding every setting an operator changes without rebuilding this app, is\ndocumented in [anahita-services](https://github.com/anahitasocial/anahita-services).\n\nTo set up a whole site, make it your own, keep it running or build on it,\nbegin with the four ways in that the services' docs give:\n[Where to begin](https://www.anahita.io/docs/services/#where-to-begin).\n\nIts releases are named as the services' are: `main` is always the\n**Embryo**, and a tagged version is a **Birth** release; see\n[Releases](https://www.anahita.io/docs/services/releases/).\n\nAsking an AI agent about the web app? Add Anahita's MCP server to it: see\n[Anahita MCP](https://www.anahita.io/docs/services/ai-agents/)."}]},{"source":"web","slug":"getting-started","title":"Getting started","group":null,"url":"https://www.anahita.io/docs/web/getting-started/","sections":[{"id":"","heading":null,"level":1,"markdown":"Running the app locally and working on it."},{"id":"requirements","heading":"Requirements","level":2,"markdown":"- [Node.js](https://nodejs.org/), a current LTS release\n- [Yarn](https://yarnpkg.com/)\n- [anahita-services](https://github.com/anahitasocial/anahita-services)\n  running locally: see its\n  [getting started](https://www.anahita.io/docs/services/getting-started/).\n  Its nginx gateway answers at `http://localhost`, and allows\n  `http://localhost:3000`, where this app runs, for CORS."},{"id":"installing","heading":"Installing","level":2,"markdown":"```sh\nyarn install\ncp .env.example .env\n```\n\nIn `.env`, point the app at the local API:\n\n```sh\nREACT_APP_API_BASE_URL=http://localhost\n```\n\nThe other values in `.env.example` work as they are for local development.\nEvery variable is described in [Configuration](https://www.anahita.io/docs/web/configuration/)."},{"id":"first-run","heading":"First run","level":2,"markdown":"```sh\nyarn start\n```\n\nThe app opens at `http://localhost:3000`.\n\nSigning in sends you to auth-service's sign-in page and back to\n`http://localhost:3000/oauth/callback`. anahita-services registers that\naddress for the `anahita-web` OAuth client when it seeds its clients, so it\nworks without any setup. If signing in fails with a redirect error, check that\nthe app is on port 3000 and that the client's seed has not been changed.\n\nWhether the **Create account** button appears depends on the server's\n`REGISTRATION_MODE`, not on anything in this app."},{"id":"developing","heading":"Developing","level":2,"markdown":"`yarn start` runs the development server with hot reload.\n\nIn development the Redux store also runs `redux-logger`, which logs every\naction to the browser console, and connects to the Redux DevTools browser\nextension when it is installed. Neither is in a production build.\n\n`REACT_APP_*` values are read when the server starts. After changing `.env`,\nrestart `yarn start`."},{"id":"tests","heading":"Tests","level":2,"markdown":"```sh\nyarn test                                  # watch mode\nCI=true yarn test --watchAll=false         # run once, as CI would\n```\n\nTests live next to what they test, in `__tests__` directories, and are run by\nJest through Create React App. Most test plain modules rather than rendering\ncomponents. For example, `src/containers/actors/Settings/__tests__/` checks\nthe settings tabs and the permission rules as data. That is why several of\nthose modules are kept free of JSX.\n\n`src/__tests__/docsLinks.test.js` checks the docs: it fails on a link between\nthem that leads nowhere, a page that is not there or a `#heading` it does not\nhave."},{"id":"linting","heading":"Linting","level":2,"markdown":"The code follows the Airbnb style guide (`.eslintrc.json`), with a few changes.\nThe one that shows most is that arrow functions always have a block body\n(`arrow-body-style: always`).\n\n```sh\nnpx eslint src\n```\n\n`yarn start` shows lint problems in the browser and the terminal as you work."},{"id":"with-an-ai-agent","heading":"With an AI agent","level":2,"markdown":"Anahita's MCP server puts these docs and the API reference in the AI agent\nyou use, so that it answers from them, not from memory. In Claude Code,\neither:\n\n```sh\nclaude mcp add --transport http anahita https://mcp.anahita.io/mcp   # hosted\nclaude mcp add anahita -- npx -y anahita-mcp                          # on your computer\n```\n\nClaude Desktop, Gemini, Cursor, VS Code and the rest: [Anahita MCP](https://www.anahita.io/docs/services/ai-agents/)."}]},{"source":"web","slug":"configuration","title":"Configuration","group":null,"url":"https://www.anahita.io/docs/web/configuration/","sections":[{"id":"","heading":null,"level":1,"markdown":"Every environment variable the app reads, and why most settings are not here."},{"id":"how-configuration-works","heading":"How configuration works","level":2,"markdown":"The app is configured with environment variables, set in a `.env` file in\ndevelopment or in the host's settings when it is built for production.\n`.env.example` lists them all with notes. Copy it to `.env` to start.\n\nThree things follow from the app being built with Create React App:\n\n- **Only `PUBLIC_URL`, `NODE_ENV` and variables beginning `REACT_APP_` reach\n  the app.** Anything else is invisible to it, whatever its name.\n- **Values are compiled into the bundle when it is built.** Changing one means\n  restarting `yarn start`, or rebuilding for production.\n- **Anybody can read them.** They end up in JavaScript that every visitor\n  downloads, so none of them may hold a secret.\n\nCreate React App reads these files, highest priority first. No other file name\nis read:\n\n| File | Loaded |\n| --- | --- |\n| `.env.development.local`, `.env.production.local` | For `yarn start` and `yarn build` respectively |\n| `.env.development`, `.env.production` | Likewise |\n| `.env.local` | Always, except when running tests |\n| `.env` | Always |"},{"id":"variables","heading":"Variables","level":2,"markdown":""},{"id":"identity","heading":"Identity","level":3,"markdown":"| Variable | Description |\n| --- | --- |\n| `REACT_APP_NAME` | The installation's name. Shown throughout the app and used as the page title in `public/index.html` |\n| `REACT_APP_DESCRIPTION` | A one-line description, used as the page's meta description |\n| `PUBLIC_URL` | Where the app is served from, for example `http://localhost:3000` |"},{"id":"back-end","heading":"Back end","level":3,"markdown":"| Variable | Description |\n| --- | --- |\n| `REACT_APP_API_BASE_URL` | The anahita-services gateway, for example `http://localhost` in development or `https://api.example.com` in production. Every API call goes here, so this decides which installation the app belongs to |"},{"id":"optional","heading":"Optional","level":3,"markdown":"| Variable | Description |\n| --- | --- |\n| `REACT_APP_NOTIFICATIONS_CHECK_INTERVAL` | The longest wait between two checks of the bell's count, in milliseconds: `300000` (5 minutes) when unset. The bell asks sooner after something new, and at once when the tab is looked at again. See [Notifications](https://www.anahita.io/docs/web/architecture/#notifications) |\n| `REACT_APP_LANGUAGE` | The language for a visitor whose browser asks for none the app has: `en-CA`, `en-US`, `fr-FR`, `fr-CA`, `de-DE`, `es-419` or `pt-BR`. Unset, or anything else, is `en-CA`. See [Languages](https://www.anahita.io/docs/web/customising/#languages) |\n| `REACT_APP_ASSETS` | The theme to use: the name of a directory under `src/assets/`. Unset uses `src/assets/default`. See [Themes](https://www.anahita.io/docs/web/customising/#themes) |\n| `REACT_APP_ANALYTICS` | Page-view analytics: `plausible`, `umami` or `matomo`. Unset means off: no script is loaded and nothing leaves the browser |\n| `REACT_APP_ANALYTICS_URL` | Where that analytics service is, for example `https://stats.example.org` |\n| `REACT_APP_ANALYTICS_SITE_ID` | The site as that service knows it: the domain for Plausible, the website id for Umami, the numeric site id for Matomo |\n| `REACT_APP_MAP_TILE_URL` | Where map tiles come from, in Leaflet's `{s}/{z}/{x}/{y}` form. Unset uses OpenStreetMap's public tile server |\n| `REACT_APP_MAP_TILE_ATTRIBUTION` | The credit shown on the map, as the tile server's licence requires. Unset credits OpenStreetMap |\n| `REACT_APP_LOCATION_FIXED_COUNTRY` | Pins every new location to one country, as a two-letter code such as `CA`, and hides the country field |\n| `REACT_APP_LOCATION_FIXED_STATE_PROVINCE` | Pins new locations to one state or province, as a short code such as `BC`, and hides that field |\n| `REACT_APP_LOCATION_FIXED_CITY` | Pins new locations to one city, as free text, and hides that field |\n\nLeave the location variables unset to let people choose freely. That is the\nusual case."},{"id":"third-parties-the-app-talks-to","heading":"Third parties the app talks to","level":3,"markdown":"With the defaults, the browser contacts one service besides your own API:\n**OpenStreetMap's tile server**, whenever a map is shown. Point\n`REACT_APP_MAP_TILE_URL` at your own tiles to avoid it. A link to YouTube,\nVimeo or any other site is shown as its card, whose picture is this\ninstallation's own copy; nothing from that site is loaded until somebody\nfollows the link.\n\nAnalytics is off unless you configure it, and all three supported services can\nbe self-hosted. The app loads no fonts or scripts from a CDN."},{"id":"build","heading":"Build","level":3,"markdown":"| Variable | Description |\n| --- | --- |\n| `ESLINT_NO_DEV_ERRORS` | `true` shows lint errors as warnings in development instead of failing the build |\n| `DISABLE_ESLINT_PLUGIN` | `true` skips linting during the build entirely, which is faster for production builds |"},{"id":"settings-that-live-on-the-server","heading":"Settings that live on the server","level":2,"markdown":"Anything an operator should be able to change without rebuilding the app is\nconfigured in anahita-services. The app reads it at run time, mostly from the\nserver's NodeInfo document (`/.well-known/nodeinfo`):\n\n| Setting | Where it is set |\n| --- | --- |\n| Whether people can sign up, and how | `REGISTRATION_MODE` |\n| Support email, phone and website | `SUPPORT_EMAIL`, `SUPPORT_PHONE`, `SUPPORT_WEBSITE` |\n| Who may create groups and send invitations | `GROUPS_FROM`, `INVITES_FROM` |\n| What people who are not signed in can read | `SITE_READ_ACCESS`: everything public, only the start of it, or nothing. The app shows them a notice, or on a members-only site a way in, in place of every page |\n| Whether anything new may be public | `MIN_CONTENT_ACCESS`. The app stops offering \"Public\" where it may not |\n| Who may post, comment and like by default | `graph-grpc-defaults/actor_features` |\n\nThe two privacy settings, what each does to visitors and how to check them are\ndescribed in anahita-services under\n[Privacy](https://www.anahita.io/docs/services/privacy/).\n\nSome older variables are no longer read:\n\n| Variable | Replaced by |\n| --- | --- |\n| `REACT_APP_SIGNUP_CLOSED` | `REGISTRATION_MODE` on the server |\n| `REACT_APP_GOOGLE_MAPS_API_KEY` | Nothing. Maps use Leaflet with OpenStreetMap |\n| `REACT_APP_THEME` | `REACT_APP_ASSETS` |\n| `SUPPORT_EMAIL` and the other `SUPPORT_*` | The server's `SUPPORT_*`. Without the `REACT_APP_` prefix these never reached the app |"},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| Variable | Read in |\n| --- | --- |\n| `REACT_APP_API_BASE_URL` | `src/api/index.js`, `src/api/publicClient.js`, and the helpers in `src/utils/` that build file addresses |\n| `REACT_APP_NAME`, `REACT_APP_DESCRIPTION` | `src/components/HeaderMeta.jsx`, `src/components/Logo.jsx`, and page titles throughout `src/containers/` |\n| `REACT_APP_NOTIFICATIONS_CHECK_INTERVAL` | `src/containers/notifications/useUnreadCount.js` |\n| `REACT_APP_LANGUAGE` | `src/languages/index.js`, through `src/languages/choose.js` |\n| `REACT_APP_ASSETS` | `src/assets/index.js` |\n| `REACT_APP_ANALYTICS`, `REACT_APP_ANALYTICS_URL`, `REACT_APP_ANALYTICS_SITE_ID` | `src/utils/analytics.js` |\n| `REACT_APP_MAP_TILE_URL`, `REACT_APP_MAP_TILE_ATTRIBUTION` | `src/components/Map.jsx` |\n| `REACT_APP_LOCATION_FIXED_*` | `src/containers/locations/Add/Add.jsx`, `src/containers/locations/Read/index.jsx` |\n| The server's NodeInfo document | `src/api/nodeInfo.js` |"}]},{"source":"web","slug":"architecture","title":"Architecture","group":null,"url":"https://www.anahita.io/docs/web/architecture/","sections":[{"id":"","heading":null,"level":1,"markdown":"How the app is put together, and how it talks to anahita-services."},{"id":"directory-layout","heading":"Directory layout","level":2,"markdown":"Everything is under `src/`:\n\n| Directory | What it holds |\n| --- | --- |\n| `api/` | One module per API resource. Each returns axios promises; `index.js` sets up axios and exports them all |\n| `actions/` | Redux thunks that call the API and dispatch the result |\n| `reducers/` | Redux reducers, most of them made by shared factories |\n| `store/` | The Redux store, with a development and a production version |\n| `containers/` | Pages and components connected to the store, grouped by feature (`actors`, `media`, `replies`, `settings`, `auth`…) |\n| `components/` | Presentational components with no store access |\n| `routes/` | The route table and the four route guards |\n| `permissions/` | What the viewer may do, mostly read from the server's answers |\n| `languages/` | Translations, one directory per language |\n| `assets/` | Themes: logo, home page (what somebody not signed in sees at `/`), left menu, styles and static pages |\n| `statics/` | Files shipped with the app, including the Terms of Service and Privacy Policy |\n| `proptypes/` | PropTypes shapes and matching default objects for the API's entities |\n| `styles/` | Builds the Material UI theme from the active theme's `styles` |\n| `constants/`, `utils/`, `middleware/` | Shared helpers |"},{"id":"talking-to-the-api","heading":"Talking to the API","level":2,"markdown":"All requests go through axios, configured in `src/api/index.js`:\n\n- **Base URL.** Every request is relative to `REACT_APP_API_BASE_URL`.\n- **Cookies.** Requests are sent with credentials, because the session is a\n  cookie set by the API's domain.\n- **Key casing.** The API speaks `snake_case` and the app `camelCase`. A request\n  interceptor converts outgoing keys, including form-data field names, to\n  `snake_case`. A response interceptor converts incoming keys to `camelCase`.\n  Code in the app never sees a `snake_case` key.\n- **Redirects** are not followed (`maxRedirects: 0`).\n\nWhat each route takes and sends is in the services' API reference\n(anahita-services `docs/api.md` and `docs/api/`, made from the code); to read\nit, open anahita-services `docs/api/index.html` in a browser. It describes the\nwire, in `snake_case`, not the `camelCase` the app sees.\n\nA few documents come from elsewhere and skip the interceptors. For example, the\nOpenID configuration goes through `api/publicClient.js`, which repeats the key\nconversion itself."},{"id":"signing-in","heading":"Signing in","level":2,"markdown":"The app never handles a password. Signing in is OAuth 2.0 authorization code\nwith PKCE, as the first-party client `anahita-web` (`src/api/session.js`):\n\n1. **Sign in.** The app generates a random state and PKCE verifier, keeps them\n   in `sessionStorage`, and sends the browser to the server's\n   `/oauth/authorize`.\n2. **Callback.** auth-service shows its own sign-in page, then redirects back\n   to `/oauth/callback` with a code (`containers/OAuthCallback.jsx`).\n3. **Session.** The app posts the code and verifier to the server's\n   `/oauth/session`. The server exchanges them for tokens, keeps the tokens\n   itself, and sets a session cookie. From then on, requests carry the cookie\n   and the gateway attaches the access token on the way in.\n4. **Viewer.** The app reads the signed-in person from `/oauth/userinfo`.\n\nSigning up, accepting an invitation and confirming an email address are pages\non auth-service too. The app only links to them.\n\nFour components guard routes (`src/routes/`):\n\n- **`AuthenticatedRoute`** sends signed-out visitors to `/auth`. It waits for\n  the session check to finish first, so a signed-in person is not bounced on\n  every reload.\n- **`AgreementsGate`** wraps the whole route table. It sends a signed-in person\n  to `/agreements` when the Terms of Service or Privacy Policy has a newer\n  version than the one they accepted; after that, to `/rules` when the\n  site's rules have (roadmap row 102). The rules are the server's, read once\n  from `GET /site` (`containers/site/useSite.js`, `useRules`, with its own\n  client: no credentials, and language names left as they are). On their\n  page (`containers/rules`) each is ticked in turn: the next zooms forward\n  a moment after the one before is ticked, the keyboard following; for\n  anybody not behind on them it is a numbered list to read. With\n  `metadata.siteRules` false, nobody is sent: whoever installs Anahita makes\n  its creator's pledge on auth-service's set-password page instead. `OnboardingGate` waits for both. The About page shows the\n  site's promises first, from the same `GET /site`, with the way to\n  `/rules` (`containers/about/Promises.jsx`). An administrator removes a\n  person or a group as spam, at once, from its menu or from a report's case\n  (`containers/moderation/useRemoveAsSpam.jsx`, asked once more). A group's\n  and an event's rules are written in a Rules tab of its settings\n  (`actors/Settings/Rules.jsx`: at most ten, in order, picked from the site's\n  suggestions or written; the site's own open in a dialog from there,\n  `rules/SiteRulesDialog.jsx`) and listed in its About card\n  (`actors/Read/useRules.js`). Every list of rules, here, on `/rules`, in\n  the checklists and on the server's sign-up pages, is numbered when it has\n  more than three (`utils/lists.js`). Following one, asking to, answering an event\n  or accepting an invitation first asks for its rules, ticked one at a time\n  in a dialog (`containers/rules/useRulesFirst.jsx`, `components/RulesChecklist.jsx`,\n  which `/rules` uses too), unless the viewer agreed to the version they are\n  at; the server refuses otherwise (409 `rules_not_agreed`). A new post on\n  one asks the same when its rules changed since (both composers, with\n  `useRulesFirst(actor, 'post')`). See\n  [Terms of Service and Privacy Policy](https://www.anahita.io/docs/web/customising/#terms-of-service-and-privacy-policy).\n- **`OnboardingGate`**, inside it, sends a signed-in person with an\n  incomplete profile to `/onboarding`, once, and waits while the agreements\n  are outstanding so the two never send somebody back and forth.\n- **`MembersOnlyGate`**, on a members-only site, shows somebody who is not\n  signed in a way in instead of the page they asked for. See\n  [A members-only site](#a-members-only-site).\n\nAll four are gates on the interface. What may be read is decided by the\nserver."},{"id":"state","heading":"State","level":2,"markdown":"State is kept in Redux, with `redux-thunk` for asynchronous actions.\n\nMost resources behave the same way, so their actions and reducers are made by\nfactories instead of being written out each time:\n\n- `actions/create.js` makes `browse`, `read`, `edit`, `add` and `deleteItem`\n  actions for a namespace, such as `people`, `groups`, `notes` or `photos`.\n- `reducers/create.js` makes the matching reducer. It keeps each namespace's\n  items by id, their order, the `current` item being viewed, and loading and\n  error state.\n\n`actions/index.js` and `reducers/index.js` list the namespaces and wire them\nup. Anything that does not fit the pattern, such as the session or the\nsocialgraph, has its own module.\n\nIn development the store also logs every action, and connects to the Redux\nDevTools browser extension when it is installed."},{"id":"routes","heading":"Routes","level":2,"markdown":"The route table is `src/routes/index.js`, using React Router 7 with browser\nhistory. The main paths:\n\n| Path | Page |\n| --- | --- |\n| `/` | The landing page when signed out. Signed in, home: `containers/feeds`, with the feed and the + button to post. `/dashboard` leads here |\n| `/auth`, `/oauth/callback` | Signing in |\n| `/@:alias`, `/@:alias/:tab` | A profile of any kind at its alias, as it is known on the fediverse: a person (`/@rastin`), somebody on another server by their whole handle (`/@alice@mastodon.example`, `/@alice.bsky.social`), a group (`/@anahita-atrium`) or an event. `containers/actors/ByHandle.jsx` asks the API which profile the alias is (`api.handles`, `GET /actors/:alias`) and draws its page, its tabs under `/@:alias`. `utils/node.getURL` gives every profile this address; `getKindURL` gives the one by kind, under which settings are |\n| `/people`, `/people/:id` | People. A person's old address, `/people/:id` and its tabs, sends to `/@:id` |\n| `/people/:id/:tab`, `/groups/:id/:tab` | A group's page opened on one of its tabs at its address by kind, which still works; for a person, sent to `/@:id/:tab` |\n| `/saved` | The posts the viewer saved. Signed in only; in the left menu |\n| `/recommendations` | People, groups and events the viewer might follow, and feeds people have shared, a tab each (`?kind=`). Signed in only; reached from Who to follow on Home, and from the left menu where the installation has the recommendation service. `/suggestions`, its first address, leads to it |\n| `/feeds`, `/feeds/new`, `/feeds/:id/edit` | The viewer's custom feeds, making one, changing one. Signed in only |\n| `/feeds/:id` | A custom feed's own page. Read without signing in when the feed is public |\n| `/events`, `/events/add`, `/events/:id` | The viewer's own events, making one, an event's page. `/events/:id/edit` sends to the event's settings. See *Events* |\n| `/people/add`, `/groups/add` | Making a person (administrators) or a group |\n| `/people/:id/settings/password` | The old address of the password form, linked from older emails. Sends to Security |\n| `/people/:id/settings/:section` | A person's settings, grouped into sections |\n| `/groups`, `/groups/:id` | Groups, and a group. `:id` is `<id>-<slug>` |\n| `/groups/:id/settings` | A group's settings |\n| `/websites`, `/websites/:id`, `/websites/:id/settings` | Websites, a website (a site's feed as a profile), and its settings: Info, Admins, Blocked, On your site, Danger zone. Its page is at `/@alias` like any profile |\n| `/websites/connect` | Connecting a website: its address, the proof of its domain, then its name and handle. For those the installation's `websitesFrom` allows. See [Websites](https://www.anahita.io/docs/services/websites/) in anahita-services |\n| `/notes/:id`, `/articles/:id`, `/topics/:id`, `/photos/:id`, `/videos/:id` | One post. There is no page listing every post of a type; the bare paths send an old bookmark home |\n| `/hashtags`, `/hashtags/:alias`, `/locations`, `/locations/:id` | Hashtags and places, the lists and each one. The old `/tags/:alias` sends to `/hashtags/:alias` |\n| `/notifications` | The viewer's notifications |\n| `/admin/:tab` | The administration area. The tab is in the address so an email can link to it. See below |\n| `/invites` | Invitations, for a member who may invite. An administrator is sent to `/admin/invites` |\n| `/settings` | Site settings, for super administrators only. A page and a menu entry of its own: OAuth clients, signing keys, Privacy, which shows what the installation lets visitors read and can make everything public members-only, and Fediverse, the servers the site refuses |\n| `/signup-requests` | Where the signup queue used to be. Redirects to `/admin/signup-requests` |\n| `/legal/tos`, `/legal/privacy`, `/agreements` | The legal documents, and accepting new versions |\n| `/onboarding` | Where somebody signed in with an incomplete profile is sent, once (`routes/OnboardingGate.jsx`): a name and a few words; a picture, in a circle that shows the initials of that name until there is one; hashtags they are interested in, up to ten, which make one \"My interests\" feed on their home page; profiles the site features; then people and groups recommended for them, with the reason for each. Nothing is followed without a tick. A step with nothing to show is left out (`onboarding/featured.js#stepsFor`), every step can be skipped, and without the recommendation service there are no interests and no recommendations (`containers/onboarding`) |\n| `/pages/:alias` | A page of the installation's own, from the theme's `pages/` folder (`src/assets/<theme>/pages`). `/pages/tos` and `/pages/privacy` redirect to `/legal` |\n| `/404`, and any address not above | The not-found page |\n| `/search`, `/blogs`, `/support`, `/about` | Everything else |"},{"id":"a-members-only-site","heading":"A members-only site","level":3,"markdown":"Everything about who can see what, on the server and here, is in one place:\n[Privacy](https://www.anahita.io/docs/services/privacy/)\nin anahita-services. What follows is the app's part of it.\n\nAn installation can serve nothing to people who are not signed in\n(`SITE_READ_ACCESS=registered` on the server). The server enforces it: every\ncontent route answers 401 to them. NodeInfo still answers and says so in\n`metadata.readAccess`, and `routes/MembersOnlyGate.jsx`, which wraps the whole\nroute table, reads that. Somebody who is not signed in then sees a notice with\na Sign in button and the home page, in place of whatever page they asked for\nand at the same address, so a link to a post leads to the post once they are\nin. Every page carries `robots: noindex`.\n\nThe pages that stay open, the ways in and the pages about the site, are listed\nin `routes/membersOnly.js`.\n\nThe setting has a middle value, `preview`: visitors are sent only the start of\nwhat is public, cut on the server. The pages work, so the same gate leaves\nthem in place under a notice that this is a preview. A post that was cut\narrives with `truncated` set, and `components/SignInPrompt.jsx` shows the way\nto the rest under it. Lists stop at their first page by themselves: the server\nlowers their total to what it sent.\n\n**The app does not ask for what a visitor will be refused.** `utils/visitor.js`\nanswers, from the session and NodeInfo, whether the viewer is a visitor and on\nwhich kind of site:\n\n- On a preview site the replies under a post and the lists of who follows\n  whom are not requested. A sign-in prompt stands where they would be.\n- On a members-only site the search box and the People, Groups, Hashtags and\n  Places menu entries are not shown.\n\n**A page that could be refused waits to be drawn** until the session has been\nread and NodeInfo has answered. Drawn sooner, it asks for its content at once,\nis refused, and shows the refusal before the gate has caught up. The wait is a\nmoment, and an open site does not pay it: what the site said last time is kept\nin the browser (`site.readAccess` in `localStorage`), and a site that said\n`public` is drawn straight away.\n\nA request that is refused anyway, because some page did not expect it, is\ncaught in `src/index.js`: an unhandled \"sign in first\" answer from the server\nis dropped instead of reaching the screen as an error. Every other unhandled\nfailure is left to be seen."},{"id":"the-administration-area","heading":"The administration area","level":3,"markdown":"`/admin` is one page with a tab per thing administrators look after\n(`src/containers/admin`). Each tab names who may see it in\n`containers/admin/tabs.js`, reusing the rule the page behind it enforces:\n\n| Tab | Address | Who sees it |\n| --- | --- | --- |\n| Reports | `/admin/reports`, and `/admin/reports/:id` for one case | Administrators and super administrators |\n| Signup requests | `/admin/signup-requests` | Administrators and super administrators |\n| Invites | `/admin/invites` | Administrators and super administrators |\n| Scheduled for deletion | `/admin/scheduled` | Super administrators only |\n| Accounts | `/admin/accounts` | Super administrators only |\n\n**Scheduled for deletion** (`containers/admin/Scheduled`) lists every profile\non its way out, people, groups, events and websites together, soonest first,\neach with its days left and a Settings button to restore it or remove it for\ngood. It asks `GET /accounts` for the deleted of each kind.\n\nSite settings are not a tab. They configure the installation, where this area\nlooks after the people on it, so they keep their own page (`/settings`) and\nmenu entry.\n\n**Accounts** (`containers/admin/Accounts`) lists every person, group, event or\nwebsite, least recently active first, with filters for finding the ones nobody\nuses: an activity tier (empty, dormant, active), a state (active, disabled,\narchived, deleted), dates, never verified, never onboarded, nothing written,\nand for groups, events and websites, no administrator. A deleted group, event\nor website's Deleted chip leads to its settings, to restore it or remove it\nfor good. Accounts are ticked, or all\nthat match are selected at once, up to 500 (people and groups; events and\nwebsites are removed one at a time, from their settings), and \"Delete permanently\" opens a\ndialog that says what will go, asks for the count to be typed (`PURGE 37`) and\nfor a proof of identity, then shows the progress. The server answers a purge\nbefore the work is done, so `usePurgeProgress` asks how the batch is doing\nuntil nothing is queued. Administrators and the viewer cannot be ticked. A\ngroup with no administrator links to its settings, where one is appointed.\n\nA profile scheduled for deletion, of any kind, carries a **Scheduled for\ndeletion · N days left** label (`components/ScheduledDeletion.jsx`,\n`utils/deletion.js`) on its page and in the lists of who somebody follows,\nwhich keep it for its followers and administrators only (graph-grpc-service,\n`setLiveNeighboursQuery`). Its Danger zone offers only Restore, for a group,\nan event or a website, and Delete permanently.\n\nThe same purge for one account is the last card in a profile's Danger zone,\n\"Delete permanently\" (`containers/actors/Settings/Purge.jsx`), offered to\nsuper administrators on anybody's profile but their own. What a purge removes\nis in the services' `docs/account-lifecycle.md`.\n\nA person's **account type** (registered, administrator or super\nadministrator) is changed on the **Account type** card, the first in the\nAdministration section of their settings (`people/Settings/AccountType.jsx`),\noffered to administrators on anybody's settings but their own. Its dialog\n(`AccountTypeDialog.jsx`) offers only what the viewer may make\n(`permissions.account.personTypesFor`, mirroring `MayChangePersonType` in\nanahita-libs): an administrator makes administrators of registered people,\nand making a super administrator or taking an account type away is a super\nadministrator's. It is saved with `PATCH /accounts/:id/person-type` after a\nproof of identity (`useStepUp`). Nobody is offered their own, which the server\nrefuses too: that is what keeps the last super administrator. The change takes\neffect within fifteen minutes, when the person's sign-in is next renewed.\n\nA tab the viewer may not see is not drawn, and its address lands on the first\ntab they may. Somebody with no tabs sees a \"restricted\" message, and has no\nAdministration entry in the left menu.\n\nThe menu entry carries the number of things waiting: open reports plus pending\nsignup requests. The app reads it when an administrator's session is known,\nevery five minutes, when the window regains focus, and after a request is\napproved or rejected (`actions.admin.readCounts`, `state.admin.counts`)."},{"id":"reporting","heading":"Reporting","level":3,"markdown":"Every menu that sits on a node has a **Report** item: profiles, posts, feed\nitems, replies, hashtags and places. It is offered to anybody signed in,\nexcept on themselves and on what they wrote (`permissions/report.js`). A menu\nthat somebody could do nothing else in is now drawn for them, holding Report.\n\nThe item comes from the `useReport(viewer, node)` hook in\n`containers/reports`, which also returns the dialog. The dialog is rendered\nbeside the menu, not inside it, because choosing the item closes the menu.\nThe reasons it lists come from the server, for the kind of thing being\nreported and in the app's language, and are fetched once per kind.\n\nFirst among them is **Breaks a rule**, when there are rules to break:\nchoosing it lists the site's rules (`GET /site`, unless `SITE_RULES` is\noff) and those of the group or event the thing is in, or is. The rule's\ntitle and whose it is go with the report (`rule`, `rule_profile_id`, 0 for\nthe site's). A case names the rule and whose it is, and \"Breaks a rule\" is\none of the reasons to filter by.\n\nA group's or an event's administrators find the reports against its rules\nin the **Reports** tab of its settings (`actors/Settings/Reports.jsx`,\n`GET /abuse-reports/profiles/:id/cases`): what was reported, the rule and\nthe reporter's words, never who reported, and never a report on the group\nitself or on what the viewer wrote. **View** goes to the post, where\ndeleting it closes its report.\n\nThe Reports tab lists cases, filtered by status and by reason with two select\nlists, and adds more with a \"Show more\" button. It opens one case at a time. It records an\nadministrator's decision; it does not delete or disable anything. \"View\"\ngoes to the reported thing, where those controls already live. See\nanahita-services' `docs/abuse-reports.md`."},{"id":"what-a-viewer-may-do","heading":"What a viewer may do","level":2,"markdown":"**The server decides, and the app follows.** Every person, group, post and\nreply the API returns carries an `authorized` object answering what the\nviewer may do with it. The server works each answer out with the same checks it\nenforces:\n\n| Field | On | Answers |\n| --- | --- | --- |\n| `edit`, `delete`, `administration` | Everything | Whether the viewer may edit, delete or administer it |\n| `composers` | People and groups | Which kinds of post the viewer may create on the profile |\n| `audiences` | People and groups | Who a post the viewer writes there may be shown to |\n| `addFollower` | Groups | Whether the viewer may add somebody else as a follower |\n| `comment` | Posts and replies | Whether the viewer may reply. The name is from when replies were comments |\n| `like` | Posts and replies | Whether the viewer may like it |\n\nThe helpers in `src/permissions/` read these answers. Where a response does not\ncarry an answer, the helpers fall back to a simple rule, and the server still\nrefuses anything it must. The app never works out a permission rule itself:\nthe rules depend on the profile's access, the viewer's relationship to it and\nthe profile's own settings, and a second copy of them in the browser has\ndrifted before.\n\nThe rules themselves, and how to change their defaults, are documented in\nanahita-services under Permissions."},{"id":"choosing-who-can-see-a-post","heading":"Choosing who can see a post","level":3,"markdown":"One button, `components/AudienceButton.jsx`, in the two places the question\ncomes up: beside Post in the composer\n(`containers/media/Composer/Audience.jsx`), and on a post that exists, for\nsomebody who may change who sees it (`containers/controls/medium/Access.jsx`).\nA post carries its own `authorized.audiences` for that.\n\nIn the composer, it names the current choice and\nopens a short menu; the choice is sent with the post as `access`, so a post is\nnever public first and narrowed afterwards.\n\nWhat it offers comes from the server: every person and group carries\n`authorized.audiences`, the audiences a new post there may have (the services'\n`docs/permissions.md`, *Choosing who can see a post*). `src/utils/audience.js`\nreads that and adds what the server has no opinion on:\n\n- The options depend on where the post is going: your own profile, somebody\n  else's, or a group.\n- An audience wider than the profile itself is shown disabled, with the\n  reason: a post is never seen more widely than its profile.\n- It starts on the last audience used for that kind of place, remembered per\n  person in the browser's `localStorage`, or else on the widest the profile\n  allows. It works the same with storage unavailable, only without a memory."},{"id":"the-language-of-a-post","heading":"The language of a post","level":3,"markdown":"Beside the audience button the composer has a language button\n(`components/LanguageButton.jsx`), showing a code such as `EN`. The choice is\nsent with the post as `language`. `src/utils/postLanguage.js` decides where it\nstarts: the posting language on their profile, which the session's viewer\ncarries; then the browser's language, which is the computer's; then\nEnglish. A different language chosen for one post applies to that post only.\nIt once remembered the language somebody last posted in, which made one post\nsent in the wrong language the start of every post after it; it no longer\ndoes, and clears what it kept. Language names come from the browser\n(`Intl.DisplayNames`), in the language the app is being read in.\n\nWhere a post's text is drawn, `components/NodeBody.jsx` puts the post's\nlanguage on it as the `lang` attribute, so a screen reader reads each post in\nthe right one.\n\n**Right-to-left writing.** The interface is left to right and has no\nright-to-left layout. What people write is another matter: a post's title and\ntext, and the fields they are typed into, carry `dir=\"auto\"`, so Persian,\nArabic or Hebrew runs right to left and aligns to the right. The direction\ncomes from the text, not from the language tag, so it holds for untagged and\nolder posts, and each paragraph of a post is judged on its own. **While a\npost is written** the box follows the language chosen for it\n(`postLanguage.directionOf`): right to left for Persian, Arabic, Hebrew,\nUrdu and the others, so an empty box starts at the right, and changing the\nlanguage changes the direction at once; otherwise from the first letter\ntyped. Replies and quotes have no language button and follow the\nwriter's own language. A placeholder takes its direction from its own words\n(`unicode-bidi: plaintext`, in `styles/index.js`), so an English one reads\nrightly in a box set right to left. Mirroring the\nwhole interface (MUI's `direction: 'rtl'` theme with the stylis RTL plugin)\nwould come with a Persian or Arabic translation of it, and there is none yet.\n\n\"Language you post in\" is in the profile form. It is the language somebody\nwrites in, not the language of the interface.\n\nWhat the server stores and accepts is in the services'\n`docs/post-language.md`."},{"id":"the-photos-of-a-post","heading":"The photos of a post","level":3,"markdown":"A photo post holds up to twenty images, or fewer where the installation says\nso (`PHOTO_MAX_FILES`). The number comes from the server\n(`metadata.photoMaxFiles` in NodeInfo); the app does not carry it, but for\nfour when the server has not said (`utils/photoFiles.js`).\n\n**Making one.** The photo composer (`Composer/Forms/Photo.jsx`) shows\n`components/PhotoFilesEditor.jsx`: pick or drop several images, put them in\norder with the arrows under each, remove one, and describe each with its\n`ALT` button. Each image is uploaded the moment it is picked, in a request of\nits own (`api.photos.upload`), so the Post button waits until all of them are\nstored and the post itself is then a small JSON request naming the uploads.\n\n**Showing one.** A post with more than one image is drawn by\n`components/PhotoSlides.jsx` on the post card, in the feed and on the photo's\npage. Material UI has no carousel, so this is one made for this one job from\nits small parts (`ButtonBase`, `IconButton`, `MobileStepper` for the dots)\nwith no other library. The photos sit side by side in a row that the browser\nscrolls sideways and brings to rest on one photo (CSS scroll snap), so on a\nphone a photo follows the finger and carries on with the flick. Where there\nis a mouse there are arrows, and the left and right keys work on the focused\npost. Photos after the first load when they are about to come into view. The\nframe takes the first photo's shape, kept between 4 by 5 and about 2 by 1,\nand a photo of another shape is shown whole inside it.\n\nThe lightbox shows a post's images as the same kind of row, swiped the same\nway. It goes through a post's images before it goes on to the next post: the\narrows and the arrow keys step through them, and a swipe leaves the post only\nfrom its first or last image. `Stepper/index.jsx` holds which image is on\nshow; the row tells it when a swipe has moved. Zooming takes the image on\nshow out of the row and shows it at full size, as for any photo. A post with\none image is drawn as it always was.\n\nEvery image carries its description as `alt`. One without a description falls\nback to the post's title.\n\n**Changing one.** \"Edit photos\" in a photo's menu opens\n`containers/media/PhotoFilesDialog.jsx`, the same editor over what the post\nhas. Save sends the whole list as it is to be; nothing changes before that.\n\nThe rules that can be tested without a browser are in\n`src/utils/photoFiles.js`: what can be sent, how a list is reordered, what a\nstep lands on. `asSingle(medium, index)` gives the post as if one of its\nimages were its only one, which is how everything that already draws a\nphoto from `portraitUrls` draws any of them.\n\nIn preview mode a visitor is sent one small image of a post however many it\nhas, so it is drawn as a single photo.\n\nWhat the server stores and accepts is in the services' `docs/photos.md`."},{"id":"replies","heading":"Replies","level":3,"markdown":"A post of any kind is answered with replies, each of which can be answered\nin turn. A reply is a post of any kind too: words alone make a note, pictures\na photo post, a GIF or a video a video post. `containers/replies/Thread.jsx` shows them, as the Replies\ntab on a post's page and in the lightbox. What used to be comments are\nreplies, made directly to the post.\n\n- **The thread is read whole** (`api.replies.thread`) and kept in the\n  component, not in the store. Nothing else on the page shows it.\n- **`src/utils/thread.js`** arranges the flat list the server sends into\n  replies and the replies under them, and holds the rule for each change: a\n  reply added, edited, removed, hidden. After a change is made on the server\n  the same change is made to the copy on the page, so the thread is not read\n  again after every reply. It is plain functions with tests.\n- **What a viewer may do comes with each reply,** in `authorized`: `comment`\n  (reply to it), `edit`, `delete`, `hide`, `like`. A control is drawn only\n  when its answer is yes. Whether somebody may reply to the post at all comes\n  with the thread, as `canReply`.\n- **A removed reply that had been answered** arrives as `deleted`, with no\n  text and no author, and is drawn as a line saying so with its replies still\n  under it.\n- **Hidden replies** are sent only to whoever may show them again, marked\n  `hidden`. They are drawn apart, under the thread, closed until opened.\n  The server also leaves out, for every reader, the replies of somebody the\n  post's owner or writer has blocked. A reply from Bluesky is hidden there\n  too, when the post went there; one from the fediverse is hidden here\n  only, and asking to hide it says so, naming its server\n  (`utils/federation.js#fromFediverse`).\n- Replies are set in a level for each step down, up to four, with a line down\n  the side. Past that they line up, so a long exchange fits a phone.\n- **The reply box** (`ReplyForm`, with `attachments` for a new reply) has a\n  button for pictures and, where videos may be posted, one for a GIF or a\n  video: one or the other, with words or without. Pictures use the photo\n  composer's editor (`PhotoFilesEditor`, uploaded for the person replying)\n  and go to `api.replies.addWithPictures`. A GIF or a video is sent as a\n  video is, with a `parentId` (`VideoReplyOnTheWay`, through\n  `useVideoUpload`), shown as being sent and then prepared under the box,\n  and the thread is read again once the reply is made. Nothing about who\n  sees a reply, who may answer it or its language is asked: the thread's.\n- **A reply shows its pictures** (`PhotoSlides`) **or its video**\n  (`VideoPlayer`, the looping clip for a GIF) under its words. Its words are\n  changed at `/notes/:id` whatever its kind; it is removed under its own\n  kind's address (`api.replies.remove`), which takes its files with it.\n\n- **Who can reply** is the post's own setting, chosen by its author:\n  anyone, nobody, or any of the people who follow them, the people they\n  follow and the people the post mentions. `components/ReplyAccessDialog.jsx`\n  asks it as two questions, anyone or nobody and then who is let in anyway,\n  and `utils/replyAccess.js` turns that into the one value the server keeps.\n  The dialog opens from `ReplyAccessButton` in the composer, beside the\n  audience and the language, and from \"Who can reply\" in a post's menu\n  (`controls/medium/ReplyAccess.jsx`). The thread reads the setting with the\n  replies and says it when it is limited.\n\n- **A post's menu in a feed** has Edit and Delete where the server says the\n  viewer may (`authorized.edit`, `authorized.delete`, sent with each feed\n  item). Edit opens the post's page with its form up (`?edit=1`, which is\n  then taken out of the address). A deleted post leaves every feed on the\n  page, with any repost of it (`reducers/createFeed.js`).\n- **In a feed**, the reply button under a post is a link to the post's page,\n  where its thread is (`containers/feed/components/ReplyButton.jsx`). How\n  many replies it has is in its interactions.\n\n- **A reply has a page of its own**, at `/notes/:id` like any note. It is\n  drawn as the post, with what it answers above it\n  (`containers/replies/ReplyContext.jsx`) and below it the part of the thread\n  that is under it: the thread is read whole for the post at the top, and\n  `utils/thread.js` picks out the branch. Replying there answers that reply.\n- **Where a reply was said** is shown above it wherever it is away from its\n  thread: the profile the thread is on, a person, a group or any other actor,\n  from `root.owner`. A reply is owned by whoever wrote it, so its own owner\n  does not say. A post shows the profile it is on the same way when that is\n  not its author's own.\n- **A person's profile has four lists**: Posts, Tagged, Replies and\n  Reposts. Tagged is the photos and videos they are in, their tag approved,\n  whoever posted them (roadmap row 103). Only people reply, repost and are\n  tagged, so a group has Posts alone. They are the first tabs of\n  `containers/actors/Read/Body.jsx`. All four are the profile's feed asked\n  for with a different `filter` (`containers/feed/Actor`); they share one\n  place in the store, so each is keyed and read when its tab is opened.\n  Posts are one column beside the profile's details; the others fill the\n  page in the same masonry as the lists of notes, articles and\n  photos (`components/BreakpointMasonry.jsx`). In Reposts the reposted post\n  is drawn as itself, not inside a card for whoever reposted it.\n  The tab is in the address. Reposts are not among the posts unless the\n  profile's owner asks for that, under Settings › Privacy\n  (`containers/actors/Settings/RepostsOnProfile.jsx`), and the server applies\n  it."},{"id":"home","heading":"Home","level":3,"markdown":"Home for somebody signed in is `containers/feeds/index.jsx` (it was\n`Dashboard.jsx`; the menu says \"Home\" either way): the feed of the people and\ngroups the viewer follows, with the custom feeds the viewer keeps there as\ntabs after it.\n\n**Posting is the + button** at the bottom right\n(`containers/media/Composer/ComposeButton.jsx`; roadmap row 99,\n[https://github.com/anahitasocial/anahita-web/blob/main/docs/plans/done/compose-button.md](https://github.com/anahitasocial/anahita-web/blob/main/docs/plans/done/compose-button.md)), on the home page and on\na profile the viewer may post to, and nowhere else. The page says where it\nposts while it is open (`reducers/composeTarget.js`): from home to the\nviewer's own profile, from a profile to that one. It opens the short\ncomposer, \"New post\" (`Composer/Short.jsx`), whose Long content opens the\nlong one (`Composer/Long.jsx`); a profile that takes only long writing opens\nthat at once. A ring goes round the button while a video posted from it is\nbeing prepared. What the viewer posts is at the head of an open feed it\nbelongs in at once (`reducers/lastPosted.js`, `feed/usePosted.js`): the\nhome feed, and the profile it was posted to."},{"id":"suggestions","heading":"Suggestions","level":3,"markdown":"Suggestions of who to follow come from the installation's recommendation\nservice (`/recommendations/`; the services' `docs/recommendations.md`). The\nservice is optional, and everything here draws nothing where there is none:\na request that fails as it does without the service\n(`utils/recommendations.js#isAbsent`) is taken as there being no suggestions.\n\n- **Who to follow** on the home page, above the feed\n  (`containers/recommendations/WhoToFollow.jsx`): three people, each with the\n  reason, **+ Follow** and a ✕ for not interested, and a button on to the\n  rest. As wide as the feeds: when they are read in columns, the three are\n  side by side, each outlined like a card with its ✕ a small circle on the\n  top left corner; otherwise a list. Not drawn while it is read or when there is nobody. The ✕ in\n  its header puts the whole card away for seven days, remembered in the\n  browser (`utils/recommendations.js#hide`); Reset on the Recommendations\n  page brings it back.\n- **The page** at `/recommendations` (`containers/recommendations/index.jsx`):\n  People, Groups and Events, the open tab in the address (`?kind=group`),\n  as cards with the reason inside each, under the name. An event is drawn with the event's\n  own card and is not followed from here. It is in the left menu as\n  **Recommendations**, where the installation has the service\n  (`useHasSuggestions.js` asks once).\n- **Feeds,** the last tab (`?kind=feed`, `containers/recommendations/SharedFeeds.jsx`),\n  is not chosen by the recommendation service: it is the feeds people have\n  shared that the viewer may open, newest first (feed-service,\n  `GET /feeds/discover`), without the viewer's own, each with the button\n  that adds it to the home page. The weights and preferences beside the\n  tabs do not change it.\n- **What counts** is the viewer's to weigh. The button beside the tabs, and\n  one in settings, opens a dialog of sliders\n  (`containers/recommendations/WeightsDialog.jsx`), one a reason, from nothing,\n  which leaves the reason out, to the most there is; Reset puts them back\n  to the installation's own. Saved, the list is read again. The server\n  names a reason in snake case and the keys of what it sends are put in\n  camel case on the way in: `utils/recommendations.js#weightKey` is the one\n  place that knows.\n  The last slider is not a reason: how lately a person must have been here\n  to be suggested at all, over the steps the server gives, from the last\n  month to any time. A year unless the viewer says.\n- **The reason is always shown.** `utils/recommendations.js#reasonText`\n  puts the first reason into words from what the service sent with it\n  (\"Followed by Ana and 2 others\"), and counts where it may name nobody.\n- **Not interested** takes a suggestion out at once and tells the service,\n  which does not make it again.\n- **What the viewer is recommended** is chosen on the Recommendations page\n  itself, from the cog beside the weights\n  (`containers/recommendations/PreferencesDialog.jsx`): whether people, groups\n  and events are suggested, whether what they like and post is used, and\n  the suggestions they removed, each to be taken back.\n- **Whether they are suggested to others** is a privacy choice, and is the\n  one switch in Settings › Privacy\n  (`containers/actors/Settings/Suggestions.jsx`)."},{"id":"videos","heading":"Videos","level":3,"markdown":"A video post plays in `components/VideoPlayer.jsx`, the browser's own\nplayer with a cog in its top corner that opens the sizes in a menu, the one playing ticked. Posting one is a composer of its\nown, because a video is too big to be sent with the post (the services'\n`docs/videos.md`).\n\n- **Whether there is video at all** is the installation's choice. Where it\n  is off, the server leaves the video feature off every profile, so there\n  is no Video composer and no Videos tab, and nothing here has to hide\n  them. NodeInfo's `videoUploads`, `videoMaxBytes`, `videoMaxSeconds`,\n  `animationMaxBytes` and `animationMaxSeconds` say what is allowed\n  (`utils/videos.js`).\n- **Posting** (`containers/media/Composer/Video`): the file is refused at\n  once if it is not a video or is too large. `useVideoUpload.js` then\n  begins the upload, cuts the file into parts and sends them to storage\n  three at a time, each tried again when it fails, asking for addresses a\n  few at a time. The page has to stay open for that, and says so. When\n  everything is sent the server prepares the video, and the page can be\n  left: the composer asks after it and says when it is posted, and the +\n  button has a ring round it until then.\n- **Videos on the way** are listed in the composer: ones still being\n  prepared, and ones that failed in the last day, each with why.\n- **The parts go to storage, not to the API**, through an axios instance\n  of their own (`api/videos.js#sendPart`) that sends no cookies and none\n  of the API's handling.\n- **Playing:** a video is kept at a few sizes. It starts at 720p, at the\n  smallest where the browser says the connection is slow, or at the size\n  chosen last time, which is kept in the browser (`utils/videos.js#pick`).\n  1080p and above are played only when chosen. Changing size carries on\n  from the same moment. Nothing changes size while playing.\n- **A video's files are paths under the API** (`video.files[].url`,\n  `video.poster`), which ask each time whether the viewer may see the post\n  and send the browser on to storage. The player is a plain `<video>`, so\n  the browser sends the session cookie itself.\n- **A GIF or an animated PNG** is posted from the Video composer too,\n  which lists them with the video formats and holds them to their own,\n  smaller limits (`utils/videos.js#problem`). The server makes it a\n  silent clip and marks the post `video.loop`. Where video is off there\n  is no Video composer, so none can be posted; the Photo composer takes\n  JPEG and PNG pictures as before, and keeps an animated PNG still. A\n  picture that does not move is refused by the server as `still`, and the\n  composer says to post it as a photo.\n- **A looping post plays in `components/LoopingClip.jsx`**, which\n  `VideoPlayer` hands it to: muted, on a loop, with no controls or sizes.\n  It plays only while at least half of it is in view and stops when\n  scrolled away, so only the few on the screen move, not every one on a\n  long page. Where the reader asked for less motion\n  (`prefers-reduced-motion`) it waits to be pressed. The whole picture is\n  one button that stops and starts it, and a small \"GIF\" sits in its\n  corner.\n- A video is a kind of post like the others: `/videos/:id`, the `videos`\n  namespace in the store, and the kind `video` in custom feeds."},{"id":"downloading","heading":"Downloading","level":3,"markdown":"A photo or a video has a Download button in the row under it on its own\npage, where the server said this viewer may download it\n(`authorized.download`; `utils/downloads.js`). Whoever posted it decides,\nand it is off until they say otherwise. It is not on a post's card in a\nfeed or a list: a card is narrow, and one more button does not fit on a\nphone.\n\n- **When posting**, the photo and video composers have a download button\n  among the post's options (`components/DownloadOptionButton.jsx`), which\n  starts on what the profile being posted on says.\n- **Afterwards**, \"Allow download\" and \"Do not allow download\" are in the\n  post's menu on its own page, for whoever may edit it\n  (`containers/controls/medium/Download.jsx`).\n- **The default** is a switch under Settings › Permissions, for a person\n  and for a group, not for an event\n  (`containers/actors/Settings/DownloadDefault.jsx`).\n- **The download** sends the browser to the API's address for the file,\n  which answers with something to save, so the page stays where it is. A\n  post with several images offers each in a short menu."},{"id":"download-your-data","heading":"Download your data","level":3,"markdown":"**Settings › Account › Download your data**\n(`containers/actors/Settings/DataExport.jsx`), on the viewer's own\nsettings: a copy of everything they put here, as one file, from the\ninstallation's export service (`/exports`; the services'\n`docs/data-export.md`).\n\n- **Asking** opens the step-up dialog (`containers/auth/StepUp`) when the\n  server says a fresh proof is needed, and asks again once it is given.\n- **While it is made**, the card asks after it every five seconds and can\n  be left; it can be cancelled.\n- **When it is ready**, the card says how big it is and until when it is\n  kept. Download asks the server for the file's address at the press: the\n  address is good for a few minutes, so one read with the page may have\n  run out.\n- **One at a time, and one a week** unless the installation says\n  otherwise. The card says when the next can be asked for\n  (`utils/exports.js#canAsk`).\n- **Where there is no export service**, the card says the download is not\n  available here (`utils/exports.js#isAbsent`)."},{"id":"people-in-a-photo-or-a-video","heading":"People in a photo or a video","level":3,"markdown":"Who is in a photo or a video (roadmap row 103) shows on its page, right\nunder the picture or the player, and in the theatre above its words:\n\"With Worf and Dax\", each a link, joined as the reader's language joins a\nlist (`containers/media/PeopleInPost.jsx`, `api/peopleTags.js`). A tag\nwaiting for somebody shows only to them and the post's author, marked as\nwaiting; the person tagged accepts or declines it there, or takes\nthemselves out later with **Remove me**. Not on cards in a feed, which would\nask the server once for every card. Who may tag whom is anahita-services'\n`docs/permissions.md`.\n\nIts author tags people with **Tag people**: in the composer, among its small\nbuttons once there are photos, the people chosen shown as chips and tagged\nas soon as the post is made; and from a photo's or a video's menu, which\nadds and takes off (`containers/media/TagPeopleDialog.jsx`). Each person\ndecides: tagged at once, asked first, or not at all, and the author is told\nwho is asked and who cannot be. People are found by name or by handle."},{"id":"locations-of-a-new-post","heading":"Locations of a new post","level":3,"markdown":"The pin among a composer's small buttons, **Add location**, in the short box\nand the extended composer, tags a new post with up to five locations\n(`containers/media/Composer/usePostLocations.jsx`, `LocationPicker.jsx`; roadmap\nrow 82, part 3). Not replies, which belong to their thread, and not a video,\nwhose post is made only once its upload is ready.\n\n- **Choosing:** a popover with a search as one types, the arrow keys and\n  Enter, and a **Near me** chip that asks the browser for one position only\n  when it is tapped, to put the nearest first; it is used for that search and\n  kept nowhere.\n- **A new one:** the last line, **Add \"…\" as a new location**, for those who\n  may add one (`LOCATIONS_FROM`), opens the Add locations form with the name\n  filled in, and what it makes is chosen.\n- **Shown** as chips under the text, removed with their ✕.\n- **From a photo's position:** when a photo with a GPS position is picked\n  for a post, the browser reads it (`utils/photoPosition.js`, with\n  `exifr`, loaded only then, reading only the start of the file), and the\n  popover offers **This photo has a location. Suggest locations near it?**\n  Only when that is tapped is the position used, once, as the point of that\n  search, and as the pin's start if a new location is added; it is never\n  shown, sent elsewhere or kept, and the file uploaded is the one chosen,\n  stripped on the server as always (roadmap row 82, part 5).\n- **Kept** with the draft in this browser, one list for each profile, shared\n  by the short box and the extended composer, so they come along when one\n  becomes the other.\n- **On posting** they are sent with the post (`location_ids`), and the\n  server ties them as it makes it, before it goes to other servers, which it\n  then goes with: the first as its `location`, all as `Place`s in its `tag`\n  (the user, 2026-10-19: choosing them is the author's consent, for that\n  post; part 3b). Where the post will go out, a line under the chips says\n  so. A draft kept on the server is tied to them before it is published.\n  A form sends a list as the same key once for each item\n  (`utils/api.js#constructFormData`)."},{"id":"link-previews","heading":"Link previews","level":3,"markdown":"A note with a link in it can carry a card for the link: the page's picture,\nsite, title and a line about it (`components/LinkPreviewCard.jsx`). The\ncard comes from the installation's link preview service (`/link-previews`;\nthe services' `docs/link-previews.md`), which is optional: with none there\nis simply no card.\n\n- **While a note is written**, `containers/media/Composer/useLinkPreview.js`\n  finds the first link once the writer has paused for half a second, or\n  at once when it was pasted, and asks for its card. The ✕ on the card posts the note without it. A link\n  with nothing to show, or whose card was taken off, is not asked for\n  again.\n- **While a note is edited** on its own page the same hook is used, given\n  the card the note already has, which it shows without asking again. A\n  different link gets its own card, and the ✕ saves the note without one\n  (`link_preview_remove`).\n- **A reply is a note** and has the card the same way: the reply box\n  (`containers/replies/ReplyForm.jsx`) uses the same hook, for a new reply\n  and for one being changed, and a reply shows its card under its text.\n- **The note is sent with the link only** (`link_preview_url`). What the\n  card says is what the server read off the page, never what the browser\n  sends.\n- **On a post**, the card is `linkPreview` on the node, drawn under the\n  text on the feed, on cards and on the post's own page. Its picture is the\n  installation's own copy (`utils/linkPreviews.js#imageOf`), so reading a\n  note asks nothing of the other site.\n- **A link the player plays** (YouTube, Vimeo and the others in\n  `components/Player.jsx`) keeps the player and has no card\n  (`utils/linkPreviews.js#isShown`)."},{"id":"titles-and-tags","heading":"Titles and tags","level":3,"markdown":"`components/HeaderMeta.jsx` keeps the tab's title and the page's tags in\nstep as the viewer moves about. A site a link is pasted into never sees\nthem: it runs no JavaScript. What it reads is written into `index.html` by\nthe services' `web-shell`, when the app is hosted through it\n([Deploying](https://www.anahita.io/docs/web/deploying/#through-the-web-shell)). The site's name and\ndescription in the build's `index.html` are what the shell uses for pages\nwith nothing of their own to say."},{"id":"privacy-and-permissions-in-settings","heading":"Privacy and permissions in settings","level":3,"markdown":"A profile's settings keep two things apart:\n\n- **Privacy** is who can see what: who can see the profile, whether\n  reposts show on it, who is blocked, and whether the person is suggested\n  to others. (Its key in the code and in the address is still `access`.)\n- **Permissions** is who can do what: who can post and reply, who can\n  quote, and whether new photos and videos start out downloadable.\n\nA setting goes where its question does. What somebody is shown, such as\nwhich recommendations, is neither, and is chosen where it is shown."},{"id":"sharing","heading":"Sharing","level":3,"markdown":"Share is the paper plane. It opens one dialog for the whole app\n(`containers/share/Dialog.jsx`, drawn once beside the routes) with the link\nand the ways to send it: copy it, email it, post it on Mastodon or Bluesky,\nand, where the browser has one, the device's own share sheet (\"More\noptions\"), which has every app the person uses. Mastodon asks for the\nperson's server the first time and keeps it in the browser.\n\n- **Anything on a page opens it** through `utils/share.js#open`:\n  `ShareAction`, `ShareButton` and `ShareMenuItem` in\n  `containers/controls/Share.jsx`. A menu can close behind its item, since\n  the dialog is not the menu's.\n- **Where it is:** under a post, in the row with like, reply and repost, in\n  a feed, in a list and on its page; a profile's menu; a shared custom\n  feed.\n- **Only what a visitor could open is sent beyond the site.** Something that\n  is not public, and anything on a members-only site, is offered to copy and\n  nothing else, with a note saying who can open the link.\n- **Nothing is counted and nothing is sent to the server.** The links to\n  other networks are plain addresses, and no script of theirs is loaded."},{"id":"the-width-of-a-page","heading":"The width of a page","level":3,"markdown":"`App.jsx` wraps every page in one `Container`, which sets the most a page can\nbe and its gutters. Inside it a page chooses its own width.\n`components/PageColumn.jsx` is the usual choice: the whole width on a phone,\ntwo thirds of the page, centred, on a wide screen, or all of it with `wide`.\nHome, the pages of custom feeds, a post's own page and the event forms use\nit. A profile, with its side column, lays itself out."},{"id":"describing-photos","heading":"Describing photos","level":3,"markdown":"Each image of a photo post has its own description, for people who cannot\nsee it (`altText` on each of `medium.files`).\n\n- **Writing it** is a field under each photo in the composer and in\n  \"Edit photos\" (`components/PhotoFilesEditor.jsx`). The photos are a row\n  that is swiped through one at a time, like the slides a reader sees,\n  each with its place in the order and the way to remove it; a photo just\n  picked is the one shown. Under a photo with no description is a prompt,\n  \"Add an alt description\", with why in a line beneath it; pressing it\n  opens the field, which is called \"Alt description\" so that it is not\n  taken for the post's own description just below. It is never required.\n- **Hearing it:** it is the `alt` of the image wherever a photo is drawn\n  (`utils/photoFiles.js#altOf`, which falls back to the post's title).\n- **Reading it:** `components/AltBadge.jsx` puts \"ALT\" in the corner of a\n  photo that has a description of its own (`descriptionOf`, which does not\n  fall back), and shows it when pressed. It sits over photos that are\n  links or open the lightbox, and pressing it does neither."},{"id":"pictures-that-are-no-longer-kept","heading":"Pictures that are no longer kept","level":3,"markdown":"The server removes the pictures of a post from another network once the\npost has been left alone for a while, and copies them again when asked. A\npost whose pictures are gone has no `files` and carries\n`mediaRemoved: { count, alts, gone }`.\n\n`components/RemovedPictures.jsx` is drawn where the pictures were: how\nmany there were, what each showed, and a button that asks for them\n(`api/federation.js#restorePictures`, which goes to whichever service the\npost came through). When they are back it reads the post again and draws\nthem itself. On the post's own page it asks at once. When the answer is\nthat they are no longer to be had, it says so in place of the button."},{"id":"new-posts-in-an-open-feed","heading":"New posts in an open feed","level":3,"markdown":"A feed that is open asks again, now and then, for its first page\n(`containers/feed/useNewPosts.js`, for the home feed and every custom\nfeed).\n\n- **It asks less and less often, and stops.** After 15 seconds, then 30,\n  60, 120 and 240; when the last of those finds nothing, it asks no more\n  (`utils/heartbeat.js`). Finding something begins it again at 15 seconds.\n  So does the reader coming back to the tab or the window, which is asked\n  at once. Nothing is asked while the tab is hidden.\n- **At the top of the page, what is new is put in at once.** Further\n  down, the page is left alone, since a page that grows above what is\n  being read loses the reader's place: a button, \"3 new posts\", stays in\n  sight (`containers/feed/components/NewPosts.jsx`) and puts them in and\n  goes to the top when pressed.\n- **In the store** it is `<FEED>_BROWSE_PREPEND` (`reducers/createFeed.js`):\n  the page goes before what is held, in the server's order, and what is\n  held already is brought up to date. When none of the page is held, more\n  was posted than a page holds, and the feed is begun again from it."},{"id":"notifications","heading":"Notifications","level":3,"markdown":"The bell in the top bar says how many notifications are unread, and leads\nto `/notifications`, the list of them. What the services tell, and to\nwhom, is in anahita-services' `docs/notifications.md`; none is ever\nemailed.\n\n- **The bell asks; there are no sockets** (`containers/notifications/useUnreadCount.js`).\n  It asks at once when the app opens, when the tab is looked at again,\n  when the window comes into focus and when the connection comes back;\n  then after 15 seconds, 30, 1 minute, 2, and every 5 minutes after that\n  (`REACT_APP_NOTIFICATIONS_CHECK_INTERVAL`), for as long as the tab is\n  looked at, and never from a tab that is not. Something new, or the\n  viewer doing anything that tends to bring answers (a post, a reply, a\n  follow: any request but a `GET`, theirs or for notifications), begins it\n  again at 15 seconds. Each wait is a fifth longer or shorter at random,\n  and a failure keeps the last count. All of it is `utils/heartbeat.js`\n  with `holdLast` and `spread`.\n- **One tab asks for all of them.** The tab being looked at takes a Web\n  Lock (`anahita-notifications-check`) and tells the others the count over\n  a `BroadcastChannel` (`anahita-notifications`); when it is hidden or\n  closed, the next one looked at takes over. Where a browser has neither,\n  each tab asks for itself.\n- **The count is in the store** (`reducers/notificationsUnread.js`), with\n  when the newest was told (`newestAt`, from `GET /notifications/unread-count`).\n  Marking one read or deleting an unread one lowers it at once. The list,\n  when open, loads again from its start when something newer comes.\n- **The bell's menu** (`containers/notifications/Menu.jsx`), on a desktop:\n  the latest eight as on the page, grouped and with their actions, Mark all\n  read and See all. Following a link in it reads that one and closes it. On\n  a phone the bell goes to the page.\n- **What one is told about** is a person's own Notifications section in\n  settings (`actors/Settings/NotificationChoices.jsx`): eight kinds of\n  thing, on or off, each saved as it is switched.\n- **The list** is `containers/notifications/Browse/`. Each kind has its\n  words in `notifications` (`notifications:replyAdd` for `reply_add`), and\n  `constants/notifications.js` lists the kinds the services send, which a\n  test keeps in step with the words; a kind the app does not know says\n  `notifications:other`. Its links stay in the app, unread is said in\n  words to a screen reader, and the next page begins after what is on the\n  page, so deleting one skips nothing.\n- **Read when opened:** following any of a notification's links marks it\n  read, quietly (`Browse/ListItem.jsx`, `onClickCapture`); seeing it in the\n  list does not. A profile with no name yet is named by its handle.\n- **Several as one:** likes of one post, reposts of one post and follows of\n  one profile come as one line (`ids`, `others`, `others_count`), \"dax and 1\n  other liked your post\" (`notifications:voteupOthers`, in each language's\n  plurals), with their pictures overlapping. Reading, opening or deleting\n  it does so to all of them, and the bell asks again how many are unread;\n  the next page begins after every row on the page. Marking one read keeps\n  its line whole (`reducers/notifications.js`).\n- **Actions** under a notification (`Browse/Actions.jsx`), as the server\n  says they are still to be done (`actions`): Decline and Accept a request\n  to follow, or a request to be tagged in a photo or a video (`answer_tag`,\n  roadmap row 103), **+ Follow** whoever followed, and Reply, which opens the reply\n  form under it and answers the reply itself, or the post. Using one reads\n  the notification.\n- **Mark all read**, in the page's header (the icon alone on a phone),\n  marks every one read (`PATCH /notifications/read-all`), the list and the\n  bell with them.\n- **Get notifications** in a post's menu subscribes to its replies\n  (`containers/controls/medium/NotificationSub.jsx`), and Stop\n  notifications ends it."},{"id":"custom-feeds","heading":"Custom feeds","level":3,"markdown":"A custom feed is one somebody builds: where its posts come from, what they\nare about, what to leave out. The server's side is in the services'\n`docs/feeds.md`. The words a person reads are always \"custom feed\", never\n\"algorithm\".\n\n- **Home tabs.** The first tab is Following, the feed there always was\n  (`containers/feed/Leaders`). After it, the feeds from `GET /feeds/pinned`,\n  in the viewer's order. The open tab is in the address as `?feed=<id>`.\n  The way to `/feeds` is \"Feeds\" in the left menu; the tabs have no button\n  of their own for it, which looked like a setting (the user, 2026-10-19).\n- **Back to the top.** Once somebody has scrolled about a screen down Home\n  or a profile, an arrow fades in at the bottom left, across from the +\n  button, and takes them back up (`components/BackToTop.jsx`).\n- **One column or several.** On a wide screen a pair of buttons at the end\n  of the tabs lays the open feed out one post under another or in columns\n  (`components/BreakpointMasonry`), when it takes the width of the page. The\n  choice is kept in the browser (`home.feedView`). On a narrow screen there\n  is one column and no buttons.\n- **One set of cards.** `containers/feed/components/Items.jsx` draws a feed's\n  posts and reposts, with their menus and buttons, for Following and for\n  custom feeds alike. `containers/feed/Custom` is the list for a custom feed,\n  given a saved `feed` or an unsaved `definition`.\n- **Paging.** A custom feed is paged by a cursor the server hands back, and\n  the store's lists are kept by how far down they are. `api/customFeeds.js`\n  keeps each list's cursor and reports a page to the store in the store's\n  terms (`feed_custom`, beside `feed_leaders` and `feed_actor`).\n- **Under Home in the left menu** are the feeds on the viewer's home page, in\n  their order, each opening Home on that feed. The list page is an entry of\n  its own below them, **Feeds**, like Saved. A feed is made from the + on the Feeds page. A feed is put on its maker's home page when it is made, so it\n  appears there at once. The menu and the tabs read the same list\n  (`containers/feeds/usePinnedFeeds.js`), again whenever a feed is made,\n  changed, deleted or moved: `api/customFeeds.js` tells whoever asked\n  (`onChange`).\n- **The list page** (`containers/feeds/Manage.jsx`, reached from \"Feeds\" in\n  the menu): the viewer's\n  feeds and the ones of other people's they keep.\n- **Rows of one height.** A feed's row in the list is its name, who can\n  see it and when it was made, and one line cut short: what it is for, or\n  what it shows when it does not say. Pressing the name opens the feed in\n  full in a dialog (`containers/feeds/DetailsDialog.jsx`): its description\n  and every setting it has, one line each, with a button on to the feed.\n- **Finding shared feeds:** the site's search, and the Feeds tab of\n  Recommendations (see *Suggestions*). In search a feed that matches is a\n  result like any other, among the profiles and posts under **All**, and by\n  itself under a **Feeds** tab (`scope=feeds`). Search sends it as a node\n  with a `feed` block; `containers/feeds/SearchCard.jsx` draws it as a card\n  (`components/CustomFeedCard.jsx`) in the same columns, with the button\n  that adds it to the home page. Feeds kept to their owners are never found.\n- **The editor** (`containers/feeds/Form.jsx`) is a form in the page, for a\n  new feed at `/feeds/new` (`Add.jsx`) and for changing one at\n  `/feeds/:id/edit` (`Edit.jsx`). Both are reached from the list of feeds:\n  the + in its header, and the pencil on a feed's card. Hashtags and keywords\n  are typed as chips (`components/WordsField.jsx`); profiles and places are\n  searched for and picked (`components/NodesField.jsx`). People on other\n  servers are found there too, through the site's search, if anybody here\n  has followed or looked them up; the lists of people and groups keep to\n  this site's own. \"Posted on\" (people, groups, events; people meaning\n  people here and elsewhere) is not offered when profiles named by hand\n  are the feed's only source, since it could only hide some of their\n  posts, and is not sent then. Under the fields is\n  what the feed shows, in a sentence. There is no preview: saving opens the\n  feed, Home on its tab when it is kept there. Everything done to a feed is\n  done from the list page. Home has only the way to it.\n- **A feed's icon** is one of sixteen, chosen in the editor from a list that\n  names each, on the same line as the feed's name, and kept by name\n  (`icon` on the feed; the server's `feeds.Icons`). `components/CustomFeedIcon`\n  draws it: in the menu under Home, on the tabs, in the list and on the\n  feed's page.\n- **Deleting a feed** is offered to its owner in two places, each asking\n  first: its card in the list and its own page. Not in the editor, which is\n  for changing a feed.\n- **Ten feeds** is the most a person owns, the number the home page holds.\n- **The sentence** is `utils/customFeeds.js#summary`, from the definition\n  alone. It is on the editor, on a feed's page and in the list, so nothing\n  about what a feed shows is hidden. The same file repeats the server's\n  limits so the editor can say what would be refused before it is sent. The\n  server decides.\n- **A feed's page** (`containers/feeds/Read.jsx`): add to home, copy, and for\n  its owner edit and delete. Copying makes a feed of the viewer's own, seen\n  by them alone, and opens it in the editor.\n- **Share** is on a shared feed's card (in search and in Recommendations ›\n  Feeds), in the list of feeds and on the feed's page.\n  See [Sharing](#sharing). Not on a feed kept to its owner.\n- **Following a hashtag or a place** (`containers/feeds/FollowHashtag.jsx`\n  and `FollowLocation.jsx`, on the hashtag's and the place's page; both are\n  `FollowFeed.jsx`) makes a feed of that one hashtag or place, the viewer's\n  alone, and puts it on the home page, or puts there the one the viewer\n  already has. The feed is given the hashtag's or the place's own picture.\n  It goes into no other feed. A feed of a place shows only\n  what the viewer could see anyway, and who follows a place is kept\n  nowhere.\n- **Naming in requests.** The definition's keys are snake case on the server\n  (`hashtags_any`) and camel case here (`hashtagsAny`), converted both ways\n  by the interceptors like every other body."},{"id":"pinned-posts","heading":"Pinned posts","level":3,"markdown":"\"Pin to profile\" and \"Unpin\" are in a post's menu, on its page and in a feed\n(`containers/controls/medium/Pin.jsx`), where the server says the viewer may\n(`authorized.pin`). A pinned post has a \"Pinned\" line at the top of its card\n(`components/PinnedLabel.jsx`). A profile has one pin, so pinning moves it:\nthe change is told to every list on the page (`POST_PIN_CHANGED`, handled in\n`reducers/create.js` by `utils/reducer.js`), and the post that had the pin\nstops saying so. The new order shows when the list is next read."},{"id":"saved-posts","heading":"Saved posts","level":3,"markdown":"\"Save\" in a post's menu puts it on the viewer's own list; the same item reads\n\"Remove from saved\" once it is (`containers/controls/medium/Save.jsx`,\n`isSavedByViewer` on the post). The list is a page of its own, **Saved** in\nthe left menu under Home, at `/saved` (`containers/saved/index.jsx`, the list\nin `Browse.jsx`), in the same masonry as the other lists. It was a tab on the\nviewer's own profile for a day and was moved on 2026-10-06: a profile is what\nother people look at. There are no folders. Collections, planned for later, would be a separate feature filled from\nthis list, and would leave it as it is.\n\nIt is private end to end: the server never says who saved a post. The page\nsays so in one line under its title. The mark is told to every list on the page (`POST_SAVED_CHANGED`,\nhandled in `reducers/create.js`)."},{"id":"a-posts-interactions","heading":"A post's interactions","level":3,"markdown":"\"Interactions\" is a dialog about a post: who liked it, reposted it, quoted\nit and replied to it (`containers/activity/PostActivity.jsx`,\n`utils/activity.js`). It has a tab for each of the four, with its icon and\nits number; under the tabs is the list of who, each row a link. In the code it is still\ncalled activity. Each list is read when its tab is first opened.\n\nIt opens from **the last button in the row under a post**\n(`containers/likes`, drawn by `components/PostActionsRow.jsx`, which every\nlayout of a post uses), there to press when the post has any of the four,\nand from \"Interactions\" in a post's menu. There is one row under a post:\nlike, reply, repost, share and interactions, each the same kind of button\nand as wide as the others, so the row is shared out evenly. None is an\nicon button. The separate row of counts, of likes and of replies, is gone.\n\nThere are no views in it, and nothing anywhere counts them."},{"id":"the-top-of-a-profile","heading":"The top of a profile","level":3,"markdown":"`actors/Read/ActorHeader.jsx` lays it out the same way on a phone and a\ndesktop, left aligned:\n\n- **The cover,** when there is one, as a wide band three times as wide as\n  it is tall and no taller than 320px (`components/CoverForm.jsx`). With\n  none it is not drawn at all, apart from a slim \"Add a cover photo\" band\n  for whoever may add one. Cards of profiles use the same band.\n- **The picture** over the cover's lower edge, or at the top of the card\n  with no cover, smaller on a phone; **beside it, the buttons**: Follow\n  (filled while not following, outlined once following), the others the\n  viewer may use, and the ⋮ menu. They were a row of their own under\n  everything, which left a band of white.\n- **The name, with the pronouns after it** in a lighter grey, \"Sarah Noble\n  (they/them)\".\n- **The handle,** and \"· Follows you\" after it for somebody who follows\n  the viewer.\n- **One line of counts,** \"22 followers · 16 following\"\n  (`actors/Read/SocialgraphMeta.jsx`), each opening the dialog below on\n  that list. Following is counted only for this site's people: whom\n  somebody on another server follows is not known here.\n\nWhat the profile says of itself is in its About card, not here."},{"id":"followers-following-and-what-is-in-common","heading":"Followers, following and what is in common","level":3,"markdown":"Under a profile's name are its counts. Pressing one opens a\ndialog (`actors/Read/SocialgraphDialog.jsx`, `utils/socialgraph.js`) with up\nto three lists:\n\n- **Followers:** who follows the profile.\n- **Following:** who the profile follows. Only people follow, so only a\n  person's profile has it. The code and the API call these `leaders`.\n- **In common:** the people the viewer follows who also follow this\n  profile. Only for somebody signed in, on a profile that is not their own.\n Each is\nread when first opened and more of it as its end is scrolled to, whoever\nfollowed most lately first. The order is a small menu at the right of the\ndialog's title, Latest or Oldest (`?dir=asc`); it was a row of its own under\nthe tabs. Each tab's count is in grey after its name, as on the search\npage. A group's administrators can remove a follower\nthere.\n\nWhat is in common is found by one query in the graph, so it is whole, paged\nand counted; its number is on its tab once the tab has been opened.\n\nA person who follows the viewer says so: \"Follows you\" after their handle on\ntheir profile, and beside their name in these lists.\n\n**One word for following.** Every button that follows anything, a profile,\na hashtag or a place, reads \"Follow\" with a plus before it\n(`controls/Follow.jsx`, `feeds/FollowFeed.jsx`), except for somebody who\nfollows the viewer already, where it reads \"Follow back\" (asked for by the\nuser on 2026-10-14). Two things keep words of their own because they are not that\nbutton: \"Request to follow\", which asks and does not follow, and \"Follow\n<name>\" in a post's menu, where what is followed is not what the menu\nbelongs to.\n\nThere is no Social Graph tab. An old address such as\n`/people/ana/socialgraph/leaders` opens the profile with that list showing.\n\n**Blocked** is not in the dialog: who you have blocked is yours alone. It is\none row under Settings › Privacy (`actors/Settings/Blocked.jsx`) saying how\nmany there are, which opens a dialog: everybody blocked, with their picture,\nread a page at a time as the list is scrolled, a box to search it by name,\nand Unblock on each.\n\nA group or an event has the same row under its own Privacy tab, below Access,\nlisting who it has blocked; Unblock there lifts the group's block, not the\nviewer's. A group blocks somebody from the menu on what they wrote in it:\n\"Block from group\" (\"Block from event\" in an event), on a post (`controls/BlockFromOwner.jsx`, in the feed's\nmenu and the post page's) and on a reply (`replies/Thread.jsx`, which reads\nthe thread again afterwards). It is offered where the server says\n`authorized.blockFromOwner`: to the group's administrators, on what somebody\nelse wrote, and on the post's own page to site administrators too. Pin is\noffered the same way. A person's own block does not reach the groups they\nrun."},{"id":"events","heading":"Events","level":3,"markdown":"An event is an actor like a group, so most of it is the containers a group\nuses, told the `events` namespace: its page (`actors/Read`), its settings, its\nadministrators, its access. What only an event has is in `containers/events`.\n\n| Where | What |\n| --- | --- |\n| **Events** in the left menu, `/events` | The viewer's own: Upcoming, Invited, Hosting, Past (`events/index.jsx`). There is no list of every event. Its + follows the installation's `eventsFrom` (`permissions/actor.js`), a setting apart from the one for groups |\n| `/events/add`, `/events/add?host=<group id>` | The form (`events/Form.jsx`). With a host it is the group's event |\n| `/events/:id` | The actor page with `events/Panel.jsx` above the tabs: when it is, who hosts it, how many are going, Going and Maybe, Add to calendar. Its administrators change it from its settings |\n| Info under its settings | The same form, to change it. The old address `/events/:id/edit` sends there; calling an event off is in the Danger zone |\n| A group's **Events** tab | What the group hosts, upcoming or past, with Add event for its administrators (`events/Hosted.jsx`) |\n\n**Times** are kept as instants with the name of the zone they were chosen in.\nThe form works on that zone's clocks, whatever zone the browser is in; a\nreader sees them on their own, with a second line for the event's zone when\nit differs. `utils/events.js` does both with `Intl`, clock changes included,\nand has the tests for it.\n\n**Capacity** is set on a slider on the form, not typed: its stops are close\ntogether where the numbers are small and further apart as they grow, and all\nthe way up is no limit (`events.capacityStops`). An event whose number is\nnot one of the stops has it added, so opening it to change something else\ndoes not change how many may go.\n\n**Changing and cancelling** are in the event's settings, reached from its\nmenu like a group's. The Info tab is the event's own form\n(`events/Form.jsx`), so the event's page has no Edit button, and\n`/events/:id/edit` leads to the settings. Cancelling is the first card of the\nDanger zone (`events/CancelSetting.jsx`): it cannot be undone, and it is the\nthing to weigh against archiving and deleting.\n\n**Answering** is Going or Maybe, and is following the event, so an event's\npage has no Follow button. Not going, Maybe and Going are one group of\nbuttons the width of the card, the chosen one filled; Not going takes an\nanswer back. The number going opens who they are\n(`events/Attendees.jsx`). The link to join online is sent by the server only\nto whoever is going or looks after the event.\n\n**Where it is** is an address typed on the event's form and kept on the\nevent, not a place on the site's map: places are public, and an event at\nsomebody's home should not put their home on it. The server sends the address\nonly to whoever is going or looks after the event, and the About card on the\nevent's page shows it (`actors/Read/About.jsx`), which is drawn for an event\nwith an address even when it has no description. Everybody else is told there\nis an address for people who are going.\n\n**A map** is drawn in that card when the address came with a point. The form\nhas a \"Show a map\" checkbox, off unless ticked, because finding an address\nmeans the server sending it to a geocoder. The marker opens nothing\n(`linked={false}` on `components/Map.jsx`). Pressing the \"Where\" row opens a\nmenu of Apple Maps, Google Maps and OpenStreetMap (`events.mapLinks`). Each\nopens at the point when there is one, and otherwise looks the address up, from\nthe reader's own browser and only when picked. The event's website, a field\non its form, is the row above \"Where\", so the map sits right under the\naddress. A public venue can still be tagged from the\nevent's Locations tab, and the panel names it.\n\n**An event for a group's followers.** An event hosted by a group has a\ncheckbox on its form: everyone who follows the group can see it and answer.\nA follower who has not answered is shown its name, time and description with\nMaybe and Going (`events/Answer.jsx`), on its page and in the group's Events\ntab. Once they answer they follow the event, and its page opens to them.\n\n**No avatar.** An event's picture is its cover. Wherever an avatar would be\ndrawn, `components/ActorAvatar.jsx` draws `EventDateTile` for an event: the\nmonth over the day it starts on, on the reader's calendar, or a calendar icon\nwhere the event is named without its times. Its page has no avatar to upload.\n\n**Add to calendar** asks the server for the event as an `.ics` file and hands\nit to the browser to save."},{"id":"inviting-people-to-a-group","heading":"Inviting people to a group","level":3,"markdown":"A group's page has **Invite** for whoever the group lets invite\n(`authorized.invite`). It opens `containers/actors/Invite`: the viewer's own\nfollowers, found by name and ticked, up to 50 at a time. The server answers\nfor each person, and its answer is shown beside the name (`utils/invites.js`).\nWhoever looks after the group has a second tab, **Invited**, with who is still\nto answer and a way to take an invitation back.\n\nThe person invited answers with `containers/controls/InviteAnswer.jsx`,\nDecline and Accept, in three places: on the notification, on the group's page,\nand on the limited card when the group is one they could not otherwise see.\n\nWho can invite is set under Settings › Permissions. The old \"Who can add a\nfollower?\" row is gone: nobody is added without saying yes. The \"+ Followers\"\nbutton and its picker went with it."},{"id":"private-profiles-and-asking-to-follow","heading":"Private profiles and asking to follow","level":3,"markdown":"A profile the viewer may not see is refused by the server, unless it lets\npeople ask to follow it. Then the server sends its name and picture, marked\n`restricted`, and the web app draws `components/LimitedActorCard.jsx`: as the\ncard in the lists of people and of groups (`actors/Browse/Card.jsx`), and as\nthe whole page when the profile is opened (`actors/Read/index.jsx`).\n\nThe button on it is `containers/controls/FollowRequest.jsx`: \"Request to\nfollow\", then \"Requested\", which takes the request back when pressed. Whoever\nlooks after the profile answers from the follow requests on their own\nprofile, and is told by a notification."},{"id":"the-home-page-of-somebody-not-signed-in","heading":"The home page of somebody not signed in","level":3,"markdown":"`assets/default/home`, the theme's, drawn at `/` in place of the feeds. It is\nabout the installation and not about Anahita: its name and description as\nits operator set them (NodeInfo's `nodeName` and `nodeDescription`, the\nbuild's until that has answered), how many people and posts are on it,\nwhether it is part of the fediverse and Bluesky, and the two ways in, Sign in\nand, where NodeInfo says profiles can be made, Join. It is one column, no\nwider than 960 pixels, on the page's own background: no box, cards or\nshadows. Under a hairline, what can be done here, three or four short items\nside by side (a fourth on an installation that federates); then tiles,\ntwo by two, for what may be looked at without a profile: people, groups,\nhashtags and About. That it runs on Anahita is one line at the foot. A members-only installation never draws it\n(`routes/MembersOnlyGate.jsx`)."},{"id":"the-fediverse","heading":"The fediverse","level":3,"markdown":"People on other servers (Mastodon and the like), and what they post. The\nserver's side is `activitypub-service`, described in the services'\n`docs/federation.md`. It is optional: where there is none, everything here\ndraws nothing (`utils/federation.js#isAbsent`).\n\n- **The composer says when a note will stay here.** Somebody on the\n  fediverse, writing a note on their own profile for fewer than everybody,\n  is told above the Post button that it will not be shared with the\n  fediverse (`media/Composer/useOnFediverse.js`, asked once and kept up to\n  date by the switch in settings). Nothing is said to anybody else, or\n  about any other kind of post: only public notes go out.\n- **A thread written in one go** (roadmap row 101): under a note's words,\n  \"Add to thread\" opens a box for the next note, up to ten in all\n  (`Composer/ThreadParts.jsx`, `THREAD_PARTS_MAX`). Post sends them together\n  (`api.replies.addThread`, `POST /notes/:owner_id/thread`): the first takes\n  the post's audience, who may reply and quote, its locations and its card;\n  the rest are replies to the one before. Only for words alone: adding\n  photos or a video hides it. The notes after the first are not kept as a\n  draft. A new reply can be a thread too (`replies/ReplyForm.jsx`, sent as\n  `{ thread }` to `Thread.jsx`, `POST /notes/:id/replies/thread`); its notes\n  are shown as any replies are, each under the one before.\n- **What other networks will show** of a public post on the viewer's own\n  profile is said in a line under it (roadmap row 100, `Composer/Short.jsx`):\n  past 300 characters, when their Bluesky account is connected, that\n  Bluesky shows the first 300 with a link to the rest; with more than four\n  photos, that Mastodon, Bluesky or both show the first four with a link to\n  all of them. The Bluesky line's room is kept while the post goes there, so\n  nothing moves as the words pass 300. The numbers are `BLUESKY_POST_MAX` and\n  `PICTURES_SHOWN_MAX` in `constants/limits.js`; the count is of what is\n  typed, Markdown's marks included, a little more than Bluesky is sent.\n- **Answering something from another server** is offered where the server\n  says the viewer may (`authorized.comment`), as for any post: the reply is\n  the viewer's own note and is sent to whoever it answers when the viewer\n  is on the fediverse. When they are not, the reply form says that it will\n  be read here and not there (`useReplyStaysHere`, in\n  `media/Composer/useOnFediverse.js`). Liking and reposting something from\n  elsewhere work the same way, where the server allows them\n  (`authorized.like`), and reach its author's server.\n- **A hashtag at `/tags/<name>`** leads to `/hashtags/<name>`\n  (`routes/index.js#HashtagMoved`). It is the address other servers are\n  given for a hashtag's page, where Mastodon has its own, because some of\n  the apps people read with know a hashtag's link by that shape.\n- **A profile from another server has three lists,** what was posted,\n  Notes and Photos (`utils/node.js#getActorFeatureTabs`, `REMOTE_TABS`): a\n  post of theirs that came with pictures is a photo post here. Articles\n  and videos are not taken in from other servers, so those lists would be\n  empty for ever and say the person has none; each comes back when it is\n  taken in. Their replies and reposts are in what was posted, not in lists\n  of their own. Its box of locations is the site's administrators' to fill,\n  as it is for any profile here, to say where an organisation on another\n  server is (`locations/Gadget`, `hideWhenEmpty`); nobody else sees it\n  while it is empty.\n- **The servers the site refuses** are in Site settings, under Fediverse\n  (`settings/Fediverse.jsx`), for super administrators as every tab there\n  is: the list with why, and a button to stop refusing one. The list is\n  read fifty at a time as it is scrolled and searched on the server, by\n  name or reason, since an installation that takes up somebody else's list\n  has thousands; a server is refused from a dialog, which says that\n  refusing it does not remove what already came from it.\n\n- **The switch** is a card under Settings › Fediverse, beside the Bluesky\n  card, on the viewer's own profile only (`actors/Settings/Fediverse.jsx`): \"Let people on other servers find and\n  follow me\". Off until the person turns it on, and theirs alone to turn\n  on. It shows their handle, says that what is sent cannot be called back,\n  asks before it is turned off (other servers are told to remove the\n  person, and may not take them back), says that making a profile private\n  has the same effect,\n  and when they cannot turn it on (their profile is not open to everybody,\n  or the site does not federate) it is disabled with the server's reason\n  beside it.\n- **Closing a profile is the one way to leave by accident,** so the card\n  where a profile's audience is chosen (`actors/Settings/Access.jsx`) asks\n  whether the person is on the fediverse, warns beside the choice as soon\n  as anything but \"everybody\" is picked, and asks once more before saving.\n  It is the only choice on that card that is confirmed, because it is the\n  only one that cannot be undone. An administrator changing somebody\n  else's profile is warned the same way.\n- **Somebody from another server is a person like any other** to the app:\n  their type ends in `person`, so they are opened at `/people/<handle>`,\n  where the handle is whole, `alice@mastodon.example`, and the same page\n  draws them. What differs is told by `utils/federation.js`:\n  - their handle is drawn whole (`displayHandle`), where a username here\n    is made safe first;\n  - their About card says which network they are on, in a row with the\n    network's mark (the butterfly for Bluesky, the hub for the fediverse),\n    what of them is kept here, and a way to their own page there\n    (`actors/Read/About.jsx`). Nothing is added to the header: it is a\n    fact about the profile, not something to act on;\n  - the server sends nothing they can be edited, run or posted on by, so\n    none of those controls is drawn. Follow is, and works as for anybody.\n- **A post from another server** is an ordinary note with a `uri`. Its menu\n  has \"View on <server>\" (`media/MediaMenu.jsx`). It cannot be edited\n  here, whatever the viewer's role (`permissions/node.js#canEdit`): it is\n  its author's to change, on their own server. It can be liked, replied to\n  and reposted wherever the server allows (`authorized`).\n- **Looking somebody up** is on the search page\n  (`search/RemoteLookup.jsx`): when what was searched for is a handle with\n  a server in it or the address of a profile, a card offers to ask that\n  server. It asks when the button is pressed, since asking tells another\n  server that this site did; then the person found is drawn first among the\n  search page's results, with their card and its Follow button. It asks at once,\n  without the button, when somebody signed in typed an unmistakable\n  handle into the search box and pressed Enter (`isClearHandle`:\n  `@alice@mastodon.example`, a Bluesky DID, a Bluesky handle written\n  with `@` or on `bsky.social`, or the address of a profile,\n  `https://pixelfed.social/dansup`, which is what Pixelfed and others show\n  people). The box says so in the router's state; a\n  link to the same search does not, so a link cannot make this site call\n  another server just by being opened. See [Search](#search).\n- **A lookup that fails** says what happened and what to try, naming the\n  server (`federation:lookup.errors`): nobody by that name, a group or a\n  post rather than a profile, a server this site does not connect to, or\n  somebody on this site are answers, said with no button; a server that did\n  not answer, or too many lookups, may pass, and offer **Try again**\n  (`utils/federation.js#isRetryable`). A search of this site that fails\n  says so in a sentence with Try again, never the request's own message;\n  and a follow or an unfollow that fails gives the button back and says so\n  (`controls/Follow.jsx`).\n- **Bluesky is read through a service of its own,** which may be there\n  when the fediverse's is not, or the other way round. To the app,\n  somebody on Bluesky is somebody from another server like any other.\n  What differs is in `utils/federation.js`:\n  - **which service a lookup asks** is told by how the handle is written\n    (`lookupKind`): `@alice@mastodon.example` has its server after a\n    second `@`; `alice.bsky.social`, a `did:plc:…` or an address under\n    `bsky.app/profile/` is Bluesky. `api/federation.js#lookup` sends it to\n    `/activitypub/lookup` or `/atproto/lookup`, and the card goes for the\n    one network whose service is not there, not for both;\n  - **the page of a post or a person** (`pageOf`): on the fediverse the\n    address a thing has between servers is its page; on Bluesky it is an\n    `at://` address, and the page is worked out from it. \"View on …\"\n    leads there, in the menus and on the profile;\n  - **a reply to a post from Bluesky stays here unless its writer\n    connected their Bluesky account:** `useReplyStaysHere` answers with\n    the words to say so under the reply being written, and with nothing\n    for somebody connected (`useOnBluesky`, asked once and kept up to\n    date by the settings card).\n- **A person's own Bluesky account** is connected in their settings,\n  under Fediverse (`actors/Settings/Bluesky.jsx`): a handle and an app\n  password, with a link to where Bluesky makes one, then \"Connected as\"\n  and a button to disconnect, which asks first. When Bluesky no longer\n  takes the connection the card says so and offers the form again.\n  Once connected, a second button follows here the accounts they follow\n  on Bluesky already. It asks first: a dialog says how many they follow\n  there, offers a number (the 25, 100 or 500 followed most recently, or\n  all of them when there are fewer), and says what it does and that it is\n  not undone in one step. The server carries on with it after answering.\n  Nothing is drawn where the installation does not write to Bluesky."},{"id":"search","heading":"Search","level":3,"markdown":"- **The box in the top bar** (`components/SearchBox.jsx`) is an MUI\n  `Autocomplete` in free-solo mode. Searching opens the search page\n  through the router, without reloading the app, and marks the search as\n  typed (see Looking somebody up, above).\n- **Recent searches** (`utils/recentSearches.js`): focused while empty,\n  the box lists the last ten searches, newest first, each with the tab it\n  was run under; typing narrows them; each can be forgotten, or all at\n  once. The search page lists them too when nothing has been typed\n  (`search/Browse/RecentSearches.jsx`). They are kept **in this browser\n  only**, under a key with the person's id, and cleared when they sign\n  out. Nothing about what somebody searched for reaches the server, so\n  they do not follow somebody to another device. A search is remembered\n  when it is run, however it was reached.\n- **The controls:** the order as a small menu with, beside it, the two\n  choices below as chips; the row wraps on a narrow screen. The distance\n  is a slider on a line of its own under them (25 to 100 km, or anywhere),\n  written to the address when it is let go.\n- **Two choices for \"Most relevant\"** (`utils/searchChoices.js`), chips\n  shown only with that order and only to somebody signed in: **Profiles I\n  follow first** (people, groups and events they follow, and what is\n  posted on them, before everything else) and **Newer results first** (the\n  newest first among results that match equally well). Off as they\n  come, kept in this browser per person, and sent with a search only when\n  chosen (`followed_first`, `recent_first`).\n- **The page keeps its place in its address** (`utils/searchAddress.js`):\n  `?q=seeds&scope=feeds&sort=recent&km=50`. Reloading, going back and a\n  link sent to somebody open it as it was. What is as it comes is left\n  out. Changing the tab, the order or the distance replaces the address\n  rather than adding to the history.\n- **The tabs are All, Posts, Media, Profiles and Feeds** (`scope` `all`,\n  `posts`, `media`, `actors`, `feeds`). Posts is every kind of post; Media\n  is photos and videos alone, as other networks mean it (the user,\n  2026-10-20).\n- **Each tab says how many it finds,** in grey after its name and short\n  however large (`utils/shortCount.js`: 515, 1.2K, 48K, 3.1M, as the\n  reader's language writes them). One request for each tab, asking for a\n  single result and reading the total (`search/Browse/useScopeCounts.js`),\n  again when the words or the distance change but not the order. **A tab\n  that finds nothing is not drawn,** except All and the open one.\n- **A search that finds nothing** says so (`search/Browse/NothingFound.jsx`),\n  and offers the other tabs that would find something, from the same\n  counts. A handle from another server is left to the lookup card instead.\n- **A distance needs a place.** The server leaves the distance out when\n  it is not told where the searcher is, so when the browser cannot say or\n  was not allowed to, the page says that the search is not limited by\n  distance, rather than search everywhere and look as though it had not.\n  While the browser is still asking, it waits."},{"id":"cards-in-lists","heading":"Cards in lists","level":3,"markdown":"The cards that show posts in lists, `components/MediumCard.jsx` (search,\nhashtags, places, saved, a profile's tabs) and the feed's\n(`feed/components/Default.jsx`), are drawn alike:\n\n- **A title** (an article's, a topic's, a photo's) through\n  `components/PostTitle.jsx`: one size, a little larger and bolder than the\n  text, in the text's colour, a link with no underline.\n- **The title and the text open the post** when clicked\n  (`utils/openOnClick.js`), unless text is being selected, so it can still\n  be copied, or the click was on a link or a button in it: a hashtag, a\n  mention, an address. Ctrl-, ⌘- or a middle click opens it in a new tab.\n  The title stays a real link for the keyboard and screen readers.\n- **Where it was posted,** a group or somebody else's profile, as a quiet\n  line above the author (`components/MediumOwnerCardHeader.jsx`), not a\n  second header as heavy as the author's.\n- **What a reply answers** is quoted in plain words (`utils/plainText.js`),\n  so a link in it reads as its words, not as Markdown written out.\n- **Raised the same way** as every other card and the gadgets beside them:\n  MUI's default variant and elevation, set by nobody. Only a post quoted\n  inside a repost is outlined, to stand apart from the card it is in. The\n  composer above a feed is raised too, and the form's own card in it lies\n  flat on it."},{"id":"long-posts","heading":"Long posts","level":3,"markdown":"A long post on a card shows its first lines and **Read more**\n(`components/ReadMore.jsx`): six lines on a post, four on a profile card.\nThe whole text is always drawn and only its height is kept short, with the\nlast line fading out, so nothing is cut through: a link or bold text near\nthe cut stays whole, and a screen reader has all of it. Read more is shown\nonly when the text is taller than that, measured as it is drawn and again\nwhen a picture in it loads. On a card, a text of more than 1000\nletters as read leads to the post's or the profile's own page, where the\nwhole text is: opened in place, a long text made its card many times taller\nthan the ones beside it and the columns jumped. A shorter one opens in\nplace."},{"id":"menus","heading":"Menus","level":3,"markdown":"Every item in the menus on posts, feed items, replies and profiles has an\nicon before its words, through `components/MenuItemLabel.jsx`."},{"id":"quote-posts","heading":"Quote posts","level":3,"markdown":"A quote is a note that carries another post under its own words. It is an\nordinary note in every other way: its own audience, likes and replies.\n\n- **Making one.** The repost button under a post opens a small menu, upward:\n  Repost, or Quote (`containers/controls/Repost.jsx`). Quote is switched off\n  where the server said this viewer may not quote this post\n  (`authorized.quote`). It opens `containers/controls/QuoteDialog.jsx`, which\n  posts a note on the writer's own profile with `quote_id`. A refusal at that\n  point is said in the server's words (`utils/quotes.js`).\n- **Showing one.** A note that quotes is sent `quote`: the post, or why it is\n  not shown (`unavailable`, `detached`). `components/QuoteEmbed.jsx` draws\n  either, inside the note, in feeds, lists and on the note's page. The server\n  sends the post only to a reader who may see it.\n- **Taking a post out.** On the page of a note that quotes their post, the\n  quoted author has \"Remove my post from this quote\"\n  (`containers/controls/QuoteDetach.jsx`). It cannot be undone.\n- **Who can quote** is the third part of a post's interaction settings\n  (`components/ReplyAccessDialog.jsx`): anyone, the author's followers, or\n  nobody. A post that has never been asked follows its author's own setting,\n  \"Who can quote my posts\", under Settings › Permissions\n  (`containers/actors/Settings/QuotePolicy.jsx`).\n\nThere is no comment code left: the comment components, actions, reducers,\nAPI modules and prop types went with the comment service. Two names remain\nfrom then, because the server still uses them: `authorized.comment` and\n`commentCount`.\n\nThe rules are the server's, in the services' `docs/permissions.md`, Replies."},{"id":"translations","heading":"Translations","level":2,"markdown":"Text is translated with i18next (`src/languages/`). Each language is a\ndirectory named for its locale, such as `en-CA` or `fr-FR`. Each file in it is\na namespace, such as `people.js` or `settings.js`, used as\n`i18n.t('people:settings.info')`.\n\n`en-CA`, `fr-FR`, `de-DE`, `es-419` and `pt-BR` are bases, with every string; `en-US` and `fr-CA` are\nlayers, with only the strings that differ, and `fallbackLng` reads the rest\nfrom the base. The language is chosen from the browser's when the app starts\n(`src/languages/choose.js`), and `REACT_APP_LANGUAGE` is the default when the\nbrowser asks for none of them. When it changes, `src/languages/index.js` sets\nmoment's locale and the page's `lang`. Code that formats a date or a number\nwith `Intl` or `toLocale…` passes `i18n.language`. A post processor makes the\nspace before `?`, `!` and `;` in French (`src/languages/frenchSpacing.js`).\nSee [Languages](https://www.anahita.io/docs/web/customising/#languages)."},{"id":"styling","heading":"Styling","level":2,"markdown":"The UI is Material UI 9 (`@mui/material`), styled with Emotion. The theme is\nmade in `src/styles/index.js` with `createTheme`, from the options the active\ntheme's `styles.global()` returns (see [Themes](https://www.anahita.io/docs/web/customising/#themes)), and\nprovided in `src/containers/Root.jsx`. Under the theme's options,\n`src/styles/index.js` puts back a few Material-UI 4 defaults, such as the\nbreakpoint widths and the background colours, so that the app kept its look\nthrough the upgrade; a theme can override any of them.\n\nComponents style themselves in one of two ways:\n\n- `makeStyles` or `withStyles` from `tss-react/mui`, which return class names\n  built from the theme. Most existing components use these.\n- The `sx` prop, for a few rules on one element.\n\nStyles are plain objects. `theme.spacing(n)` returns a string with its unit\n(`'16px'`), so write `theme.spacing(-2)`, not `-theme.spacing(2)`, and don't\nadd `px` after it.\n\nMaterial UI 9 supports Chrome 117, Edge 121, Firefox 121 and Safari 17 and\nlater; `browserslist` in `package.json` matches."}]},{"source":"web","slug":"customising","title":"Customising","group":null,"url":"https://www.anahita.io/docs/web/customising/","sections":[{"id":"","heading":null,"level":1,"markdown":"Making an installation your own: its name, look, languages and legal\ndocuments."},{"id":"name-and-description","heading":"Name and description","level":2,"markdown":"`REACT_APP_NAME` and `REACT_APP_DESCRIPTION` set the installation's name and\none-line description. They are used throughout the app and in the page title\nand meta tags in `public/index.html`. See [Configuration](https://www.anahita.io/docs/web/configuration/)."},{"id":"themes","heading":"Themes","level":2,"markdown":"A theme is a directory under `src/assets/`. The app uses the one named by\n`REACT_APP_ASSETS`, or `src/assets/default` when it is unset.\n\nTo make your own, copy the default and point the app at the copy:\n\n```sh\ncp -r src/assets/default src/assets/mysite\n```\n\n```sh\nREACT_APP_ASSETS=mysite\n```\n\nA theme exports, from its `index.js`:\n\n| Part | Directory | What it controls |\n| --- | --- | --- |\n| `logo` | `media/logo` | The logo in the menu: `color` for light mode and `white` for dark, which may be the same file. The default theme's is Anahita's mark, an SVG for both |\n| `opengraph` | `media/og` | An image for link previews. Exported, but nothing in the app uses it yet |\n| `Home` | `home/` | The home page signed-out visitors see |\n| `navs` | `navs/` | The left menu |\n| `styles` | `styles/` | The Material UI theme: fonts and colours, for light and dark mode |\n| `pages` | `pages/` | Markdown pages for the site, such as `contact.md` and `join.md` |\n\nA theme is compiled into the bundle, so changing one means rebuilding. Keep\nthe parts' names and exports as they are in the default theme; the app imports\nthem by those names.\n\n`styles/index.js` exports `global({ colors, prefersDarkMode })`, which returns\n[Material UI theme options](https://mui.com/material-ui/customization/theming/)\nfor `createTheme`. `prefersDarkMode` follows the visitor's system setting, so\nset `palette.mode` from it (`'dark'` or `'light'`) and choose colours for\neach. Options you return are applied over the app's defaults; to change how a\ncomponent looks everywhere, add it under `components`, for example\n`components: { MuiButton: { defaultProps: { disableElevation: true } } }`."},{"id":"languages","heading":"Languages","level":2,"markdown":"The app is in seven languages. Five are bases, with every string; two are\nlayers, with only the strings that differ from their base:\n\n| Language | Directory | Holds | Falls back to |\n| --- | --- | --- | --- |\n| English (Canada), `en-CA` | `src/languages/en-CA/` | Every string | — |\n| English (United States), `en-US` | `src/languages/en-US/` | The strings that differ: *canceled*, *license*, *gray* | `en-CA` |\n| French (France), `fr-FR` | `src/languages/fr-FR/` | Every string | `en-CA` |\n| German (Germany), `de-DE` | `src/languages/de-DE/` | Every string | `en-CA` |\n| Spanish (Latin America), `es-419` | `src/languages/es-419/` | Every string | `en-CA` |\n| Portuguese (Brazil), `pt-BR` | `src/languages/pt-BR/` | Every string | `en-CA` |\n| French (Quebec), `fr-CA` | `src/languages/fr-CA/` | The strings that differ: *courriel* for *email*, *mot-clic* for *hashtag* | `fr-FR`, then `en-CA` |\n\nEach file in a directory is a namespace of strings, and `index.js` gathers\nthem. A string a language does not have is read from the one it falls back\nto.\n\n**Who translated them.** The translations, from the Canadian English the app\nis written in, are made by Claude, an AI model made by Anthropic, at the\nproject's request. Nobody who speaks French, German, Spanish or Portuguese,\nor any language to come, as their first language has read them yet. They\nspeak to the reader as Mastodon does in each language: *du* in German, *tú*\nin Spanish, *você* in Portuguese, and *vous* in French. If something reads wrong to you,\nplease say so: a correction to a string in `src/languages/` is welcome.\n\n**Which language a visitor sees** is chosen from their browser's languages,\nin their order, by `src/languages/choose.js`:\n\n| The browser asks for | Shown |\n| --- | --- |\n| `en-US` | `en-US` |\n| Any other English: `en-CA`, `en-GB`, `en-AU`, `en` | `en-CA` |\n| `fr-CA` | `fr-CA` |\n| Any other French: `fr-FR`, `fr-BE`, `fr-CH`, `fr` | `fr-FR` |\n| Any German: `de-DE`, `de-AT`, `de-CH`, `de` | `de-DE` |\n| Any Spanish: `es-MX`, `es-ES`, `es-AR`, `es` | `es-419` |\n| Any Portuguese: `pt-BR`, `pt-PT`, `pt` | `pt-BR` |\n| None of these | The site's default, `REACT_APP_LANGUAGE`; `en-CA` when unset |\n\nSo somebody whose browser prefers Japanese, then French, sees French. It is\nchosen again on each visit. There is no choice of language in a person's\nsettings: the operating system is the source of truth, and the browser\npasses on its languages. Dates, times and numbers are written in the\nlanguage shown, and the page's `lang` follows it.\n\n**The space before `?`, `!` and `;`** is written once in the French strings,\nas an ordinary space, and made as each string is shown\n(`src/languages/frenchSpacing.js`): a narrow no-break space in `fr-FR`, so\nthe mark never starts a line, and none in `fr-CA`.\n\nTo add a language:\n\n1. Copy `src/languages/en-CA` to a directory named for the new locale, for\n   example `de-DE`, and translate its files. Keep every key.\n2. Import it in `src/languages/index.js` and add it to `resources`.\n3. Add it to `LANGUAGES` and `FALLBACK` in `src/languages/choose.js`, and say\n   in `match` which of the browser's languages it is shown for.\n\nTo add a layer, such as `fr-BE`, make a directory with only the strings that\ndiffer, and give it its base in `FALLBACK`. The tests check that every base\nhas every string the Canadian English has and no other (add a new base to\n`BASES` in `choose.js`), and that a layer has no key its base does not.\n\nWhen you add a string to the app, add it to both bases. Add it to a layer\nonly when it differs there. The Canadian English files are the reference."},{"id":"terms-of-service-and-privacy-policy","heading":"Terms of Service and Privacy Policy","level":2,"markdown":"The documents are Markdown files in `src/statics/legal/`:\n\n| Document | File | Shown at |\n| --- | --- | --- |\n| Terms of Service | `tos.md` | `/legal/tos` |\n| Privacy Policy | `privacy.md` | `/legal/privacy` |\n\n**The ones in this repository are boilerplate. Replace them with your own\nbefore going live.**\n\nEach document has a version in `src/statics/legal/index.js`. **When you change\na document, raise its version.** Every signed-in person whose accepted version\nis older is sent to `/agreements`, and must accept the new one before they can\nuse the app again. This applies to existing members and new ones alike.\n\n- Versions are compared as numbers, part by part: `1.0.0` to `1.0.1` or\n  `1.1.0` asks everybody again. Going back down asks nobody.\n- Use digits and dots only, up to three parts. The server refuses anything\n  else.\n- The two documents are versioned separately. Raise only the one whose text\n  changed, or people are asked to accept a document they have already\n  accepted.\n\nThe site's rules, which everybody ticks one by one on the same page, are not\nthe web app's: they are in the services' `site.json`, with their version, and\nchanged there ([Rules and promises](https://www.anahita.io/docs/services/configuration/#rules-and-promises))."},{"id":"where-the-code-is","heading":"Where the code is","level":2,"markdown":"| What | Where |\n| --- | --- |\n| Choosing the theme (`REACT_APP_ASSETS`) | `src/assets/index.js` |\n| The default theme | `src/assets/default/` |\n| Applying a theme's styles over the app's defaults | `src/styles/index.js` (`createTheme`) |\n| A theme's Markdown pages, at `/pages/:alias` | `src/assets/default/pages/`, `src/containers/page/index.jsx` |\n| The page title and meta tags | `src/components/HeaderMeta.jsx` |\n| The languages, and the one in use | `src/languages/index.js`, `src/languages/en-CA/`, `en-US/`, `fr-FR/`, `fr-CA/` |\n| Choosing the language from the browser's | `src/languages/choose.js`, tested in `src/languages/__tests__/languages.test.js` |\n| The space before `?`, `!` and `;` in French | `src/languages/frenchSpacing.js` |\n| The legal documents and their versions | `src/statics/legal/` |\n| Who must accept a new version | `src/utils/agreements.js` (`needsAcceptance`), tested in `src/utils/__tests__/agreements.test.js` |\n| The page that asks for it, at `/agreements` | `src/containers/agreements/` |\n| The documents as pages, at `/legal/tos` and `/legal/privacy` | `src/containers/legal/index.jsx` |"}]},{"source":"web","slug":"deploying","title":"Deploying","group":null,"url":"https://www.anahita.io/docs/web/deploying/","sections":[{"id":"","heading":null,"level":1,"markdown":"Building the app and hosting it."},{"id":"building","heading":"Building","level":2,"markdown":"```sh\nyarn install\nyarn build\n```\n\nThe result is a static site in `build/`: HTML, JavaScript, CSS and images. No\nserver-side code runs.\n\nSet the [environment variables](https://www.anahita.io/docs/web/configuration/) **before** building. They are\ncompiled into the bundle, so a build is tied to one installation. In\nparticular, `REACT_APP_API_BASE_URL` has to be the public address of your API,\nfor example `https://api.example.com`.\n\n`DISABLE_ESLINT_PLUGIN=true` makes production builds faster by skipping lint,\nwhich has already run during development."},{"id":"what-the-server-needs-to-know","heading":"What the server needs to know","level":2,"markdown":"The app and the API are usually on different domains, for example\n`www.example.com` and `api.example.com`. anahita-services has to be told about\nthe app's address in two places, or signing in fails:\n\n| Where | What |\n| --- | --- |\n| The gateway's `WEB_ORIGIN` setting | The app's origin, such as `https://www.example.com`. The gateway allows that one origin to call the API with the session cookie. See anahita-services' `docs/deploying.md` |\n| The `anahita-web` OAuth client | Register `https://www.example.com/oauth/callback` as a redirect URI. A site administrator can do this in the app under **Settings › OAuth clients**; on a new installation it can go in anahita-services' `auth-defaults/oauth_seed_clients.json` before the first run |\n\nServe both over HTTPS. The session is a cookie set by the API's domain and sent\nwith the app's requests, and browsers only send such a cookie across sites\nover HTTPS."},{"id":"hosting","heading":"Hosting","level":2,"markdown":"There are two ways to put the app on the web."},{"id":"through-the-web-shell","heading":"Through the web shell","level":3,"markdown":"anahita-services has a small server for the app, `web-shell`. It sends the\nbuild's files, and for every page it sends `index.html` with that page's\nown title, description and picture written in. That is what makes a link to\na profile or a post show as itself when it is pasted into Mastodon,\nBluesky, Slack or a message: those sites read the page's tags and run no\nJavaScript, so on a plain static host every link to the app looks the same.\n\nBuild the app, then give the shell the `build/` folder:\n\n```sh\nyarn build\ncd ../anahita-services\nmake web_shell WEB_BUILD=/path/to/anahita-web/build\n```\n\nIt answers on the web app's own host (`www.example.com`), separate from the\nAPI's, and needs no rewrite rule. Set `PUBLIC_WEB_URL` to that address.\nStart it again after each new build. Everything else is in\nanahita-services' `docs/web-shell.md`."},{"id":"on-a-static-host","heading":"On a static host","level":3,"markdown":"Any static host works: a CDN in front of object storage, nginx, Netlify, AWS\nAmplify and so on. The app works the same; every shared link shows the\nsite's own title and picture, whatever page it is to.\n\n**Every path has to serve `index.html`.** The app uses browser history, so a\nlink such as `/people/alice` is a real URL. The host must answer any path that\nis not a file with `index.html`, with status 200, and let the app route it.\nWithout that rule, reloading any page other than `/` returns the host's 404.\n\nWith nginx, for example:\n\n```nginx\nlocation / {\n    try_files $uri /index.html;\n}\n```\n\nFiles in `build/static/` have content hashes in their names and can be cached\nfor a long time. Keep `index.html` uncached, so a new build is picked up\nstraight away."},{"id":"aws-amplify","heading":"AWS Amplify","level":2,"markdown":"Connect Amplify to the repository and it builds on every push, using\n`amplify.yml`: `yarn install`, then `yarn run build`, publishing `build/`.\n\n- Set the [environment variables](https://www.anahita.io/docs/web/configuration/) in Amplify's settings\n  instead of a `.env` file.\n- Add a rewrite so every path serves `index.html`. In **Rewrites and\n  redirects**, send `</^[^.]+$|\\.(?!(css|gif|ico|jpg|js|png|txt|svg|woff|woff2|ttf|map|json|webp)$)([^.]+$)/>`\n  to `/index.html` as a **200 (Rewrite)**.\n- Tell the server about the app's address, as described in\n  [What the server needs to know](#what-the-server-needs-to-know)."}]}]}