Mobile App Integration

Wiring Phenom Chat into the PhenomApp React Native app: Matrix/Synapse only, Cognito JWT login, and the membership-driven multi-room model (single Red Room to room switcher) that needs no app update.

Reconciled to the canonical page. The single source of truth for chat services is Chat Services (validated 2026-05-27); where this page disagrees with it, the canonical page wins. Chat is Matrix/Synapse onlyImplementation B (Hasura Lite) and the SSO-first login flow that earlier versions of this page described are retired. JWT login (org.matrix.login.jwt) is the correct and required flow; m.login.sso is an alternative, not a requirement.

This is the wiring guide for the PhenomApp chat client: how to take the existing single-room Matrix integration to a membership-driven multi-room model. The target reader is the mobile developer (Jonathan Hart) or an autonomous engineering agent.

Repo Phenom-earth/PhenomApp (Expo / React Native, Ignite boilerplate)
Source root PhenomApp/PhenomApp/app/ (note the nested PhenomApp/PhenomApp layout)
Chat backend Matrix / Synapse at chat.thephenom.app (prod), chat-staging.thephenom.app (dev)
SDK matrix-js-sdk@^34.13.0, UI via react-native-gifted-chat@^3.3.2
Authored against branch fix/89-chatconfig-prod @ 9917783

Security context (read first): the hardened chat origin

The chat backend was hardened on 2026-05-27. Per the canonical Chat Services page, which overrides all chat sub-pages on conflict, the topology is now:

flowchart LR
  subgraph Clients
    MOB["PhenomApp (mobile)<br/>Matrix JWT login"]
    SPA["nest / dev-nest SPA<br/>Cognito bearer"]
  end
  MOB --> CF
  SPA --> CF
  CF["Cloudflare edge (orange-cloud)<br/>SSL Full, WAF (COUNT), rate-limit"] -->|"AOP client cert (clientAuth EKU)"| ALB
  ALB[("phenom-prod-alb :443<br/>mTLS mutual_authentication=verify")] -->|"mTLS verified"| ORI["ECS Fargate (phenom-prod-cluster)<br/>Synapse 1.105 (Matrix) + Hasura<br/>origin enforces Cognito"]
  RAW(["raw ALB direct / :80"]) -.->|"rejected / 301"| ALB

What changed server-side, and what it means for the mobile app:

Hardening change (server side) Impact on PhenomApp
Implementation B (Hasura Lite chat) excised. Chat is Matrix/Synapse only. Ignore every “Hasura Lite” / GraphQL chat code path in older docs. The app’s matrix-js-sdk approach is the only correct one. The old chatConfig shape with hasura/synapse/cognito/rooms sub-objects is retired; the app’s current flat chatConfig.ts is correct.
Origin mTLS-locked (mutual_authentication=verify); raw-ALB bypass closed; :80 returns 301. The client cert is presented by Cloudflare (Authenticated Origin Pulls), not by the client. No app work, but a hard rule: the app must talk only to https://chat.thephenom.app (the Cloudflare hostname). It must never be pointed at a raw ALB DNS name, an IP, or http://. Cloudflare does the mTLS handshake to the origin; the phone is a normal HTTPS client to Cloudflare’s edge. mTLS is not a mobile blocker as long as you go through the hostname.
Edge-auth Worker dropped; the origin enforces Cognito. A valid Cognito ID token remains mandatory. The org.matrix.login.jwt exchange is exactly right and required.
Login/register rate limit: 15 requests / 10s per IP (Cloudflare rule). The app re-runs the JWT login on every launch and every “Retry” tap. A tight retry loop, or many users behind one NAT/corporate IP, can trip this. Add backoff to login/retry (see Edge cases).
WAF phenom-prod-chat-protect in COUNT (not BLOCK yet). HasuraAdminPaths COUNT→BLOCK is pending/CI-gated. Mobile never touches /_synapse/admin/* or admin paths, so this is safe. Do not add any admin-path calls to the app.
Auth: Cognito prod pool us-east-1_knEL7cqS3, app client 5vlgjrab90897c45ls9jkf9s2p. Flows: org.matrix.login.jwt (Cognito ID token) and m.login.sso. Keep JWT login (the app already aligns to pool knEL7cqS3). SSO is an alternative, not needed.
Provisioning: Cognito PostConfirmation trigger → chat-user-provisioner lambda creates the Matrix user and auto-joins The Red Room only. Admin/extra-room adds use bot @phenom-provisioner-bot. Confirms the “new user lands in The Red Room with zero config” path. Extra rooms are granted separately (see Server-side prerequisites).

The four real rooms – use the room ID, never the alias

The canonical page warns of alias drift: the legacy aliases (#internal, #partners, #community) do not match the room names. Always key on the room ID.

Room name Room ID Legacy alias (do NOT trust) Who is in it
The Red Room !jFXGFANpXTXXgNkdNi:chat.thephenom.app #the-red-room Mobile default; every new/mobile user auto-joins. Rendered as plain “chat”, no title.
Experiencers !dhlPzWDEjiJBhrqORG:chat.thephenom.app #community Community / experiencers (the room this guide targets for multi-room).
Analysts !NvNAXrFhqFBZXUtEkU:chat.thephenom.app #partners Analysts (e.g. mark@).
Staff !WXBotwMKlVifIuEeBL:chat.thephenom.app #internal Team/admins (power level 100: Aaron, Irena, Logan, M, Jonathan).

The membership-driven design in this guide is exactly what the canonical page calls the mobile target: “PhenomApp build 20: a single Red Room, no title, membership-driven, JWT login.” Multi-room is the natural extension – the same membership-driven model, now rendering more than one joined room.

The goal in one sentence

A user who has just downloaded the app lands in The Red Room with zero configuration; a user who has additionally been granted the Experiencers room (or any room created later) sees a room switcher and can move between every room they belong to, without an app update.

“Without an app update” is the whole design constraint. It rules out hardcoded room IDs and dictates a membership-driven architecture: the app shows whatever rooms the Matrix server says the user has joined, and nothing else.

Current state (what exists today)

The chat integration is deliberately minimal and hardcoded to one room. Three files matter:

File Role today
app/services/chat/chatConfig.ts Hardcodes homeserver URL + three room IDs per env. In prod, internal/partners/community all point at the same room (!jFXGFANpXTXXgNkdNi, “The Red Room”) so no code path can route a mobile user into a staff room.
app/services/chat/matrixChatService.ts Cognito JWT to Matrix session exchange (org.matrix.login.jwt), client lifecycle, and message helpers that all read config.rooms.internal directly. Sync is filtered to that one room.
app/screens/PhenomScreens/Chat/ChatScreen.tsx + ChatDetailScreen.tsx ChatScreen initialises the client then navigation.replace("ChatDetail", { threadId: rooms.internal }). ChatDetailScreen renders one room with GiftedChat.

The auth flow is correct and was hard-won in #89keep it exactly as-is:

Cognito ID token (from AuthContext.fetchAuthSession)
  -> POST {homeserver}/_matrix/client/v3/login  { type: "org.matrix.login.jwt", token }
  -> Synapse validates JWT against pool us-east-1_knEL7cqS3, maps `sub` to @<sub>:chat.thephenom.app
  -> returns { access_token, user_id, device_id }
  -> createClient({ baseUrl, accessToken, userId, deviceId }).startClient()

The blocker for multi-room is structural, in three places:

  1. chatConfig.ts pins room IDs at build time.
  2. initMatrixClient installs a Filter that syncs only config.rooms.internal, so the SDK never even learns about the user’s other rooms.
  3. Every helper (getMessageHistory, sendMessage, onNewMessage, loadEarlierMessages) hardcodes config.rooms.internal instead of taking a roomId argument.

The correct mental model for a Matrix mobile client

This is the single most important section. Internalise it before writing code.

Rooms are discovered, not configured. A Matrix client does not decide which rooms a user can see; the server does, through membership. When you call startClient(), the sync response hands you every room the user has join (and invite) membership in. The idiomatic flow is:

  1. Authenticate (you already do this).
  2. Sync – remove the single-room filter so you get all joined rooms.
  3. Ask the client getRooms() and keep the ones where getMyMembership() === "join".
  4. Render that list. One room → open it directly. More than one → show the switcher.

This is why “additional as-yet-generated rooms” requires no client change: when an admin creates the Experiencers room and adds a user (or any future room), that membership arrives on the next sync and the room simply appears in getRooms(). Entitlement is a server-side concern (who gets invited/joined to what); the app’s only job is to faithfully mirror membership.

The default room (“The Red Room”) is not special to the client. It is special to the server: the provisioner auto-joins every new mobile account to it. To the app it is just “the room the brand-new user happens to be a member of.” You may keep its ID in config purely as a preferred default selection (which room to open first when several exist), never as a filter.

Target architecture

flowchart TD
  CS["ChatScreen<br/>initMatrixClient() – full sync, no room filter<br/>getJoinedRooms()"]
  CS -->|"0 rooms"| EMPTY["Empty state<br/>(provisioner lag – pull to retry)"]
  CS -->|"1 room"| DETAIL["ChatDetail { roomId }<br/>titleless Red Room"]
  CS -->|"more than 1 room"| ROOMS["ChatRooms switcher<br/>(RoomListScreen)"]
  ROOMS -->|"tap room"| DETAIL
  DETAIL -.->|"'Rooms' button (only when joinedRooms > 1)"| ROOMS
  • ChatScreen – unchanged purpose (connect + spinner + error/retry), but routes based on room count.
  • New RoomListScreen (route ChatRooms) – the switcher. FlatList of joined rooms with name, last-message preview, unread badge, sorted by last activity.
  • ChatDetailScreen – now room-parameterised. Reads route.params.roomId. Header shows the room name and, when the user belongs to more than one room, a “Rooms” affordance back to the switcher.
  • matrixChatService.ts – every helper takes an explicit roomId; adds getJoinedRooms(), subscribeToRoomList(), and invite auto-accept.

Step-by-step implementation

Each step lists the file, the change, the code, and why. Steps are ordered so the app compiles and runs after each one.

Step 1 – Stop filtering sync to one room; keep config as URL + preferred default

File: app/services/chat/chatConfig.ts

Keep the per-environment homeserver URL. Demote the room IDs to a single optional defaultRoomId used only to pick which room opens first when the user has several. Remove the internal/partners/community triplet (it only ever existed to defend the single-room hack).

// app/services/chat/chatConfig.ts

export const ChatConfig = {
  testing: {
    homeserverUrl: "https://chat-staging.thephenom.app",
    // "The Red Room" on testing. Used ONLY as the preferred room to open first
    // when the user is a member of more than one room. NOT a sync filter.
    defaultRoomId: "!HYLubJHvGcvVsOOblg:chat-staging.thephenom.app",
  },
  production: {
    homeserverUrl: "https://chat.thephenom.app",
    defaultRoomId: "!jFXGFANpXTXXgNkdNi:chat.thephenom.app", // The Red Room (auto-joined)
  },
} as const
// For reference only (do NOT hardcode as a filter): the other prod rooms a user
// may be granted are Experiencers !dhlPzWDEjiJBhrqORG, Analysts !NvNAXrFhqFBZXUtEkU,
// Staff !WXBotwMKlVifIuEeBL (all :chat.thephenom.app). Always go through chat.thephenom.app
// (the Cloudflare hostname) – never a raw ALB/IP/:80 origin (origin is mTLS-locked).

export type ChatEnvironment = keyof typeof ChatConfig

export function getActiveChatConfig() {
  const env: ChatEnvironment = __DEV__ ? "testing" : "production"
  return ChatConfig[env]
}

Why keep defaultRoomId at all? Pure UX: when a user has both The Red Room and Experiencers, opening The Red Room first matches the brand. If the ID ever drifts, the code falls back to “most recently active room”, so a stale ID degrades gracefully rather than breaking.

Step 2 – Rewrite the service to be room-parameterised and discovery-driven

File: app/services/chat/matrixChatService.ts

Changes:

  • Drop the single-room Filter from initMatrixClient. Enable lazyLoadMembers for sync performance.
  • Add a RoomSummary type and getJoinedRooms().
  • Make getMessageHistory, sendMessage, onNewMessage, loadEarlierMessages take a roomId.
  • Add subscribeToRoomList() so the switcher updates live when rooms are granted/removed.
  • Add invite auto-accept so server-side invites (not just force-joins) become visible automatically.
// app/services/chat/matrixChatService.ts (key excerpts)

import {
  ClientEvent,
  createClient,
  EventType,
  MatrixClient,
  MatrixEvent,
  MsgType,
  Room,
  RoomEvent,
  RoomMemberEvent, // for invite handling, see below
} from "matrix-js-sdk"
import { IMessage } from "react-native-gifted-chat"
import { getActiveChatConfig } from "./chatConfig"

let client: MatrixClient | null = null

// ... loginWithCognitoJwt() stays EXACTLY as-is ...

export async function initMatrixClient(
  cognitoIdToken: string,
  cognitoUserId: string,
): Promise<MatrixClient> {
  const config = getActiveChatConfig()
  const session = await loginWithCognitoJwt(config.homeserverUrl, cognitoIdToken)
  const matrixUserId = session.userId

  // (keep the expected-user-id sanity check)

  client = createClient({
    baseUrl: config.homeserverUrl,
    accessToken: session.accessToken,
    userId: matrixUserId,
    deviceId: session.deviceId,
  })

  // Auto-accept invites so server-granted rooms (Experiencers, future rooms)
  // appear without the user having to do anything. Entitlement is decided
  // server-side; the client just accepts.
  client.on(RoomEvent.MyMembership, (room, membership) => {
    if (membership === "invite") {
      client?.joinRoom(room.roomId).catch((e) =>
        console.warn("[matrix] auto-join failed for", room.roomId, e?.message),
      )
    }
  })

  // NO room filter. We want every joined room in the sync.
  await new Promise<void>((resolve, reject) => {
    client!.once(ClientEvent.Sync, (state) => {
      if (state === "PREPARED") resolve()
      else if (state === "ERROR") reject(new Error("Matrix sync error"))
    })
    client!.startClient({ initialSyncLimit: 30, lazyLoadMembers: true })
  })

  return client!
}

export interface RoomSummary {
  roomId: string
  name: string
  lastMessage: string
  lastTs: number
  unread: number
  isDefault: boolean
}

/** Every room the user has actually joined, newest activity first. */
export function getJoinedRooms(): RoomSummary[] {
  if (!client) throw new Error("Matrix client not initialized")
  const { defaultRoomId } = getActiveChatConfig()

  return client
    .getRooms()
    .filter((r) => r.getMyMembership() === "join")
    .map((r): RoomSummary => {
      const events = r.getLiveTimeline().getEvents()
      const lastMsg = [...events].reverse().find((e) => e.getType() === "m.room.message")
      return {
        roomId: r.roomId,
        name: r.name || r.roomId,           // server-set via m.room.name
        lastMessage: lastMsg?.getContent()?.body ?? "",
        lastTs: lastMsg?.getTs() ?? 0,
        unread: r.getUnreadNotificationCount() ?? 0,
        isDefault: r.roomId === defaultRoomId,
      }
    })
    .sort((a, b) => b.lastTs - a.lastTs)
}

/** Which room should open first: the configured default if joined, else most active. */
export function getInitialRoomId(): string | null {
  const rooms = getJoinedRooms()
  if (rooms.length === 0) return null
  return (rooms.find((r) => r.isDefault) ?? rooms[0]).roomId
}

/** Re-emit the room list on any change (new room, rename, new message, membership). */
export function subscribeToRoomList(cb: () => void): () => void {
  if (!client) throw new Error("Matrix client not initialized")
  const handler = () => cb()
  client.on(ClientEvent.Room, handler)
  client.on(RoomEvent.Name, handler)
  client.on(RoomEvent.Timeline, handler)
  client.on(RoomEvent.MyMembership, handler)
  return () => {
    client?.off(ClientEvent.Room, handler)
    client?.off(RoomEvent.Name, handler)
    client?.off(RoomEvent.Timeline, handler)
    client?.off(RoomEvent.MyMembership, handler)
  }
}

// --- the message helpers now take roomId ---

export function getMessageHistory(roomId: string, limit = 50): IMessage[] {
  if (!client) throw new Error("Matrix client not initialized")
  const room = client.getRoom(roomId)
  if (!room) return []
  return room
    .getLiveTimeline()
    .getEvents()
    .map(matrixEventToMessage)
    .filter((m): m is IMessage => m !== null)
    .reverse()
    .slice(0, limit)
}

export async function sendMessage(roomId: string, text: string, phenomId?: string) {
  if (!client) throw new Error("Matrix client not initialized")
  const content = { msgtype: MsgType.Text, body: text, ...(phenomId ? { phenom_id: phenomId } : {}) }
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  await client.sendEvent(roomId, EventType.RoomMessage, content as any)
}

export function onNewMessage(roomId: string, callback: (m: IMessage) => void): () => void {
  if (!client) throw new Error("Matrix client not initialized")
  const handler = (event: MatrixEvent, room: Room | undefined, toStart?: boolean) => {
    if (toStart) return
    if (room?.roomId !== roomId) return
    const m = matrixEventToMessage(event)
    if (m) callback(m)
  }
  client.on(RoomEvent.Timeline, handler)
  return () => client?.off(RoomEvent.Timeline, handler)
}

export async function loadEarlierMessages(roomId: string, limit = 30): Promise<IMessage[]> {
  if (!client) throw new Error("Matrix client not initialized")
  const room = client.getRoom(roomId)
  if (!room) return []
  await client.scrollback(room, limit)
  return getMessageHistory(roomId, 9999)
}

matrixEventToMessage, getMatrixClient, and stopMatrixClient are unchanged.

Step 3 – Add the room-switcher route

File: app/navigators/navigationTypes.ts

export type ChatStackParamList = {
  ChatList: undefined                                  // connect/init screen
  ChatRooms: undefined                                 // room switcher list
  ChatDetail: { roomId: string; roomName?: string }    // was { threadId }
}

Renaming threadId to roomId is a clarity fix: these are Matrix rooms, not threads. Update the two route.params reads in ChatDetailScreen. If you want a smaller diff, you may keep threadId as the param name; just be consistent.

File: app/navigators/phenom/ChatStackNavigator.tsx

import { RoomListScreen } from "@/screens/PhenomScreens/Chat/RoomListScreen"
// ...
<Stack.Navigator screenOptions={{ headerShown: false, animation: "slide_from_right" }}>
  <Stack.Screen name="ChatList" component={ChatScreen} />
  <Stack.Screen name="ChatRooms" component={RoomListScreen} />
  <Stack.Screen name="ChatDetail" component={ChatDetailScreen} />
</Stack.Navigator>

Step 4 – Route by room count in ChatScreen

File: app/screens/PhenomScreens/Chat/ChatScreen.tsx

Replace the .then(...) body so it branches on how many rooms the user is in:

initMatrixClient(authToken, userId)
  .then(() => {
    const rooms = getJoinedRooms()
    if (rooms.length === 0) {
      setError("No rooms yet. Pull to retry in a moment.") // provisioner may lag
      return
    }
    if (rooms.length === 1) {
      navigation.replace("ChatDetail", { roomId: rooms[0].roomId, roomName: rooms[0].name })
    } else {
      navigation.replace("ChatRooms")
    }
  })
  .catch((e: any) => setError(e?.message ?? "Connection failed"))

Add imports for getJoinedRooms from the service. Everything else in ChatScreen (theme, spinner, retry) stays.

Step 5 – Build the RoomListScreen (the switcher)

New file: app/screens/PhenomScreens/Chat/RoomListScreen.tsx

Match the existing Ignite style: PhenomScreen, useAppTheme/themed, ThemedStyle, fonts.robo.mono. Subscribe to subscribeToRoomList so newly-granted rooms appear live.

import { useEffect, useState } from "react"
import { FlatList, Pressable, TextStyle, View, ViewStyle } from "react-native"
import { useNavigation } from "@react-navigation/native"
import { NativeStackNavigationProp } from "@react-navigation/native-stack"

import { Text } from "@/components/Text"
import { translate } from "@/i18n/translate"
import { ChatStackParamList } from "@/navigators/navigationTypes"
import { getJoinedRooms, subscribeToRoomList, RoomSummary } from "@/services/chat/matrixChatService"
import { useAppTheme } from "@/theme/context"
import { spacing } from "@/theme/spacing"
import type { ThemedStyle } from "@/theme/types"
import { fonts } from "@/theme/typography"

import { PhenomScreen } from "../PhenomScreen"

type Nav = NativeStackNavigationProp<ChatStackParamList, "ChatRooms">

export function RoomListScreen() {
  const { themed } = useAppTheme()
  const navigation = useNavigation<Nav>()
  const [rooms, setRooms] = useState<RoomSummary[]>(() => getJoinedRooms())

  useEffect(() => subscribeToRoomList(() => setRooms(getJoinedRooms())), [])

  return (
    <PhenomScreen headerTitle={translate("phenomNavigator:chatTab")}>
      <FlatList
        data={rooms}
        keyExtractor={(r) => r.roomId}
        renderItem={({ item }) => (
          <Pressable
            style={$row}
            onPress={() =>
              navigation.navigate("ChatDetail", { roomId: item.roomId, roomName: item.name })
            }
          >
            <View style={$rowText}>
              <Text text={item.name} style={themed($roomName)} />
              {!!item.lastMessage && (
                <Text text={item.lastMessage} numberOfLines={1} style={themed($preview)} />
              )}
            </View>
            {item.unread > 0 && <Text text={String(item.unread)} style={themed($badge)} />}
          </Pressable>
        )}
      />
    </PhenomScreen>
  )
}

const $row: ViewStyle = { flexDirection: "row", alignItems: "center", padding: spacing.md, gap: spacing.sm }
const $rowText: ViewStyle = { flex: 1 }
const $roomName: ThemedStyle<TextStyle> = ({ colors }) => ({ color: colors.text, fontFamily: fonts.robo.mono, fontSize: 15 })
const $preview: ThemedStyle<TextStyle> = ({ colors }) => ({ color: colors.textDim, fontFamily: fonts.robo.mono, fontSize: 12, marginTop: 2 })
const $badge: ThemedStyle<TextStyle> = ({ colors }) => ({ color: colors.text, backgroundColor: "#243538", borderRadius: 10, fontFamily: fonts.robo.mono, fontSize: 12, overflow: "hidden", paddingHorizontal: 8, paddingVertical: 2 })

Step 6 – Make ChatDetailScreen room-aware + add the switcher affordance

File: app/screens/PhenomScreens/Chat/ChatDetailScreen.tsx

  • Read route.params.roomId (was threadId) and use it everywhere instead of getActiveChatConfig().rooms.internal.
  • Header title rule (matches the canonical “no title” mobile spec): when the user is in only the Red Room, keep it titleless (render the existing phenomNavigator:chatTab label) exactly as build 20 does today. Only when the user belongs to more than one room do you show the room name, so the Experiencers vs Red Room distinction is clear. Concretely: headerTitle = showSwitcher ? (roomName ?? client?.getRoom(roomId)?.name) : translate("phenomNavigator:chatTab").
  • Show the “Rooms” switcher affordance only when getJoinedRooms().length > 1, navigating back to ChatRooms.

Key edits:

const { roomId, roomName } = route.params
// timeline load + listener: use `roomId` (not config.rooms.internal)
const room = client.getRoom(roomId)
// ...
client.on(RoomEvent.Timeline, onTimeline) // onTimeline already checks roomEvent.roomId === room.roomId

// onSend:
client.sendTextMessage(roomId, message.text)

// header: show name + optional switcher
const showSwitcher = getJoinedRooms().length > 1
return (
  <PhenomScreen
    headerTitle={roomName ?? client?.getRoom(roomId)?.name ?? "Chat"}
    // if PhenomScreen supports a right action, wire a "Rooms" button to it;
    // otherwise render a small Pressable above GiftedChat:
  >
    {showSwitcher && (
      <Pressable onPress={() => navigation.navigate("ChatRooms")} style={{ padding: 8 }}>
        <Text text="‹ Rooms" style={themed($status)} />
      </Pressable>
    )}
    {/* ...existing status + GiftedChat... */}
  </PhenomScreen>
)

Check what right-side header API PhenomScreen exposes before committing to the inline Pressable. Prefer a proper header action if one exists.

Server-side prerequisites

This is not app work, but the feature is dead without it. The app only mirrors membership – someone has to grant it. This is the phenom-infra chat workstream (owners on the security side; coordinate via the chat-security handoff). Per the canonical page, today:

  1. The Red Room auto-join is live. The Cognito PostConfirmation trigger invokes the chat-user-provisioner lambda, which creates the Matrix user and auto-joins The Red Room (!jFXGFANpXTXXgNkdNi), reaching Synapse via Cloud Map synapse.phenom-prod.local:8008. This is what makes “new user lands in The Red Room” work. Working as of 2026-05-27.
  2. Experiencers and future rooms are granted by a Staff member (power level 100: Aaron, Irena, Logan, M, Jonathan) adding the user, or by the provisioner bot @phenom-provisioner-bot (admin token in Secrets Manager key phenom-prod-synapse-admin-token). Two mechanisms, pick per room:
    • Force-join via Synapse admin API (POST /_synapse/admin/v1/join/{roomId}) – cleanest; the room just appears on the user’s next sync, no client action needed.
    • Invite (POST /_matrix/client/v3/rooms/{roomId}/invite) – the app’s new auto-accept handler (Step 2) joins it on the next sync.

    The provisioner lambda currently auto-joins only the Red Room. Until extra-room provisioning is automated, Experiencers membership is a Staff/bot action. The app does not need to change either way.

  3. Entitlement source of truth stays server-side. Decide what grants Experiencers access (a Cognito group, a Hasura role, a manual Staff add) and have the provisioner/Staff act on it. The app must never encode entitlement logic; it would go stale and could be bypassed.
  4. Room names must be set as m.room.name state events server-side (“Experiencers”, etc.). The switcher renders room.name; an unnamed room shows its raw !id. Beware alias drift: never derive a room’s identity or name from its legacy alias; key on the room ID and trust room.name.

Edge cases and constraints

Verify these before shipping.

  • Origin lock (mTLS / AOP). A hard rule: connect only to https://chat.thephenom.app. The origin ALB requires a client cert that Cloudflare presents (Authenticated Origin Pulls); the phone never presents one. There is no client-cert work in the app, but if anyone points the app at a raw ALB DNS name, an IP, or http://, the request is rejected (or 301’d off :80). Keep homeserverUrl on the Cloudflare hostname forever.
  • Origin enforces Cognito + login rate limit (15 req / 10s per IP). The app re-runs the JWT login on every launch and every “Retry” tap. Add backoff so a flaky network or an impatient user does not trip the Cloudflare rate limit (which would surface as login failures for everyone behind that IP). Minimum viable guard in ChatScreen:
    const [cooldown, setCooldown] = useState(false)
    const connect = useCallback(() => {
      if (cooldown) return
      setError(null)
      // ...initMatrixClient(...).then(route).catch(...)
      setCooldown(true)
      setTimeout(() => setCooldown(false), 3000) // >= ~1 attempt / 3s, well under 15/10s
    }, [cooldown, /* ... */])
    
    Disable the Retry button while cooldown is true. Do not auto-retry in a loop.
  • Encryption. This guide assumes the rooms are unencrypted (the canonical page describes no E2EE, and provisioning/auto-join would not work transparently if they were). matrix-js-sdk E2EE in React Native (Hermes) requires the Rust crypto bindings and a crypto store: a substantial separate effort and a known RN pain point. Confirm rooms are not E2EE before relying on plaintext timelines. If product later wants encryption, scope it as its own project.
  • Session persistence. Today the app re-runs the JWT exchange and a full sync on every launch. With a handful of rooms and initialSyncLimit: 30 this is fine. The keys in matrixAuth.ts (matrix_access_token etc.) are already there if you later want to cache the session and skip re-login; the trade-off is you must then handle token expiry/M_UNKNOWN_TOKEN by falling back to a fresh JWT exchange. Recommendation: keep fresh-login for now; revisit only if cold-start sync feels slow.
  • Sync cost at scale. Removing the single-room filter means sync now covers all joined rooms. Correct and necessary. If room count ever grows large (dozens+), adopt Simplified Sliding Sync (MSC4186) – but that needs server support and is premature today.
  • Unread badges. getUnreadNotificationCount() is what the switcher uses. Consider surfacing a total unread count on the PhenomChat bottom-tab icon later.
  • Empty state / provisioner lag. A brand-new account might hit ChatScreen a beat before the provisioner finishes the Red Room auto-join, yielding 0 rooms. The Step 4 code shows a retry message for this; keep it.
  • Default room ID drift. If defaultRoomId no longer matches a joined room, getInitialRoomId() falls back to most-active. Harmless, but worth a log line.

Acceptance criteria (definition of done)

Run these on a real device / TestFlight build, verified visually (not just unit-green):

  1. New user, Red Room only → app opens directly into The Red Room. No switcher button. Can send/receive.
  2. User granted Experiencers (server invites or force-joins) → switcher list shows both rooms with names; tapping each opens the correct timeline; messages are isolated per room; sending posts only to the open room.
  3. Live grant while app is open → admin adds the user to a brand-new room; within one sync cycle the room appears in the switcher with no app restart (validates subscribeToRoomList + auto-accept).
  4. Invite path → server invites (not force-joins); the room still appears (validates auto-accept).
  5. Unnamed-room guard → a room without m.room.name does not crash the list (shows id, logged as a server-side gap).
  6. Regression → the Cognito-to-Matrix JWT exchange is untouched and still works on a fresh login.

Suggested commit sequence

Small, reviewable, each compiles:

  1. refactor(chat): config exposes homeserverUrl + defaultRoomId only
  2. feat(chat): room-parameterise matrix service + getJoinedRooms + invite auto-accept (remove single-room sync filter here)
  3. feat(chat): add ChatRooms route to ChatStackParamList
  4. feat(chat): route ChatScreen by joined-room count
  5. feat(chat): RoomListScreen room switcher
  6. feat(chat): ChatDetailScreen reads roomId param + switcher affordance
  7. test(chat): multi-room acceptance pass on TestFlight (after server-side grants are confirmed)

Open against the existing #89 chat workstream or a fresh feature/NN-chat-multiroom branch off main.

What deliberately did NOT change

  • The org.matrix.login.jwt Cognito exchange (loginWithCognitoJwt) – correct, leave it.
  • matrixEventToMessage, the GiftedChat bubble/video/phenom-card rendering, the cyan monospace styling.
  • The AWS Cognito pool alignment (us-east-1_knEL7cqS3) and homeserver URLs.
  • The bottom-tab navigator and PhenomChat entry point.

The change is surgical: swap build-time room configuration for runtime room discovery, and add one list screen. Everything that was hard to get right (auth, message rendering) is untouched.