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:
parent
5331cdcc67
commit
5a2c54dabf
@ -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");
|
||||||
@ -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;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "UserCreatedVia" ADD VALUE 'JSN_BRIDGE';
|
||||||
@ -42,6 +42,7 @@ enum UserCreatedVia {
|
|||||||
STANDARD
|
STANDARD
|
||||||
SELF_REGISTRATION
|
SELF_REGISTRATION
|
||||||
QUICK_JOIN_INVITE
|
QUICK_JOIN_INVITE
|
||||||
|
JSN_BRIDGE
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@ -60,6 +61,10 @@ model User {
|
|||||||
expireDays Int?
|
expireDays Int?
|
||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
emailVerified Boolean @default(false)
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@ -1560,6 +1565,10 @@ enum SocialPlatform {
|
|||||||
snapchat
|
snapchat
|
||||||
linktree
|
linktree
|
||||||
custom
|
custom
|
||||||
|
facebook
|
||||||
|
threads
|
||||||
|
bluesky
|
||||||
|
linkedin
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UserUploadStatus {
|
enum UserUploadStatus {
|
||||||
@ -2620,6 +2629,7 @@ model UserSocialLink {
|
|||||||
// Relations
|
// Relations
|
||||||
user User @relation("UserSocialLinks", fields: [userId], references: [id])
|
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], map: "idx_user_social_links_user")
|
||||||
@@index([userId, position], map: "idx_user_social_links_position")
|
@@index([userId, position], map: "idx_user_social_links_position")
|
||||||
@@map("user_social_links")
|
@@map("user_social_links")
|
||||||
|
|||||||
@ -276,6 +276,11 @@ const envSchema = z.object({
|
|||||||
MAXMIND_ACCOUNT_ID: z.string().default(''),
|
MAXMIND_ACCOUNT_ID: z.string().default(''),
|
||||||
MAXMIND_LICENSE_KEY: z.string().default(''),
|
MAXMIND_LICENSE_KEY: z.string().default(''),
|
||||||
GEOIP_DB_PATH: z.string().default('/data/geoip/GeoLite2-City.mmdb'),
|
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>;
|
export type Env = z.infer<typeof envSchema>;
|
||||||
|
|||||||
60
api/src/modules/auth/identity-bridge.routes.ts
Normal file
60
api/src/modules/auth/identity-bridge.routes.ts
Normal 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 };
|
||||||
12
api/src/modules/auth/identity-bridge.schemas.ts
Normal file
12
api/src/modules/auth/identity-bridge.schemas.ts
Normal 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>;
|
||||||
119
api/src/modules/auth/identity-bridge.service.ts
Normal file
119
api/src/modules/auth/identity-bridge.service.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
60
api/src/modules/auth/social-link-bridge.routes.ts
Normal file
60
api/src/modules/auth/social-link-bridge.routes.ts
Normal 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 };
|
||||||
17
api/src/modules/auth/social-link-bridge.schemas.ts
Normal file
17
api/src/modules/auth/social-link-bridge.schemas.ts
Normal 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>;
|
||||||
62
api/src/modules/auth/social-link-bridge.service.ts
Normal file
62
api/src/modules/auth/social-link-bridge.service.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
60
api/src/modules/payments/donation-bridge.routes.ts
Normal file
60
api/src/modules/payments/donation-bridge.routes.ts
Normal 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 };
|
||||||
14
api/src/modules/payments/donation-bridge.schemas.ts
Normal file
14
api/src/modules/payments/donation-bridge.schemas.ts
Normal 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>;
|
||||||
85
api/src/modules/payments/donation-bridge.service.ts
Normal file
85
api/src/modules/payments/donation-bridge.service.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
91
api/src/modules/rocketchat/rocketchat-bridge.routes.ts
Normal file
91
api/src/modules/rocketchat/rocketchat-bridge.routes.ts
Normal 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 };
|
||||||
@ -15,6 +15,9 @@ import { requireRole } from './middleware/rbac.middleware';
|
|||||||
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
||||||
import { authRouter } from './modules/auth/auth.routes';
|
import { authRouter } from './modules/auth/auth.routes';
|
||||||
import { giteaSsoRouter } from './modules/auth/gitea-sso.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 { usersRouter } from './modules/users/users.routes';
|
||||||
import { provisioningRouter } from './modules/users/provisioning.routes';
|
import { provisioningRouter } from './modules/users/provisioning.routes';
|
||||||
import { campaignsRouter } from './modules/influence/campaigns/campaigns.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 { pangolinRouter } from './modules/pangolin/pangolin.routes';
|
||||||
import ccpRegistrationRouter from './modules/ccp-registration/ccp-registration.routes';
|
import ccpRegistrationRouter from './modules/ccp-registration/ccp-registration.routes';
|
||||||
import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes';
|
import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes';
|
||||||
|
import { rocketchatBridgeRouter } from './modules/rocketchat/rocketchat-bridge.routes';
|
||||||
import { jitsiRouter } from './modules/jitsi/jitsi.routes';
|
import { jitsiRouter } from './modules/jitsi/jitsi.routes';
|
||||||
import { rocketchatWebhookService } from './services/rocketchat-webhook.service';
|
import { rocketchatWebhookService } from './services/rocketchat-webhook.service';
|
||||||
import { gancioClient } from './services/gancio.client';
|
import { gancioClient } from './services/gancio.client';
|
||||||
@ -268,6 +272,9 @@ app.get('/api/metrics/internal', async (req, res) => {
|
|||||||
|
|
||||||
// --- API Routes ---
|
// --- API Routes ---
|
||||||
app.use('/api/auth', authRouter);
|
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/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request)
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)
|
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/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
|
||||||
app.use('/api/ccp-registration', ccpRegistrationRouter); // CCP remote management registration (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', 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/jitsi', jitsiRouter); // Jitsi Meet JWT + status (auth required)
|
||||||
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
|
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
|
||||||
app.use('/api/upgrade', upgradeRouter); // System upgrade management (SUPER_ADMIN)
|
app.use('/api/upgrade', upgradeRouter); // System upgrade management (SUPER_ADMIN)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user