Community Forum — Developer Reference
Community Forum — Developer Reference
Section titled “Community Forum — Developer Reference”The Sencai community forum is built as a thin orchestration overlay over upstream Lemmy Docker images (sencai-forum), fronted by a purpose-built Keycloak SSO bridge (forum-connector). For the end-user–facing description, see Community Forum.
Two repositories, one feature
Section titled “Two repositories, one feature”| Repo | Role |
|---|---|
sencai-forum | Lemmy backend + lemmy-ui + PostgreSQL + pict-rs + oauth2-proxy, theme/translation overlay, bootstrap scripts. No forked Lemmy code. |
forum-connector | Fastify sidecar (port 4181) — JIT (just-in-time) Keycloak↔Lemmy account provisioning, KC role→Lemmy admin sync, unread-notification polling, org→community auto-provisioning. |
Key decisions (sencai-forum)
Section titled “Key decisions (sencai-forum)”- Lemmy 0.19.x (Rust), not Discourse/NodeBB — smaller memory footprint (~200 MB vs. 2 GB+), PostgreSQL fits the existing pattern (
cloud-connector-dbalso runs Postgres). - oauth2-proxy sidecar for SSO — Lemmy has no official OIDC plugin; oauth2-proxy fronts the entire UI origin so nothing reaches Lemmy without a Keycloak session.
- Federation is disabled (
federation.enabled = falseinlemmy.hjson) — smaller attack surface, no moderation-policy complexity. If ever re-enabled, a domain allowlist would be required. - The Dockerfile only customizes lemmy-ui — backend, pict-rs, Postgres, and oauth2-proxy all run unmodified upstream images. The custom image is
FROM dessalines/lemmy-ui → COPY theme → RUN patch translations— no Rust/Node build.
Request chain
Section titled “Request chain”Browser → Traefik → forum-oauth2-proxy (4180) → forum-connector (4181) → forum-ui (1234)forum-oauth2-proxy’s upstreams config points at forum-connector:4181 rather than directly at forum-ui:1234 (F4.FORUM.02) — forum-connector internally proxies everything except /jit-register//health straight through to forum-ui via @fastify/http-proxy, so the rest of the forum traffic is unaffected.
Direct calls bypassing oauth2-proxy no longer work (SEC 0d.4, 2026-07-16)
Section titled “Direct calls bypassing oauth2-proxy no longer work (SEC 0d.4, 2026-07-16)”/jit-register used to trust X-Auth-Request-*/X-Forwarded-* headers without verifying the request actually passed through oauth2-proxy — since forum-connector shares sencai-net with roughly 60 other containers, any of them could forge those headers by calling http://forum-connector:4181/jit-register directly. The route now requires and cryptographically verifies the Keycloak ID token oauth2-proxy forwards via Authorization: Bearer (set_authorization_header = true) — see src/services/kc-token-verify.ts. A manual curl with forged X-Auth-Request-* headers now returns 401.
JIT user provisioning (F4.FORUM.02)
Section titled “JIT user provisioning (F4.FORUM.02)”On a user’s first visit, forum-connector calls Lemmy’s POST /api/v3/user/register with a generated internal password — no manual admin step. The Keycloak↔Lemmy mapping is stored in Strapi’s forum-user-credential content-type (sencai.space/src/api/forum-user-credential/):
| Field | Type | Notes |
|---|---|---|
kc_sub | string, unique | The Keycloak preferred_username claim, used as the stable identifier (see below) — not the OIDC sub claim |
lemmy_username | string | Sanitized Lemmy username |
encrypted_password | text, private: true | AES-256-GCM, format iv:authTag:ciphertext:salt |
is_lemmy_admin | boolean | Mirrors the current Lemmy is_admin state — see the KC role sync below, the source of truth is always the KC role |
x-sencai-classification: tenant-private — this content-type carries user credentials.
Why preferred_username instead of sub: oauth2-proxy’s set_xauthrequest never sends sub as a separate header; X-Auth-Request-User is configured as a preferred_username mapper. Renaming this identifier later would require a backfill migration of every existing record.
Registration assumes no captcha/approval — Lemmy’s registration_mode/captcha must be off so the self-service JIT flow can complete registration in a single call. If lemmy.hjson’s registration_mode changes, /jit-register fails silently (a different Lemmy error, not success) — check that setting first when debugging.
KC role forum-admin → Lemmy admin sync (F4.FORUM.03)
Section titled “KC role forum-admin → Lemmy admin sync (F4.FORUM.03)”On every JIT registration/login, syncForumAdminRole() checks whether the logging-in user currently holds the Keycloak realm role forum-admin and synchronizes Lemmy’s is_admin flag plus the forum-user-credential.is_lemmy_admin mirror field accordingly.
Why the Keycloak Admin API and not oauth2-proxy headers: oidc_groups_claim is never set (default flat groups claim), but the realm_roles protocol mapper on the sencai-forum KC client puts roles into the nested realm_access.roles claim, which oauth2-proxy cannot see — X-Auth-Request-Groups never carries it. pass_access_token/set_authorization_header are both false for the raw KC token too (deliberately — the raw token should not travel on the wire between oauth2-proxy and the upstream in most cases). src/services/keycloak-client.ts instead calls the Keycloak Admin API directly (password grant, admin-cli, master realm — the same pattern as sencai.space/src/utils/keycloak-sync.ts), so it always sees the current role assignment, not what was baked into a not-yet-refreshed token.
Flow: hasRealmRole() (composite role lookup, so a role inherited via a group/default-roles composite counts too) → if it differs from is_lemmy_admin, getPersonByUsername() → setAdmin() (authenticated as a bootstrap admin, LEMMY_ADMIN_USERNAME/LEMMY_ADMIN_PASSWORD, JWT cached in memory with auto-refresh on 401) → strapiClient.setLemmyAdminFlag() → audit forum.user.admin-promoted/forum.user.admin-demoted. Every step is best-effort — a KC Admin API error or a missing Lemmy person is logged and skipped, never blocking the login/registration itself. Demotion is lazy — it takes effect on the user’s next login, not in real time.
Two real bugs were found and fixed during live testing of this feature (neither had ever been verified against a real Lemmy instance before): setAdmin() called the nonexistent POST /api/v3/user/admin (Lemmy 0.19.8’s real endpoint is POST /api/v3/admin/add), and getPersonByUsername() read the admin flag from the wrong response field (person_view.person.admin instead of the real person_view.is_admin).
Forum notification poller (F4.FORUM.04)
Section titled “Forum notification poller (F4.FORUM.04)”Lemmy 0.19 has no webhook/event-hook mechanism — the only way to know “someone replied to/mentioned this user” is polling on their behalf. src/services/notification-poller.ts runs inside the same process as the HTTP server (started after app.listen() succeeds), not as a separate deployment unit.
Cycle (default 60s, FORUM_NOTIFICATION_POLL_INTERVAL_MS): for every forum-user-credential record, decrypt the password → log in → getUnreadCounts() (skip immediately if both counts are zero, the common case). The recipient email is read directly from Lemmy (GET /api/v3/site with the user’s own JWT) rather than stored a second time in forum-user-credential. Each unread reply/mention is published onto notification.events (routing keys forum.reply/forum.mention) and marked read on Lemmy only after a successful publish — a RabbitMQ failure leaves the item unread and it is retried on the next cycle.
Dedup is Lemmy’s own read flag — no separate “last seen id” bookkeeping in Strapi. Audit is one aggregated forum.notification.forwarded event per cycle (not one per notification), since a chatty forum would otherwise generate hundreds of low-value audit rows.
Org → Lemmy community auto-provisioning (F4.FORUM.05)
Section titled “Org → Lemmy community auto-provisioning (F4.FORUM.05)”src/consumers/organisation-consumer.ts runs a separate RabbitMQ connection in the same process (FORUM_ORG_COMMUNITY_CONSUMER_ENABLED=false disables it), consuming organisation.forum-community-requested off the existing organisation direct exchange with a dedicated routing key (deliberately not reusing create, which would fan the event out to the unrelated KC-group-sync consumer bound to that same routing key).
Per message: derive a Lemmy-safe community name from the org’s slug → lemmyClient.createCommunity() (idempotent — a community_already_exists conflict falls back to getCommunityByName() instead of throwing, so a retried message never creates a duplicate) → POST /api/organisations/:id/forum-community-callback on sencai.space, authenticated with a dedicated FORUM_CONNECTOR_SERVICE_SECRET (not the same token strapi-client.ts uses for forum-user-credential writes — the same separation-of-privilege pattern as cloud-connector’s status-callback) → audit forum.community.provisioned.
Retry: manual ack/nack, header-based retry count, exponential backoff (5s * 2^attempt, max 5 attempts), then a 14-day TTL fallback queue (forum-connector.organisation.forum-community-requested.fallback) instead of dropping the message. createCommunity()’s idempotency makes every retry safe even if the community was already created and only the callback step failed.
Known gotchas
Section titled “Known gotchas”| Gotcha | Note |
|---|---|
local_site.registration_mode must be Open | Lemmy 0.19 defaults this database column to RequireApplication on first init — this is independent of the lemmy.hjson captcha/require_application settings and cannot be preset via the hjson file. Fixed by scripts/configure-site-settings.sh (PUT /api/v3/site), run once after first start. |
local_site.federation_enabled ignores lemmy.hjson’s federation.enabled: false | Two independent settings — Lemmy defaults the DB column to true on first init regardless of the hjson file. The same configure-site-settings.sh script must be re-run even against an existing DB, not just a fresh init. |
@fastify/cors + @fastify/http-proxy route collision | cors always registers a catch-all OPTIONS preflight route; http-proxy with prefix: '/' tries to register the same route → the server fails to start entirely. Fixed by excluding OPTIONS from http-proxy’s httpMethods. No existing test exercised index.ts itself before this was found live. |
Cookie name jwt | Must stay exactly jwt — it is the cookie name lemmy-ui itself sets after a native login and expects on the SSR side. |
/jit-register rate limiting is per-KC-identity, not per-IP | Every real request arrives from the single forum-oauth2-proxy container IP — an IP-only key would rate-limit every user under one shared bucket. Default 10 req/min per identity (kc_sub/preferred_username). |
| Component | Port |
|---|---|
| forum-connector | 4181 |
| forum-oauth2-proxy | 4180 |
| Lemmy backend API | 8536 |
| PostgreSQL (forum) | 5435 |
Related services
Section titled “Related services”- Fleet Agent, Marketplace, Audit trail — other content-type-backed subsystems following the same dedicated-page pattern
notification-service— consumesforum.reply/forum.mentionfromnotification.events- Root
CLAUDE.md— full service map and RabbitMQ exchange conventions