feat(jsn-bridge): identity / donation / social-link / Rocket.Chat bridges

Four bearer-secret-gated server-to-server endpoints that mirror JSN
supporters into cmlite. All share a constant-time `requireBridgeSecret`
(timingSafeEqual on `Bearer ${JSN_BRIDGE_SECRET}`) and per-route
Redis-backed rate limiting.

- POST /api/auth/bridge/users — identity provisioning, idempotent on email.
  Tags users with `permissions._jsnProvenance` (jsn-funnel | jsn-donate |
  jsn-organize | jsn-organic) and createdVia=JSN_BRIDGE. On new-user
  creation, fire-and-forget RC account provisioning via the existing
  rocketchatClient (createUser with throwaway password; SSO uses admin-side
  createUserToken). Stores RC user id on User.rocketChatUserId.

- POST /api/payments/bridge/record — donation mirror, idempotent on
  stripePaymentIntentId. Creates Invoice (type='donation', status='paid')
  + Payment in one transaction. 404s if no cmlite user for the email
  (JSN provisions first).

- POST /api/auth/bridge/social-links — handle sync, mirror semantics
  keyed on (userId, platform). Mirror per the existing handoff spec at
  docs/docs/handoff/cmlite-bridge.md on the JSN side.

- POST /api/rocketchat/bridge/auth — returns RC SSO token by looking up
  User.rocketChatUserId and calling rocketchatClient.createUserToken.
  Returns {enabled: false} when chat is off or the user has no RC account.

env.ts gains JSN_BRIDGE_SECRET (z.string().min(32).optional()).

Prisma migrations:
- add_jsn_bridge_to_user_created_via — enum value JSN_BRIDGE
- extend_social_platforms_and_unique — facebook/threads/bluesky/linkedin +
  composite unique (user_id, platform)
- add_user_rocketchat_user_id — User.rocketChatUserId column

JSN-side spec lives in the JSN repo at
docs/docs/integrations/changemaker.md.

Bunker Admin
This commit is contained in:
bunker-admin 2026-05-25 19:06:07 -06:00
parent 5331cdcc67
commit 5a2c54dabf
16 changed files with 616 additions and 0 deletions

View File

@ -0,0 +1,9 @@
-- Extend SocialPlatform enum with JSN-side handle types
ALTER TYPE "SocialPlatform" ADD VALUE IF NOT EXISTS 'facebook';
ALTER TYPE "SocialPlatform" ADD VALUE IF NOT EXISTS 'threads';
ALTER TYPE "SocialPlatform" ADD VALUE IF NOT EXISTS 'bluesky';
ALTER TYPE "SocialPlatform" ADD VALUE IF NOT EXISTS 'linkedin';
-- One row per (user, platform) — enforces idempotency for the JSN bridge sync.
CREATE UNIQUE INDEX IF NOT EXISTS "uniq_user_social_link_user_platform"
ON "user_social_links"("user_id", "platform");

View File

@ -0,0 +1,2 @@
-- Rocket.Chat user id provisioned by the JSN identity bridge.
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "rocketchat_user_id" TEXT;

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "UserCreatedVia" ADD VALUE 'JSN_BRIDGE';

View File

@ -42,6 +42,7 @@ enum UserCreatedVia {
STANDARD
SELF_REGISTRATION
QUICK_JOIN_INVITE
JSN_BRIDGE
}
model User {
@ -60,6 +61,10 @@ model User {
expireDays Int?
lastLoginAt DateTime?
emailVerified Boolean @default(false)
// Rocket.Chat user id. Set when identity bridge provisions an RC account
// alongside the cmlite user (fire-and-forget). Null when RC is disabled or
// the createUser call failed.
rocketChatUserId String? @map("rocketchat_user_id")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -1560,6 +1565,10 @@ enum SocialPlatform {
snapchat
linktree
custom
facebook
threads
bluesky
linkedin
}
enum UserUploadStatus {
@ -2620,6 +2629,7 @@ model UserSocialLink {
// Relations
user User @relation("UserSocialLinks", fields: [userId], references: [id])
@@unique([userId, platform], map: "uniq_user_social_link_user_platform")
@@index([userId], map: "idx_user_social_links_user")
@@index([userId, position], map: "idx_user_social_links_position")
@@map("user_social_links")

View File

@ -276,6 +276,11 @@ const envSchema = z.object({
MAXMIND_ACCOUNT_ID: z.string().default(''),
MAXMIND_LICENSE_KEY: z.string().default(''),
GEOIP_DB_PATH: z.string().default('/data/geoip/GeoLite2-City.mmdb'),
// JSN -> cmlite bridge shared secret. Must match JSN's CMLITE_BRIDGE_SECRET.
// 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(),
});
export type Env = z.infer<typeof envSchema>;

View File

@ -0,0 +1,60 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import { timingSafeEqual } from 'node:crypto';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { validate } from '../../middleware/validate';
import { bridgeProvisionUserSchema } from './identity-bridge.schemas';
import { identityBridgeService } from './identity-bridge.service';
const router = Router();
const bridgeRateLimit = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) =>
redis.call(command, ...args) as Promise<any>,
prefix: 'rl:bridge:',
}),
message: {
error: { message: 'Bridge rate limit exceeded', code: 'BRIDGE_RATE_LIMIT' },
},
});
function requireBridgeSecret(req: Request, res: Response, next: NextFunction) {
if (!env.JSN_BRIDGE_SECRET) {
return res.status(503).json({ error: 'BRIDGE_DISABLED' });
}
const header = req.get('authorization') ?? '';
const expected = `Bearer ${env.JSN_BRIDGE_SECRET}`;
const headerBuf = Buffer.from(header);
const expectedBuf = Buffer.from(expected);
if (
headerBuf.length !== expectedBuf.length ||
!timingSafeEqual(headerBuf, expectedBuf)
) {
return res.status(401).json({ error: 'UNAUTHORIZED' });
}
next();
}
router.post(
'/bridge/users',
bridgeRateLimit,
requireBridgeSecret,
validate(bridgeProvisionUserSchema),
async (req, res, next) => {
try {
const result = await identityBridgeService.provisionFromJsn(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { router as identityBridgeRouter };

View File

@ -0,0 +1,12 @@
import { z } from 'zod';
export const bridgeProvisionUserSchema = z.object({
email: z.string().email(),
displayName: z.string().max(256).optional(),
postalCode: z.string().max(16).optional(),
provenance: z
.enum(['jsn-funnel', 'jsn-donate', 'jsn-organize', 'jsn-organic'])
.optional(),
});
export type BridgeProvisionUserInput = z.infer<typeof bridgeProvisionUserSchema>;

View File

@ -0,0 +1,119 @@
import bcrypt from 'bcryptjs';
import { randomBytes } from 'node:crypto';
import { UserCreatedVia, UserStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { siteSettingsService } from '../settings/settings.service';
import { rocketchatClient } from '../../services/rocketchat.client';
import type { BridgeProvisionUserInput } from './identity-bridge.schemas';
const PROVENANCE_KEY = '_jsnProvenance';
async function generateRandomPassword(): Promise<string> {
return bcrypt.hash(randomBytes(32).toString('hex'), 12);
}
// Derive an RC-suitable username from an email. RC requires lowercase
// alphanumerics + . _ -. Truncate to keep collision rate manageable.
function deriveRcUsername(email: string): string {
const base = email.split('@')[0]?.toLowerCase().replace(/[^a-z0-9._-]/g, '') ?? '';
const cleaned = base.length > 0 ? base : 'jsn-supporter';
return `${cleaned}-${randomBytes(3).toString('hex')}`.slice(0, 32);
}
// Fire-and-forget RC account provisioning after a JSN-bridged cmlite user
// is created. Failures only warn — RC outages must not block the bridge
// response or supporter signup. Stored RC id powers the chat-token bridge.
async function provisionRocketChatAccount(
userId: string,
email: string,
name: string | null,
): Promise<void> {
try {
logger.debug('RC provisioning: starting', { userId, email });
const settings = await siteSettingsService.get();
if (!settings.enableChat) {
logger.debug('RC provisioning: chat disabled, skipping', { userId });
return;
}
const rcUser = await rocketchatClient.createUser({
email,
name: name ?? email.split('@')[0] ?? 'JSN Supporter',
username: deriveRcUsername(email),
// Never used — JSN issues RC tokens via admin-side createUserToken (SSO).
password: randomBytes(24).toString('base64url'),
roles: ['user'],
});
await prisma.user.update({
where: { id: userId },
data: { rocketChatUserId: rcUser._id },
});
logger.info('RC account provisioned for JSN-bridged user', {
userId,
rcUserId: rcUser._id,
});
} catch (err) {
logger.warn('RC provisioning failed (non-blocking)', {
userId,
email,
err: err instanceof Error ? err.message : String(err),
stack: err instanceof Error ? err.stack : undefined,
});
}
}
export const identityBridgeService = {
async provisionFromJsn(input: BridgeProvisionUserInput): Promise<{
userId: string;
created: boolean;
}> {
const email = input.email.trim().toLowerCase();
const existing = await prisma.user.findUnique({
where: { email },
select: { id: true, permissions: true },
});
if (existing) {
if (input.provenance) {
const perms = (existing.permissions as Record<string, unknown> | null) ?? {};
if (perms[PROVENANCE_KEY] == null) {
await prisma.user.update({
where: { id: existing.id },
data: { permissions: { ...perms, [PROVENANCE_KEY]: input.provenance } },
});
}
}
return { userId: existing.id, created: false };
}
const hashedPassword = await generateRandomPassword();
const permissions: Record<string, unknown> = {};
if (input.provenance) permissions[PROVENANCE_KEY] = input.provenance;
const user = await prisma.user.create({
data: {
email,
password: hashedPassword,
name: input.displayName ?? null,
status: UserStatus.ACTIVE,
emailVerified: true,
createdVia: UserCreatedVia.JSN_BRIDGE,
permissions: Object.keys(permissions).length > 0 ? permissions : undefined,
},
select: { id: true },
});
logger.info('Provisioned user from JSN bridge', {
userId: user.id,
email,
provenance: input.provenance ?? null,
});
// Fire-and-forget RC account provisioning. Failures here must not block
// the bridge call (caller is JSN's fire-and-forget /auth/verify path).
void provisionRocketChatAccount(user.id, email, input.displayName ?? null);
return { userId: user.id, created: true };
},
};

View File

@ -0,0 +1,60 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import { timingSafeEqual } from 'node:crypto';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { validate } from '../../middleware/validate';
import { bridgeSocialLinksSchema } from './social-link-bridge.schemas';
import { socialLinkBridgeService } from './social-link-bridge.service';
const router = Router();
const bridgeRateLimit = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) =>
redis.call(command, ...args) as Promise<any>,
prefix: 'rl:bridge-social-links:',
}),
message: {
error: { message: 'Bridge rate limit exceeded', code: 'BRIDGE_RATE_LIMIT' },
},
});
function requireBridgeSecret(req: Request, res: Response, next: NextFunction) {
if (!env.JSN_BRIDGE_SECRET) {
return res.status(503).json({ error: 'BRIDGE_DISABLED' });
}
const header = req.get('authorization') ?? '';
const expected = `Bearer ${env.JSN_BRIDGE_SECRET}`;
const headerBuf = Buffer.from(header);
const expectedBuf = Buffer.from(expected);
if (
headerBuf.length !== expectedBuf.length ||
!timingSafeEqual(headerBuf, expectedBuf)
) {
return res.status(401).json({ error: 'UNAUTHORIZED' });
}
next();
}
router.post(
'/bridge/social-links',
bridgeRateLimit,
requireBridgeSecret,
validate(bridgeSocialLinksSchema),
async (req, res, next) => {
try {
const result = await socialLinkBridgeService.sync(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { router as socialLinkBridgeRouter };

View File

@ -0,0 +1,17 @@
import { z } from 'zod';
import { SocialPlatform } from '@prisma/client';
export const bridgeSocialLinksSchema = z.object({
email: z.string().email(),
links: z
.array(
z.object({
platform: z.nativeEnum(SocialPlatform),
url: z.string().url().max(2048),
displayName: z.string().max(256).optional(),
}),
)
.max(20),
});
export type BridgeSocialLinksInput = z.infer<typeof bridgeSocialLinksSchema>;

View File

@ -0,0 +1,62 @@
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type { BridgeSocialLinksInput } from './social-link-bridge.schemas';
export const socialLinkBridgeService = {
async sync(input: BridgeSocialLinksInput): Promise<{
created: number;
updated: number;
}> {
const email = input.email.trim().toLowerCase();
const user = await prisma.user.findUnique({
where: { email },
select: { id: true },
});
if (!user) {
throw new AppError(404, 'No cmlite user for this email', 'NO_CMLITE_USER');
}
const top = await prisma.userSocialLink.findFirst({
where: { userId: user.id },
orderBy: { position: 'desc' },
select: { position: true },
});
let nextPosition = (top?.position ?? -1) + 1;
let created = 0;
let updated = 0;
for (const link of input.links) {
const existing = await prisma.userSocialLink.findUnique({
where: { userId_platform: { userId: user.id, platform: link.platform } },
});
if (existing) {
await prisma.userSocialLink.update({
where: { id: existing.id },
data: { url: link.url, displayName: link.displayName ?? null },
});
updated++;
} else {
await prisma.userSocialLink.create({
data: {
userId: user.id,
platform: link.platform,
url: link.url,
displayName: link.displayName ?? null,
position: nextPosition++,
},
});
created++;
}
}
logger.info('Synced JSN social links to cmlite', {
userId: user.id,
email,
created,
updated,
});
return { created, updated };
},
};

View File

@ -0,0 +1,60 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import { timingSafeEqual } from 'node:crypto';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { validate } from '../../middleware/validate';
import { bridgeRecordDonationSchema } from './donation-bridge.schemas';
import { donationBridgeService } from './donation-bridge.service';
const router = Router();
const bridgeRateLimit = rateLimit({
windowMs: 60_000,
max: 120,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) =>
redis.call(command, ...args) as Promise<any>,
prefix: 'rl:bridge-payments:',
}),
message: {
error: { message: 'Bridge rate limit exceeded', code: 'BRIDGE_RATE_LIMIT' },
},
});
function requireBridgeSecret(req: Request, res: Response, next: NextFunction) {
if (!env.JSN_BRIDGE_SECRET) {
return res.status(503).json({ error: 'BRIDGE_DISABLED' });
}
const header = req.get('authorization') ?? '';
const expected = `Bearer ${env.JSN_BRIDGE_SECRET}`;
const headerBuf = Buffer.from(header);
const expectedBuf = Buffer.from(expected);
if (
headerBuf.length !== expectedBuf.length ||
!timingSafeEqual(headerBuf, expectedBuf)
) {
return res.status(401).json({ error: 'UNAUTHORIZED' });
}
next();
}
router.post(
'/bridge/record',
bridgeRateLimit,
requireBridgeSecret,
validate(bridgeRecordDonationSchema),
async (req, res, next) => {
try {
const result = await donationBridgeService.recordFromJsn(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
export { router as donationBridgeRouter };

View File

@ -0,0 +1,14 @@
import { z } from 'zod';
export const bridgeRecordDonationSchema = z.object({
email: z.string().email(),
externalId: z.string().min(1).max(128),
amountCents: z.number().int().positive(),
currency: z.literal('CAD'),
isRecurring: z.boolean(),
succeededAt: z.string().datetime(),
stripePaymentIntentId: z.string().min(1).max(256),
stripeSubscriptionId: z.string().max(256).optional(),
});
export type BridgeRecordDonationInput = z.infer<typeof bridgeRecordDonationSchema>;

View File

@ -0,0 +1,85 @@
import { InvoiceStatus, PaymentMethod, PaymentStatus } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler';
import type { BridgeRecordDonationInput } from './donation-bridge.schemas';
export const donationBridgeService = {
async recordFromJsn(input: BridgeRecordDonationInput): Promise<{
paymentId: number;
invoiceId: number;
created: boolean;
}> {
const email = input.email.trim().toLowerCase();
const user = await prisma.user.findUnique({
where: { email },
select: { id: true },
});
if (!user) {
throw new AppError(
404,
'No cmlite user for this email — provision via /api/auth/bridge/users first',
'NO_CMLITE_USER',
);
}
// Idempotency: keyed on stripePaymentIntentId (@unique on Payment).
const existing = await prisma.payment.findUnique({
where: { stripePaymentIntentId: input.stripePaymentIntentId },
select: { id: true, invoiceId: true },
});
if (existing) {
return { paymentId: existing.id, invoiceId: existing.invoiceId, created: false };
}
const succeededAt = new Date(input.succeededAt);
const metadata = {
source: 'jsn',
externalId: input.externalId,
isRecurring: input.isRecurring,
stripeSubscriptionId: input.stripeSubscriptionId ?? null,
};
const result = await prisma.$transaction(async (tx) => {
const invoice = await tx.invoice.create({
data: {
userId: user.id,
amountCAD: input.amountCents,
status: InvoiceStatus.paid,
issuedAt: succeededAt,
paidAt: succeededAt,
dueDate: succeededAt,
description: input.isRecurring
? 'JSN recurring donation'
: 'JSN one-time donation',
type: 'donation',
metadata,
},
select: { id: true },
});
const payment = await tx.payment.create({
data: {
invoiceId: invoice.id,
userId: user.id,
amountCAD: input.amountCents,
method: PaymentMethod.stripe,
status: PaymentStatus.succeeded,
externalId: input.externalId,
stripePaymentIntentId: input.stripePaymentIntentId,
processedAt: succeededAt,
metadata,
},
select: { id: true, invoiceId: true },
});
return payment;
});
logger.info('Recorded JSN donation in cmlite', {
userId: user.id,
paymentId: result.id,
externalId: input.externalId,
});
return { paymentId: result.id, invoiceId: result.invoiceId, created: true };
},
};

View File

@ -0,0 +1,91 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import { timingSafeEqual } from 'node:crypto';
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
import { env } from '../../config/env';
import { validate } from '../../middleware/validate';
import { prisma } from '../../config/database';
import { rocketchatClient } from '../../services/rocketchat.client';
import { siteSettingsService } from '../settings/settings.service';
import { logger } from '../../utils/logger';
const router = Router();
const bridgeRateLimit = rateLimit({
windowMs: 60_000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) =>
redis.call(command, ...args) as Promise<any>,
prefix: 'rl:bridge-rc:',
}),
message: {
error: { message: 'Bridge rate limit exceeded', code: 'BRIDGE_RATE_LIMIT' },
},
});
function requireBridgeSecret(req: Request, res: Response, next: NextFunction) {
if (!env.JSN_BRIDGE_SECRET) {
return res.status(503).json({ error: 'BRIDGE_DISABLED' });
}
const header = req.get('authorization') ?? '';
const expected = `Bearer ${env.JSN_BRIDGE_SECRET}`;
const headerBuf = Buffer.from(header);
const expectedBuf = Buffer.from(expected);
if (
headerBuf.length !== expectedBuf.length ||
!timingSafeEqual(headerBuf, expectedBuf)
) {
return res.status(401).json({ error: 'UNAUTHORIZED' });
}
next();
}
const chatTokenSchema = z.object({ email: z.string().email() });
router.post(
'/bridge/auth',
bridgeRateLimit,
requireBridgeSecret,
validate(chatTokenSchema),
async (req, res, next) => {
try {
const settings = await siteSettingsService.get();
if (!settings.enableChat) {
return res.json({ enabled: false });
}
const email = (req.body.email as string).trim().toLowerCase();
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, rocketChatUserId: true },
});
if (!user) {
return res.status(404).json({ error: 'NO_CMLITE_USER' });
}
if (!user.rocketChatUserId) {
// RC provisioning may have failed earlier (cmlite up before RC was)
// or the user predates the identity bridge. Either way, no chat for
// this user until they're re-provisioned. Fail soft.
logger.info('chat-token bridge: no rocketChatUserId', { userId: user.id });
return res.json({ enabled: false });
}
const token = await rocketchatClient.createUserToken(user.rocketChatUserId);
return res.json({
enabled: true,
rcUrl: env.ROCKETCHAT_URL,
userId: token.userId,
authToken: token.authToken,
});
} catch (err) {
next(err);
}
},
);
export { router as rocketchatBridgeRouter };

View File

@ -15,6 +15,9 @@ import { requireRole } from './middleware/rbac.middleware';
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
import { authRouter } from './modules/auth/auth.routes';
import { giteaSsoRouter } from './modules/auth/gitea-sso.routes';
import { identityBridgeRouter } from './modules/auth/identity-bridge.routes';
import { socialLinkBridgeRouter } from './modules/auth/social-link-bridge.routes';
import { donationBridgeRouter } from './modules/payments/donation-bridge.routes';
import { usersRouter } from './modules/users/users.routes';
import { provisioningRouter } from './modules/users/provisioning.routes';
import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes';
@ -55,6 +58,7 @@ import { eventsPublicRouter } from './modules/map/events/events.routes';
import { pangolinRouter } from './modules/pangolin/pangolin.routes';
import ccpRegistrationRouter from './modules/ccp-registration/ccp-registration.routes';
import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes';
import { rocketchatBridgeRouter } from './modules/rocketchat/rocketchat-bridge.routes';
import { jitsiRouter } from './modules/jitsi/jitsi.routes';
import { rocketchatWebhookService } from './services/rocketchat-webhook.service';
import { gancioClient } from './services/gancio.client';
@ -268,6 +272,9 @@ app.get('/api/metrics/internal', async (req, res) => {
// --- API Routes ---
app.use('/api/auth', authRouter);
app.use('/api/auth', identityBridgeRouter); // JSN bridge: server-to-server user provisioning
app.use('/api/auth', socialLinkBridgeRouter); // JSN bridge: social handle sync
app.use('/api/payments', donationBridgeRouter); // JSN bridge: donation mirror
app.use('/api/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request)
app.use('/api/users', usersRouter);
app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)
@ -323,6 +330,7 @@ app.use('/api/settings', siteSettingsRouter); // Site settings (pub
app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
app.use('/api/ccp-registration', ccpRegistrationRouter); // CCP remote management registration (SUPER_ADMIN)
app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required)
app.use('/api/rocketchat', rocketchatBridgeRouter); // JSN bridge: server-to-server chat-token issuance
app.use('/api/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required)
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
app.use('/api/upgrade', upgradeRouter); // System upgrade management (SUPER_ADMIN)