diff --git a/api/prisma/migrations/20260525182802_extend_social_platforms_and_unique/migration.sql b/api/prisma/migrations/20260525182802_extend_social_platforms_and_unique/migration.sql new file mode 100644 index 0000000..5bd4bc3 --- /dev/null +++ b/api/prisma/migrations/20260525182802_extend_social_platforms_and_unique/migration.sql @@ -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"); diff --git a/api/prisma/migrations/20260525183536_add_user_rocketchat_user_id/migration.sql b/api/prisma/migrations/20260525183536_add_user_rocketchat_user_id/migration.sql new file mode 100644 index 0000000..7318826 --- /dev/null +++ b/api/prisma/migrations/20260525183536_add_user_rocketchat_user_id/migration.sql @@ -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; diff --git a/api/prisma/migrations/20260526001901_add_jsn_bridge_to_user_created_via/migration.sql b/api/prisma/migrations/20260526001901_add_jsn_bridge_to_user_created_via/migration.sql new file mode 100644 index 0000000..9840c7e --- /dev/null +++ b/api/prisma/migrations/20260526001901_add_jsn_bridge_to_user_created_via/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "UserCreatedVia" ADD VALUE 'JSN_BRIDGE'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 542a7a0..1b5a458 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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") diff --git a/api/src/config/env.ts b/api/src/config/env.ts index 86275d8..2c9eb1a 100644 --- a/api/src/config/env.ts +++ b/api/src/config/env.ts @@ -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; diff --git a/api/src/modules/auth/identity-bridge.routes.ts b/api/src/modules/auth/identity-bridge.routes.ts new file mode 100644 index 0000000..78dabde --- /dev/null +++ b/api/src/modules/auth/identity-bridge.routes.ts @@ -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, + 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 }; diff --git a/api/src/modules/auth/identity-bridge.schemas.ts b/api/src/modules/auth/identity-bridge.schemas.ts new file mode 100644 index 0000000..637df0b --- /dev/null +++ b/api/src/modules/auth/identity-bridge.schemas.ts @@ -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; diff --git a/api/src/modules/auth/identity-bridge.service.ts b/api/src/modules/auth/identity-bridge.service.ts new file mode 100644 index 0000000..881f811 --- /dev/null +++ b/api/src/modules/auth/identity-bridge.service.ts @@ -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 { + 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 { + 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 | 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 = {}; + 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 }; + }, +}; diff --git a/api/src/modules/auth/social-link-bridge.routes.ts b/api/src/modules/auth/social-link-bridge.routes.ts new file mode 100644 index 0000000..6682c54 --- /dev/null +++ b/api/src/modules/auth/social-link-bridge.routes.ts @@ -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, + 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 }; diff --git a/api/src/modules/auth/social-link-bridge.schemas.ts b/api/src/modules/auth/social-link-bridge.schemas.ts new file mode 100644 index 0000000..7422b98 --- /dev/null +++ b/api/src/modules/auth/social-link-bridge.schemas.ts @@ -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; diff --git a/api/src/modules/auth/social-link-bridge.service.ts b/api/src/modules/auth/social-link-bridge.service.ts new file mode 100644 index 0000000..46c66e8 --- /dev/null +++ b/api/src/modules/auth/social-link-bridge.service.ts @@ -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 }; + }, +}; diff --git a/api/src/modules/payments/donation-bridge.routes.ts b/api/src/modules/payments/donation-bridge.routes.ts new file mode 100644 index 0000000..163189c --- /dev/null +++ b/api/src/modules/payments/donation-bridge.routes.ts @@ -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, + 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 }; diff --git a/api/src/modules/payments/donation-bridge.schemas.ts b/api/src/modules/payments/donation-bridge.schemas.ts new file mode 100644 index 0000000..6864466 --- /dev/null +++ b/api/src/modules/payments/donation-bridge.schemas.ts @@ -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; diff --git a/api/src/modules/payments/donation-bridge.service.ts b/api/src/modules/payments/donation-bridge.service.ts new file mode 100644 index 0000000..a85db18 --- /dev/null +++ b/api/src/modules/payments/donation-bridge.service.ts @@ -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 }; + }, +}; diff --git a/api/src/modules/rocketchat/rocketchat-bridge.routes.ts b/api/src/modules/rocketchat/rocketchat-bridge.routes.ts new file mode 100644 index 0000000..d680d70 --- /dev/null +++ b/api/src/modules/rocketchat/rocketchat-bridge.routes.ts @@ -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, + 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 }; diff --git a/api/src/server.ts b/api/src/server.ts index d9b6834..bc21022 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -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)