From 11f23c00721dfcaf41225701b1abb3dca213602b Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Mon, 25 May 2026 20:03:05 -0600 Subject: [PATCH] feat(jsn-bridge): auto-invite JSN supporters to #supporters channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After provisioning a Rocket.Chat account for a JSN-bridged user, explicitly invite them to the configured supporters channel via channels.invite. This is belt-and-braces alongside the RC default-channel flag — the default flag only fires at user creation time, so flipping a channel to default later doesn't catch existing users. The explicit invite path handles that case and also gives us a stable hook for migration scripts. Three changes: - New env: RC_SUPPORTERS_CHANNEL (default 'supporters'). z.string() in env.ts. Set in cmlite .env if you want to route to a different channel. - New rocketchatClient method: inviteUserToChannel(userId, channelName). Idempotent — RC's channels.invite no-ops if the user is already a member. - identityBridgeService.provisionRocketChatAccount now calls rocketchatClient.inviteUserToChannel after createUser + setting rocketChatUserId. Wrapped in its own try/catch so an invite failure logs warn but doesn't roll back the RC account creation. Tested end-to-end: JSN magic-link verify → cmlite User → RC account → auto-invited to #supporters (membership goes from 4 → 5 on the test signup). Three existing stragglers backfilled via direct API calls (idempotent). Bunker Admin --- api/src/config/env.ts | 6 ++++++ api/src/modules/auth/identity-bridge.service.ts | 16 ++++++++++++++++ api/src/services/rocketchat.client.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) 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 --- /**