# Server-side configs

This file is a snapshot of the server-side layout for context, not the source of truth — if
you're touching server config, SSH to the box and read the live file. Last refreshed
2026-05-06 from a directory walk + the 2026-05-05 handoff. Sections explicitly marked
**(not on hand)** weren't dumped at refresh time and may have drifted.

## Top-level layout: `~/vojo/`

```
~/vojo/
├── docker-compose.yml
├── caddy/                          # Caddyfile + ACME state (./data, ./config)
├── synapse/
│   ├── homeserver.yaml
│   ├── doublepuppet.yaml           # passive AS for double puppeting (no url)
│   ├── telegram-registration.yaml  # bind-mounted from bridges/telegram/registration.yaml
│   ├── discord-registration.yaml   # bind-mounted from bridges/discord/registration.yaml
│   ├── whatsapp-registration.yaml  # bind-mounted from bridges/whatsapp/registration.yaml
│   ├── modules/                    # username_blocklist.py + blocked_usernames.txt + blocked_patterns.txt
│   ├── media_store/
│   ├── vojo.chat.log.config
│   └── vojo.chat.signing.key
├── postgres/                       # data dir (root-owned, 700)
├── coturn/turnserver.conf
├── livekit/                        # livekit.yaml + secrets/ (LiveKit + livekit-jwt-service)
├── sygnal/                         # sygnal.yaml + fcm-service-account.json + vapid_private_key
├── prometheus/                     # prometheus.yml + data/
├── grafana/                        # grafana.db + plugins/ + report dirs
├── widgets/                        # static widget bundles served by Caddy
│   ├── telegram/
│   ├── discord/
│   └── whatsapp/
├── bridges/                        # mautrix bridge configs + per-bridge state
│   ├── telegram/   (config.yaml, registration.yaml, logs/)
│   ├── discord/    (config.yaml, registration.yaml, logs/)
│   └── whatsapp/   (config.yaml, registration.yaml, logs/)
└── cinny/                          # static client bundle (rsync target)
```

## Postgres roles & databases

`synapse` is the historical superuser. Each bridge — and the Vojo AI bot — has its own
non-superuser role + DB with a 32-char
`openssl rand -base64 32 | tr -d '/+=' | head -c 32` password.

| Role | Database | Used by |
|---|---|---|
| `synapse` | `synapse` | Synapse homeserver |
| `mautrix_telegram` | `mautrix_telegram` | Telegram bridge |
| `mautrix_discord` | `mautrix_discord` | Discord bridge |
| `mautrix_whatsapp` | `mautrix_whatsapp` | WhatsApp bridge |
| `vojo_ai` | `vojo_ai` | Vojo AI bot — operational store (txn/event dedup, daily spend ledger, encrypted-warned set; **no** message content). DSN via `AI_BOT_DATABASE_URL`. |

## `~/vojo/synapse/homeserver.yaml` — relevant slices

```yaml
server_name: "vojo.chat"
listeners:
  - port: 8008
    type: http
    tls: false
    x_forwarded: true
    resources:
      - names: [client, federation]
        compress: false
  - port: 9000
    type: metrics
    bind_addresses: ['0.0.0.0']

database:
  name: psycopg2
  args:
    user: synapse
    password: <redacted>
    database: synapse
    host: postgres
    cp_min: 5
    cp_max: 10

push:
  enabled: true
  include_content: true

app_service_config_files:
  - /data/telegram-registration.yaml
  - /data/doublepuppet.yaml
  - /data/discord-registration.yaml
  - /data/whatsapp-registration.yaml

# (also: media_store_path, signing_key_path, turn_*, encryption_enabled_by_default_for_room_type: "off",
#  trusted_key_servers, enable_metrics, federation_ip_range_whitelist, ip_range_whitelist; unchanged from earlier snapshot)
```

## `~/vojo/synapse/doublepuppet.yaml` — passive AS for double puppeting

One non-exclusive AS, no `url:` (so Synapse never pushes events to it), shared by all three
bridges. The `as_token` here is the value each bridge config references with the
`as_token:<token>` prefix in its `double_puppet.secrets` (bridgev2) or
`bridge.login_shared_secret_map` (legacy mautrix-discord).

```yaml
id: doublepuppet
url:
as_token: <hex 64>
hs_token: <hex 64>
sender_localpart: doublepuppet33ef71bf
rate_limited: false
namespaces:
  users:
    - regex: '@.*:vojo\.chat'
      exclusive: false
```

## `~/vojo/docker-compose.yml` — service map

The full file is not committed to this repo. Below are the service names, images, and
non-default details captured at refresh time. Expect minor drift — read the live file when
in doubt.

| Service | Image | Notes |
|---|---|---|
| `postgres` | `postgres:16` | `POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=C --lc-ctype=C"` |
| `synapse` | `matrixdotorg/synapse:latest` | Bind-mounts: `./synapse:/data` plus three module mounts (`username_blocklist.py`, `blocked_usernames.txt`, `blocked_patterns.txt` → `/usr/local/lib/python3.13/site-packages/`), and **five** registration mounts (`bridges/telegram/registration.yaml`, `synapse/doublepuppet.yaml`, `bridges/discord/registration.yaml`, `bridges/whatsapp/registration.yaml`, `ai-bot/registration.yaml`) into `/data/*-registration.yaml`. Exposes 8008. |
| `caddy` | `caddy:2` | Ports 80/443/8448. Bind-mounts `./caddy/Caddyfile`, `./caddy/data`, `./caddy/config`, `./cinny:/var/www/cinny`, **and the three `widgets/<bridge>` directories** (Caddy serves them at the bot widget origins). |
| `coturn` | `coturn/coturn:latest` | `network_mode: host`; `./coturn/turnserver.conf` mounted read-only at `/etc/coturn/turnserver.conf`. |
| `livekit` | LiveKit server image | Backed by `./livekit/livekit.yaml` + `./livekit/secrets`. Element Call backend. **(env / image tag not on hand — read the file)** |
| `livekit-jwt` | `livekit-jwt-service` | Issues JWTs for Element Call widget; env includes `LIVEKIT_FULL_ACCESS_HOMESERVERS=vojo.chat` (see `desired_features.md` §5). **(rest not on hand)** |
| `sygnal` | `matrixdotorg/sygnal:latest` | Mounts `sygnal.yaml`, `fcm-service-account.json`, `vapid_private_key`. `healthcheck: disable: true`. |
| `prometheus` | `prom/prometheus:latest` | `--storage.tsdb.retention.time=30d` |
| `grafana` | `grafana/grafana:latest` | Port 3000 |
| `telegram-bridge` | `dock.mau.dev/mautrix/telegram:<v26.04 bridgev2 tag>` | `./bridges/telegram:/data` |
| `discord-bridge` | `dock.mau.dev/mautrix/discord:v0.7.5` | `./bridges/discord:/data` (legacy bridge — runtime reports `0.7.6+dev`) |
| `whatsapp-bridge` | `dock.mau.dev/mautrix/whatsapp:v0.12.4` | `./bridges/whatsapp:/data` |
| `ai-bot` | `ai-bot:custom` (built locally from [`apps/ai-bot/`](../../apps/ai-bot/), shipped via `docker save \| ssh docker load` — VS Code task **Deploy AI bot**) | **Vojo AI** = `@ai:vojo.chat`, a Grok-voiced **cascade application service** (NOT a normal bot user; architecture: [ai-bot.md](ai-bot.md)). Answers `@`-mentions in groups + everything in 1:1s; the Grok reply (markdown) is rendered to `org.matrix.custom.html` and sent as `formatted_body` (in-bot `markdown.go`, zero deps; emits only tags Cinny's sanitizer keeps, escapes all model text), falling back to the plain `body` when there's no formatting. Mounts `./ai-bot:/data` (owned **uid 65532**, distroless nonroot) holding `registration.yaml` (self-generated, `generate-registration`), `state/` (runtime dir) and `secrets/xai_api_key`. Its **operational store** (txn/event dedup, daily spend ledger, encrypted-warned set) lives in the dedicated `vojo_ai` Postgres DB via `AI_BOT_DATABASE_URL` — `depends_on: [synapse, postgres]`. Push port `:8009` (registration `url: http://ai-bot:8009`). Secrets via env/`*_FILE`; `as_token`/`hs_token` read from `registration.yaml` (no rotation). See [`apps/ai-bot/README.md`](../../apps/ai-bot/README.md). |

### Bridge service stanza (template)

All three mautrix bridges share the same shape:

```yaml
  <bridge>-bridge:
    image: dock.mau.dev/mautrix/<bridge>:<tag>
    container_name: vojo-<bridge>-bridge
    restart: unless-stopped
    depends_on:
      - postgres
      - synapse
    volumes:
      - ./bridges/<bridge>:/data
```

## Caddy — `~/vojo/caddy/Caddyfile`

**(not on hand at refresh time — the snippet below is the pre-bots single-domain config; the live
Caddyfile additionally serves the three widget origins from `~/vojo/widgets/<bridge>/`).**

```caddy
vojo.chat {
    handle /_matrix/* { reverse_proxy synapse:8008 }
    handle /_synapse/* { reverse_proxy synapse:8008 }
    handle /.well-known/matrix/server {
        respond `{"m.server": "vojo.chat:443"}`
        header Content-Type application/json
    }
    handle /.well-known/matrix/client {
        respond `{"m.homeserver": {"base_url": "https://vojo.chat"}, "io.element.e2ee": {"force_disable": true}}`
        header Content-Type application/json
        header Access-Control-Allow-Origin *
    }
    handle {
        root * /var/www/cinny
        @nocache path /config.json /index.html /manifest.json /sw.js
        header @nocache Cache-Control "no-cache, no-store, must-revalidate"
        try_files {path} /index.html
        file_server
    }
}

vojo.chat:8448 { reverse_proxy synapse:8008 }
```

The `.well-known/matrix/client` block also advertises an `org.matrix.msc4143.rtc_foci`
entry pointing at the LiveKit JWT service — see `desired_features.md` §6 for the planned
stable-prefix migration.

## Operational gotchas (preserve when refreshing)

These bit us during the 2026-05 bridge migration; keep them in mind when editing
configs:

1. **`appservice.address` must use the Docker service name**, not `localhost`. From
   inside a bridge container, `localhost` resolves to the bridge itself, not Synapse.
   Use `http://<service-name>:<port>`.
2. **`appservice.hostname: 0.0.0.0`** in `whatsapp/config.yaml` (the default `127.0.0.1`
   binds inside the container only and Synapse can't reach it).
3. **Synapse caches appservice configs in memory** at startup. After editing any
   `*-registration.yaml`, `restart synapse` — restarting only the bridge does not
   invalidate the cache.
4. **Bridge containers chown `/data/config.yaml` to root on first run.** Fix with
   `sudo chown 1337:docker-mautrix && chmod 660` so the host user (in the
   `docker-mautrix` group) can edit again. POSIX ACL via `setfacl -m g:docker-mautrix:rw`
   would survive the chmod-inside-container quirk; not yet applied.
5. **Legacy `mautrix-discord -g`** writes the registration but does **not** mirror the
   generated `as_token`/`hs_token` back into `config.yaml`. Copy them by hand from
   `registration.yaml`. (bridgev2 — telegram/whatsapp — does this correctly.)
6. **Double puppeting secret prefix `as_token:` is mandatory.** Without it, the bridge
   tries to find the deprecated `synapse-shared-secret-auth` Synapse module.
7. **Single-file bind-mount + `depends_on` = phantom directory.** Docker creates the
   *source* of a single-file bind-mount as an **empty directory** if it doesn't exist when
   the container starts. This bit the `ai-bot` bring-up: Synapse `depends_on`-started while
   `ai-bot/registration.yaml` didn't exist yet → Docker made it a dir → Synapse crashed
   `IsADirectoryError`, and `generate-registration` then saw the dir and refused to write.
   Rule: **generate the file BEFORE the mounting container starts.** Generate the bot's
   registration with `docker compose run --rm --no-deps ai-bot generate-registration`
   (`--no-deps` so Synapse doesn't start and recreate the dir). Bridges avoid this because
   each bridge writes its own `registration.yaml` into its `/data` before Synapse mounts it.
8. **Registration files must be world-readable (`644`).** The Synapse container runs
   **non-root**, so a `0600` registration owned by another uid yields `PermissionError`.
   `ai-bot`'s `generate-registration` writes `0644` for this reason (token secrecy relies on
   host access control on the single-tenant VPS, not the file mode). The xAI key stays a
   separate `0600`-ish secret file the bot reads as its own uid.

## Refresh procedure

To regenerate this file with current state, on the server:

```bash
cd ~/vojo
sudo cat docker-compose.yml > /tmp/dump-compose.yml
sudo cat synapse/homeserver.yaml > /tmp/dump-homeserver.yaml
sudo cat synapse/doublepuppet.yaml > /tmp/dump-doublepuppet.yaml
sudo cat caddy/Caddyfile > /tmp/dump-caddyfile
docker compose ps > /tmp/dump-ps.txt
docker compose exec -T postgres psql -U synapse -d postgres -c "\du" > /tmp/dump-roles.txt
tar czf /tmp/vojo-configs-$(date +%Y%m%d).tar.gz -C /tmp dump-*
```

Hand the tarball to the AI session refreshing this doc — it has enough to bring every
section back in sync without `(not on hand)` placeholders.
