diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 2c9eb1a..f2eb151 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -281,6 +281,12 @@ const envSchema = z.object({ // Required for /api/auth/bridge/users and other /api/*/bridge/* endpoints to // accept calls. Generate with: openssl rand -hex 32 JSN_BRIDGE_SECRET: z.string().min(32).optional(), + + // RC channel that every JSN-bridged supporter is invited into by the + // identity bridge after RC user provisioning. Belt-and-braces alongside RC's + // default-channel flag (which only fires at user creation time, leaving + // existing-user backfill as a separate concern). Default 'supporters'. + RC_SUPPORTERS_CHANNEL: z.string().default('supporters'), }); export type Env = z.infer; diff --git a/api/src/modules/auth/identity-bridge.service.ts b/api/src/modules/auth/identity-bridge.service.ts index 881f811..aa2634a 100644 --- a/api/src/modules/auth/identity-bridge.service.ts +++ b/api/src/modules/auth/identity-bridge.service.ts @@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto'; import { UserCreatedVia, UserStatus } from '@prisma/client'; import { prisma } from '../../config/database'; import { logger } from '../../utils/logger'; +import { env } from '../../config/env'; import { siteSettingsService } from '../settings/settings.service'; import { rocketchatClient } from '../../services/rocketchat.client'; import type { BridgeProvisionUserInput } from './identity-bridge.schemas'; @@ -48,9 +49,24 @@ async function provisionRocketChatAccount( where: { id: userId }, data: { rocketChatUserId: rcUser._id }, }); + // Belt-and-braces: explicitly invite to the supporters channel. RC's + // default-channel flag also catches new users on createUser, but this + // guarantees membership even if the default-channel set changes later or + // if the user was created before the flag was flipped (e.g. backfill). + try { + await rocketchatClient.inviteUserToChannel(rcUser._id, env.RC_SUPPORTERS_CHANNEL); + } catch (inviteErr) { + logger.warn('RC supporters-channel invite failed (non-blocking)', { + userId, + rcUserId: rcUser._id, + channel: env.RC_SUPPORTERS_CHANNEL, + err: inviteErr instanceof Error ? inviteErr.message : String(inviteErr), + }); + } logger.info('RC account provisioned for JSN-bridged user', { userId, rcUserId: rcUser._id, + invitedToChannel: env.RC_SUPPORTERS_CHANNEL, }); } catch (err) { logger.warn('RC provisioning failed (non-blocking)', { diff --git a/api/src/services/rocketchat.client.ts b/api/src/services/rocketchat.client.ts index 3fb16bf..aeecf10 100644 --- a/api/src/services/rocketchat.client.ts +++ b/api/src/services/rocketchat.client.ts @@ -284,6 +284,17 @@ class RocketChatClient { } } + /** + * Add a user to a public channel by channel name. Idempotent — returns the + * channel info regardless of whether the user was already a member. Used by + * the JSN identity bridge to make sure every JSN supporter lands in a + * specific channel (#supporters by convention), independent of RC's + * default-channel flag (which only fires at user creation time). + */ + async inviteUserToChannel(userId: string, channelName: string): Promise { + await this.request('POST', '/channels.invite', { roomName: channelName, userId }); + } + // --- Direct Messages --- /**