feat(jsn-bridge): auto-invite JSN supporters to #supporters channel

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
This commit is contained in:
bunker-admin 2026-05-25 20:03:05 -06:00
parent 5a2c54dabf
commit 11f23c0072
3 changed files with 33 additions and 0 deletions

View File

@ -281,6 +281,12 @@ const envSchema = z.object({
// Required for /api/auth/bridge/users and other /api/*/bridge/* endpoints to // Required for /api/auth/bridge/users and other /api/*/bridge/* endpoints to
// accept calls. Generate with: openssl rand -hex 32 // accept calls. Generate with: openssl rand -hex 32
JSN_BRIDGE_SECRET: z.string().min(32).optional(), 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<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

View File

@ -3,6 +3,7 @@ import { randomBytes } from 'node:crypto';
import { UserCreatedVia, UserStatus } from '@prisma/client'; import { UserCreatedVia, UserStatus } from '@prisma/client';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { env } from '../../config/env';
import { siteSettingsService } from '../settings/settings.service'; import { siteSettingsService } from '../settings/settings.service';
import { rocketchatClient } from '../../services/rocketchat.client'; import { rocketchatClient } from '../../services/rocketchat.client';
import type { BridgeProvisionUserInput } from './identity-bridge.schemas'; import type { BridgeProvisionUserInput } from './identity-bridge.schemas';
@ -48,9 +49,24 @@ async function provisionRocketChatAccount(
where: { id: userId }, where: { id: userId },
data: { rocketChatUserId: rcUser._id }, 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', { logger.info('RC account provisioned for JSN-bridged user', {
userId, userId,
rcUserId: rcUser._id, rcUserId: rcUser._id,
invitedToChannel: env.RC_SUPPORTERS_CHANNEL,
}); });
} catch (err) { } catch (err) {
logger.warn('RC provisioning failed (non-blocking)', { logger.warn('RC provisioning failed (non-blocking)', {

View File

@ -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<void> {
await this.request('POST', '/channels.invite', { roomName: channelName, userId });
}
// --- Direct Messages --- // --- Direct Messages ---
/** /**