From 00bd1250ee056406e626e2bcddd2f5d98b13c4e1 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 31 Jul 2026 06:36:03 +0000 Subject: [PATCH] feat: WIP element Chat intergration --- .github/workflows/deploy.yml | 8 +- apps/edr-freight-api/.env.example | 19 ++ apps/edr-freight-api/src/app.module.ts | 6 + .../src/common/booking-guards.ts | 2 + .../edr-freight-api/src/config/chat.config.ts | 59 ++++ .../src/modules/chat/chat-bridge.service.ts | 73 +++++ .../modules/chat/chat-provisioning.service.ts | 192 +++++++++++++ .../src/modules/chat/chat-sso.service.ts | 59 ++++ .../src/modules/chat/chat.controller.ts | 35 +++ .../src/modules/chat/chat.module.ts | 16 ++ .../src/modules/chat/matrix.client.ts | 263 ++++++++++++++++++ .../notification-inbox.module.ts | 3 + .../notification-inbox.service.ts | 12 +- .../src/seed/edr-freight.seed.ts | 1 + .../src/seed/freight-permissions.registry.ts | 11 + apps/edr-freight-web/backoffice/src/App.tsx | 9 + .../src/components/layout/route-meta.ts | 7 + .../components/layout/sidebar-sections.tsx | 7 + .../backoffice/src/features/chat/chatApi.ts | 13 + .../src/features/chat/useChatSso.ts | 19 ++ .../backoffice/src/lib/permissions.ts | 4 + .../src/pages/chat/ChatLaunchPage.tsx | 62 +++++ docker-compose.yaml | 28 ++ infrastructure/matrix/element/Dockerfile | 9 + infrastructure/matrix/element/config.json | 17 ++ infrastructure/matrix/element/sso.html | 36 +++ infrastructure/matrix/synapse/Dockerfile | 15 + .../matrix/synapse/docker-entrypoint.sh | 20 ++ .../matrix/synapse/homeserver.yaml.tmpl | 95 +++++++ infrastructure/matrix/synapse/log.config | 25 ++ scripts/deploy/sync-env-from-server.sh | 5 + 31 files changed, 1128 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/config/chat.config.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat-sso.service.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat.controller.ts create mode 100644 apps/edr-freight-api/src/modules/chat/chat.module.ts create mode 100644 apps/edr-freight-api/src/modules/chat/matrix.client.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts create mode 100644 apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx create mode 100644 infrastructure/matrix/element/Dockerfile create mode 100644 infrastructure/matrix/element/config.json create mode 100644 infrastructure/matrix/element/sso.html create mode 100644 infrastructure/matrix/synapse/Dockerfile create mode 100644 infrastructure/matrix/synapse/docker-entrypoint.sh create mode 100644 infrastructure/matrix/synapse/homeserver.yaml.tmpl create mode 100644 infrastructure/matrix/synapse/log.config diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b1c797dc6..b6a7fdb69 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -43,6 +43,8 @@ jobs: "passenger-portal" "passenger-backoffice" "payment-api" + "synapse" + "element-web" ) if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then @@ -84,6 +86,10 @@ jobs: echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") + # synapse / element-web have no per-service filter line: their only + # source is infrastructure/matrix/, already caught by GLOBAL_PATTERN + # above (which redeploys every service), so a dedicated line here + # would never fire. SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u)) @@ -119,7 +125,7 @@ jobs: - name: Resolve project and build env file run: | case "${{ matrix.service }}" in - freight-api|freight-portal|freight-backoffice|gps-tracker) + freight-api|freight-portal|freight-backoffice|gps-tracker|synapse|element-web) echo "PROJECT=edr-freight" >> "$GITHUB_ENV" echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" ;; diff --git a/apps/edr-freight-api/.env.example b/apps/edr-freight-api/.env.example index d2359dd97..930c8f1ca 100644 --- a/apps/edr-freight-api/.env.example +++ b/apps/edr-freight-api/.env.example @@ -219,3 +219,22 @@ EIMS_AUTO_SUBMIT=false EIMS_AUTO_SUBMIT_CRON=0 */5 * * * * # MoR rejects documents older than 3 days; the sweep will not attempt those. EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 +# ── Internal chat (Matrix/Element) ────────────────────────────────────────── +# Disabled by default; /chat/sso and the nightly room/membership reconcile are +# no-ops until enabled. See infrastructure/matrix/. +MATRIX_ENABLED=false +# Synapse URL reachable from this container (docker-compose service DNS in +# prod, e.g. http://synapse:8008 — NOT the public https://matrix.edr.et). +MATRIX_BASE_URL=http://localhost:8008 +# Synapse's own public_baseurl — what Element itself is configured to call. +# Only used to seed the sso.html handoff page's localStorage. +MATRIX_PUBLIC_BASE_URL=https://matrix.edr.et +MATRIX_CHAT_WEB_URL=https://chat.edr.et +MATRIX_SERVER_NAME=matrix.edr.et +# Must exactly match infrastructure/matrix/synapse/.env's MATRIX_JWT_SECRET — +# this is the whole trust boundary for the SSO handoff. +MATRIX_JWT_SECRET= +# access_token of a Synapse server-admin account. Bootstrap it once via +# infrastructure/matrix/synapse's MATRIX_REGISTRATION_SHARED_SECRET (see that +# file's comments) — this app never touches the shared secret itself. +MATRIX_ADMIN_TOKEN= diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..405612ddf 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -23,6 +23,7 @@ import telebirrConfig from "./config/telebirr.config"; import rabbitmqConfig from "./config/rabbitmq.config"; import faydaConfig from "./config/fayda.config"; import eimsConfig from "./config/eims.config"; +import chatConfig from "./config/chat.config"; import { BookingsModule } from "./modules/bookings/bookings.module"; import { ContractsModule } from "./modules/contracts/contracts.module"; @@ -116,7 +117,10 @@ import { InterchangeDocumentsModule } from "./modules/interchange-documents/inte import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; import { AuditModule } from "./modules/audit/audit.module"; +// dev replaced the local LoggerMiddleware with the shared RequestLogMiddleware +// and deleted ./logger.middleware, so the branch's import is dropped here. import { RequestLogMiddleware } from "@edr/api-common"; +import { ChatModule } from "./modules/chat/chat.module"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -135,6 +139,7 @@ if (!process.env.APPLICATION_NAME) { rabbitmqConfig, faydaConfig, eimsConfig, + chatConfig, ], }), ScheduleModule.forRoot(), @@ -252,6 +257,7 @@ if (!process.env.APPLICATION_NAME) { FleetHistoryModule, AiModule, AuditModule, + ChatModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 36ed7ae74..f9eab4d39 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) => export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); +export const ChatSync = () => BookingStaff(FREIGHT_PERMS.chat.sync); + /** * The document-review countdown in the backoffice header. Its own permission so * it can be granted to exactly the position types that decide operation diff --git a/apps/edr-freight-api/src/config/chat.config.ts b/apps/edr-freight-api/src/config/chat.config.ts new file mode 100644 index 000000000..ce20b610b --- /dev/null +++ b/apps/edr-freight-api/src/config/chat.config.ts @@ -0,0 +1,59 @@ +import { registerAs } from '@nestjs/config'; + +export interface ChatConfig { + enabled: boolean; + /** Synapse base URL reachable from this container (client + admin APIs). */ + baseUrl: string; + /** Synapse's public_baseurl — what Element itself is configured to call. Only + * used to seed the sso.html handoff; server-to-server calls use {@link baseUrl}. */ + publicBaseUrl: string; + /** Public Element Web origin — the SSO handoff link points here. */ + webUrl: string; + /** Matrix server_name — the `:domain` half of every MXID. */ + serverName: string; + /** HS256 secret. Must exactly match Synapse's jwt_config.secret. */ + jwtSecret: string; + /** Bearer token for a Synapse server admin account (room/user provisioning). */ + adminToken: string; +} + +const REQUIRED_VARS = [ + 'MATRIX_BASE_URL', + 'MATRIX_PUBLIC_BASE_URL', + 'MATRIX_CHAT_WEB_URL', + 'MATRIX_SERVER_NAME', + 'MATRIX_JWT_SECRET', + 'MATRIX_ADMIN_TOKEN', +] as const; + +export default registerAs('chat', (): ChatConfig => { + const enabled = (process.env.MATRIX_ENABLED ?? 'false').toLowerCase() === 'true'; + if (!enabled) { + return { + enabled: false, + baseUrl: '', + publicBaseUrl: '', + webUrl: '', + serverName: '', + jwtSecret: '', + adminToken: '', + }; + } + + const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + if (missing.length > 0) { + throw new Error( + `Internal chat is enabled (MATRIX_ENABLED=true) but the following env vars are missing: ${missing.join(', ')}`, + ); + } + + return { + enabled: true, + baseUrl: process.env.MATRIX_BASE_URL!.replace(/\/$/, ''), + publicBaseUrl: process.env.MATRIX_PUBLIC_BASE_URL!.replace(/\/$/, ''), + webUrl: process.env.MATRIX_CHAT_WEB_URL!.replace(/\/$/, ''), + serverName: process.env.MATRIX_SERVER_NAME!, + jwtSecret: process.env.MATRIX_JWT_SECRET!, + adminToken: process.env.MATRIX_ADMIN_TOKEN!, + }; +}); diff --git a/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts new file mode 100644 index 000000000..75bbe9a29 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts @@ -0,0 +1,73 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import { NotificationType, type NotifyInput } from '@edr/types'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +const FALLBACK_ROOM = { alias: 'freight-alerts', name: 'Freight Alerts' }; + +/** + * Best-effort per-type routing to an existing dept room. Anything not listed + * (including GENERIC) falls through to #freight-alerts — safer than a wrong + * guess at which department a type belongs to. Extend as real usage shows + * which types actually want a dept room instead of the shared feed. + * + * `name` matters only if this bridge is the very first thing to touch that + * alias (normally the nightly/on-demand reconcile creates dept rooms first, + * with the position's real name) — ensureRoom never renames an existing + * room, so this must match what ChatProvisioningService would have used. + */ +const ROOM_FOR_TYPE: Partial> = { + [NotificationType.REQUEST_SUBMITTED]: { alias: 'dept-operation', name: 'Operation' }, + [NotificationType.CLEARANCE_REVIEW]: { alias: 'dept-operation', name: 'Operation' }, +}; + +/** + * Mirrors BACKOFFICE-audience notifications into chat so staff see them + * without having the inbox open. Hooked once into + * NotificationInboxService.notify() — every one of that service's ~20 + * callers gets this for free. + * + * Gated on BACKOFFICE only: notify() also serves PORTAL (customer) + * notifications, which must never land in an internal staff room. + */ +@Injectable() +export class ChatBridgeService { + private readonly logger = new Logger(ChatBridgeService.name); + + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async bridge(input: NotifyInput): Promise { + if (!this.config.enabled) return; + + try { + const room = ROOM_FOR_TYPE[input.type] ?? FALLBACK_ROOM; + const roomId = await this.matrix.ensureRoom(room.alias, room.name); + const body = input.link ? `${input.title}\n${input.body}\n${input.link}` : `${input.title}\n${input.body}`; + const html = `${escapeHtml(input.title)}
${escapeHtml(input.body)}${ + input.link ? `
${escapeHtml(input.link)}` : '' + }`; + await this.matrix.sendMessage(roomId, body, html); + } catch (err) { + // Same contract as NotificationInboxService.notify(): a chat-bridge + // failure must never break or roll back the notification that + // triggered it. + this.logger.error( + `Chat bridge failed for ${input.type}: ${(err as Error).message}`, + ); + } + } +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts new file mode 100644 index 000000000..7b748e576 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource } from 'typeorm'; + +import { MatrixClient } from './matrix.client'; + +/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat + * (one org, one unit), so this is the entire scope of what gets provisioned. */ +const ORG_KEY = 'edr_freight'; +const UNIT_KEY = 'edr_freight_app'; + +const SPACE_ALIAS = 'edr-freight'; +const GENERAL_ALIAS = 'general'; + +interface PositionHolder { + positionKey: string; + positionName: string; + userId: string; + userName: string; +} + +export interface ReconcileResult { + rooms: number; + joined: number; + kicked: number; + deactivated: number; +} + +/** + * Keeps Matrix rooms and their membership in sync with IAM's unit/position + * tree. There is no local hook on "employee position changed" — IAM writes + * happen inside the vendored @tria-plc/iamapi-common package — so this is a + * reconcile loop, not an event handler: nightly, plus on-demand via + * POST /chat/sync. + * + * Room identity is a deterministic alias (#dept-), not a stored + * mapping table — resolved via the directory API, created on first miss. + * Room membership is diffed against Matrix's own joined_members, not a local + * snapshot — so a user removed from IAM disappears from chat on the very + * next reconcile, with no extra state for this service to own. + */ +@Injectable() +export class ChatProvisioningService { + private readonly logger = new Logger(ChatProvisioningService.name); + + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly matrix: MatrixClient, + ) {} + + @Cron(CronExpression.EVERY_DAY_AT_3AM, { name: 'chat-provisioning-reconcile' }) + async scheduledReconcile(): Promise { + try { + const result = await this.reconcile(); + this.logger.log( + `Chat reconcile: ${result.rooms} room(s), ${result.joined} joined, ` + + `${result.kicked} kicked, ${result.deactivated} deactivated`, + ); + } catch (err) { + // Never throws into the scheduler — chat provisioning must not be able + // to take down anything else on the cron registry. + this.logger.error( + `Chat reconcile failed: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + + private async currentHolders(): Promise { + return this.dataSource.query( + `SELECT p.key AS "positionKey", + COALESCE(p.name->>'en', p.key) AS "positionName", + e.user_id AS "userId", + COALESCE(iu.name->>'en', iu.username, iu.email) AS "userName" + FROM iam.employee_positions ep + JOIN iam.employees e ON e.id = ep.employee_id + JOIN iam.positions p ON p.id = ep.position_id + JOIN iam.units u ON u.id = p.unit_id + JOIN iam.organizations o ON o.id = u.organization_id + JOIN iam.users iu ON iu.id = e.user_id + WHERE ep.is_current = true + AND e.is_current = true + AND o.key = $1 + AND u.key = $2`, + [ORG_KEY, UNIT_KEY], + ); + } + + /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ + private async syncMembership( + roomId: string, + desiredUserIds: Set, + botMxid: string, + ): Promise<{ joined: number; kicked: string[] }> { + const current = await this.matrix.joinedMembers(roomId); + const currentSet = new Set(current.filter((id) => id !== botMxid)); + + let joined = 0; + for (const userId of desiredUserIds) { + if (!currentSet.has(userId)) { + await this.matrix.forceJoin(roomId, userId); + joined += 1; + } + } + + const kicked: string[] = []; + for (const userId of currentSet) { + if (!desiredUserIds.has(userId)) { + await this.matrix.kick(roomId, userId, 'No longer assigned to this room'); + kicked.push(userId); + } + } + + return { joined, kicked }; + } + + async reconcile(): Promise { + const holders = await this.currentHolders(); + const botMxid = await this.matrix.whoami(); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + + const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); + + // Accounts are otherwise only created lazily on first JWT login (see + // ChatSsoService) — force-joining someone who has never clicked "Chat" + // yet 404s ("User not found") without this. + const seenUserIds = new Set(); + for (const h of holders) { + const mxid = this.matrix.mxid(h.userId); + if (seenUserIds.has(mxid)) continue; + seenUserIds.add(mxid); + await this.matrix.ensureUser(mxid, h.userName); + } + + let rooms = 2; // space + general + let joined = 0; + let kicked = 0; + // A user kicked from anything while holding zero current positions + // anywhere in the unit (allUserIds spans every position) is a full + // leaver, not just moved between positions — deactivate their account. + const kickedUserIds = new Set(); + + const generalDiff = await this.syncMembership(generalRoomId, allUserIds, botMxid); + joined += generalDiff.joined; + kicked += generalDiff.kicked.length; + generalDiff.kicked.forEach((uid) => kickedUserIds.add(uid)); + + const byPosition = new Map }>(); + for (const h of holders) { + const entry = byPosition.get(h.positionKey) ?? { + name: h.positionName, + userIds: new Set(), + }; + entry.userIds.add(this.matrix.mxid(h.userId)); + byPosition.set(h.positionKey, entry); + } + + for (const [positionKey, { name, userIds }] of byPosition) { + const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, { + parentSpaceId: spaceId, + }); + rooms += 1; + + const diff = await this.syncMembership(roomId, userIds, botMxid); + joined += diff.joined; + kicked += diff.kicked.length; + diff.kicked.forEach((uid) => kickedUserIds.add(uid)); + } + + let deactivated = 0; + for (const userId of kickedUserIds) { + if (allUserIds.has(userId)) continue; // moved position, still current elsewhere + try { + await this.matrix.deactivateUser(userId); + deactivated += 1; + } catch (err) { + this.logger.warn( + `Failed to deactivate departed user ${userId}: ${(err as Error).message}`, + ); + } + } + + return { rooms, joined, kicked, deactivated }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts new file mode 100644 index 000000000..8f9357e10 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; +import { SignJWT } from 'jose'; + +import chatConfig from '../../config/chat.config'; +import { MatrixClient } from './matrix.client'; + +/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */ +const JWT_TTL_SECONDS = 60; + +function displayName(user: TCurrentUser): string { + return ( + user.name?.en || + Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) || + user.username || + user.email + ); +} + +/** + * The SSO handoff: turn an already-authenticated freight session into a + * one-click Element sign-in link, with no second password anywhere. + * + * 1. Sign a short-lived JWT asserting this user's id (Synapse's + * org.matrix.login.jwt auto-registers the account on first use). + * 2. Trade that JWT for a real Matrix access token. + * 3. Trade the access token for a one-shot login_token. + * 4. Hand the caller a link to Element's sso.html shim, which seeds + * localStorage and forwards the token into Element's own login flow. + */ +@Injectable() +export class ChatSsoService { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + ) {} + + async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { + const secret = new TextEncoder().encode(this.config.jwtSecret); + const jwt = await new SignJWT({ name: displayName(user) }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(user.id) + .setIssuer('edr-freight-api') + .setAudience('matrix') + .setIssuedAt() + .setExpirationTime(`${JWT_TTL_SECONDS}s`) + .sign(secret); + + const { access_token } = await this.matrix.loginWithJwt(jwt); + const { login_token } = await this.matrix.getLoginToken(access_token); + + const url = new URL(`${this.config.webUrl}/sso.html`); + url.searchParams.set('t', login_token); + url.searchParams.set('hs', this.config.publicBaseUrl); + return { url: url.toString() }; + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.controller.ts b/apps/edr-freight-api/src/modules/chat/chat.controller.ts new file mode 100644 index 000000000..0ecca04c0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.controller.ts @@ -0,0 +1,35 @@ +import { Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator'; +import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; + +import { ChatSync } from '../../common/booking-guards'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; + +@ApiTags('chat') +@Controller('chat') +@ApiBearerAuth() +export class ChatController { + constructor( + private readonly sso: ChatSsoService, + private readonly provisioning: ChatProvisioningService, + ) {} + + @Get('sso') + @UseGuards(JwtGuard) + @ApiOperation({ summary: 'One-click sign-in link into EDR internal chat' }) + getSso(@CurrentUser() user: TCurrentUser) { + return this.sso.getSsoUrl(user); + } + + @Post('sync') + @ChatSync() + @ApiOperation({ + summary: 'Re-run the chat room/membership reconcile immediately (normally nightly)', + }) + sync() { + return this.provisioning.reconcile(); + } +} diff --git a/apps/edr-freight-api/src/modules/chat/chat.module.ts b/apps/edr-freight-api/src/modules/chat/chat.module.ts new file mode 100644 index 000000000..8df827339 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; + +import { ChatBridgeService } from './chat-bridge.service'; +import { ChatController } from './chat.controller'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { ChatSsoService } from './chat-sso.service'; +import { MatrixClient } from './matrix.client'; + +@Module({ + controllers: [ChatController], + providers: [MatrixClient, ChatSsoService, ChatProvisioningService, ChatBridgeService], + // ChatBridgeService: consumed by NotificationInboxModule to mirror + // BACKOFFICE notifications into chat — see notification-inbox.module.ts. + exports: [ChatBridgeService], +}) +export class ChatModule {} diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts new file mode 100644 index 000000000..01dafb6de --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -0,0 +1,263 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { ConfigType } from '@nestjs/config'; + +import chatConfig from '../../config/chat.config'; + +/** + * Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API + * calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a + * browser/Element concern; the server side only ever provisions rooms/users + * and posts bot messages, so a fetch wrapper is the whole job. + * + * All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That + * same account also posts the notification-bridge messages (see + * ChatBridgeService) — one bot/admin account covers both jobs, no separate + * bot user needed. + */ +@Injectable() +export class MatrixClient { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + ) {} + + /** `@:` — the one place this format is assembled. */ + mxid(localpart: string): string { + return `@${localpart}:${this.config.serverName}`; + } + + get serverName(): string { + return this.config.serverName; + } + + private async request( + method: string, + path: string, + body?: unknown, + token: string = this.config.adminToken, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; + } + + /** No auth — only /login accepts a bare JWT with nothing else on the request. */ + private async publicRequest( + method: string, + path: string, + body: unknown, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** 404 → null. Every other non-2xx still throws via {@link request}. */ + private async requestOrNull( + method: string, + path: string, + token?: string, + ): Promise { + const res = await fetch(`${this.config.baseUrl}${path}`, { + method, + headers: { Authorization: `Bearer ${token ?? this.config.adminToken}` }, + }); + if (res.status === 404) return null; + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Matrix ${method} ${path} -> ${res.status}: ${text.slice(0, 500)}`, + ); + } + return (await res.json()) as T; + } + + /** Sign an already-authenticated freight session into a Matrix session. */ + loginWithJwt( + jwt: string, + ): Promise<{ access_token: string; user_id: string; device_id: string }> { + return this.publicRequest('POST', '/_matrix/client/v3/login', { + type: 'org.matrix.login.jwt', + token: jwt, + initial_device_display_name: 'EDR Backoffice', + }); + } + + /** The account behind MATRIX_ADMIN_TOKEN — used to exclude the bot itself from membership reconciliation. */ + async whoami(): Promise { + const res = await this.request<{ user_id: string }>( + 'GET', + '/_matrix/client/v3/account/whoami', + ); + return res.user_id; + } + + /** Currently-joined user ids for a room (not full member-event state). */ + async joinedMembers(roomId: string): Promise { + const res = await this.request<{ joined: Record }>( + 'GET', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/joined_members`, + ); + return Object.keys(res.joined); + } + + /** Exchange a fresh access token for a one-shot login_token (5 min TTL). */ + getLoginToken(accessToken: string): Promise<{ login_token: string }> { + return this.request( + 'POST', + '/_matrix/client/v1/login/get_token', + {}, + accessToken, + ); + } + + /** null when the alias doesn't resolve to a room yet. */ + resolveAlias(alias: string): Promise<{ room_id: string } | null> { + return this.requestOrNull( + 'GET', + `/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`, + ); + } + + createRoom(input: { + alias: string; + name: string; + topic?: string; + isSpace?: boolean; + parentSpaceId?: string; + }): Promise<{ room_id: string }> { + return this.request('POST', '/_matrix/client/v3/createRoom', { + room_alias_name: input.alias, + name: input.name, + topic: input.topic, + preset: 'private_chat', + creation_content: input.isSpace ? { type: 'm.space' } : undefined, + initial_state: input.parentSpaceId + ? [ + { + type: 'm.space.parent', + state_key: input.parentSpaceId, + content: { via: [this.config.serverName], canonical: true }, + }, + ] + : undefined, + }); + } + + addToSpace(spaceId: string, childRoomId: string): Promise { + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(spaceId)}/state/m.space.child/${encodeURIComponent(childRoomId)}`, + { via: [this.config.serverName] }, + ); + } + + /** + * Get-or-create by alias — the room identity scheme this whole module + * relies on instead of a local id-mapping table. Idempotent: safe to call + * on every reconcile run and every bridged notification alike. + */ + async ensureRoom( + alias: string, + name: string, + opts: { isSpace?: boolean; parentSpaceId?: string } = {}, + ): Promise { + const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); + if (existing) return existing.room_id; + + const { room_id } = await this.createRoom({ + alias, + name, + isSpace: opts.isSpace, + parentSpaceId: opts.parentSpaceId, + }); + if (opts.parentSpaceId) { + await this.addToSpace(opts.parentSpaceId, room_id); + } + return room_id; + } + + /** + * Create the account if absent (no password — this deployment is JWT-SSO + * only), or no-op if it already exists. Needed before force-joining a + * position holder who has never clicked "Chat": accounts are otherwise + * only created lazily on first JWT login, and the admin join API 404s + * ("User not found") on an account that doesn't exist yet. + */ + async ensureUser(userId: string, displayName?: string): Promise { + const existing = await this.requestOrNull<{ name: string }>( + 'GET', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + ); + if (existing) return; + await this.request( + 'PUT', + `/_synapse/admin/v2/users/${encodeURIComponent(userId)}`, + displayName ? { displayname: displayName } : {}, + ); + } + + /** Server-admin force-join — no invite to accept, works even mid-outage for the invitee. */ + forceJoin(roomIdOrAlias: string, userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/join/${encodeURIComponent(roomIdOrAlias)}`, + { user_id: userId }, + ); + } + + kick(roomId: string, userId: string, reason: string): Promise { + return this.request( + 'POST', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/kick`, + { user_id: userId, reason }, + ); + } + + /** Deactivating (rather than just kicking) a leaver's account revokes all their sessions. */ + deactivateUser(userId: string): Promise { + return this.request( + 'POST', + `/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`, + { erase: false }, + ); + } + + sendMessage(roomId: string, body: string, formattedBody?: string): Promise { + const txnId = `edr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + return this.request( + 'PUT', + `/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`, + formattedBody + ? { + msgtype: 'm.text', + body, + format: 'org.matrix.custom.html', + formatted_body: formattedBody, + } + : { msgtype: 'm.text', body }, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts index e9f3af958..aa363ad3d 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.module.ts @@ -4,6 +4,7 @@ import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entit import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { BackofficeModule } from "../backoffice/backoffice.module"; +import { ChatModule } from "../chat/chat.module"; import { CompaniesModule } from "../companies/companies.module"; import { NotificationsModule } from "../notifications/notifications.module"; import { Notification } from "./entities/notification.entity"; @@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service"; BackofficeModule, // EmailClientService + SmsClientService (HIGH-priority fan-out) NotificationsModule, + // ChatBridgeService (mirrors BACKOFFICE notifications into chat) + ChatModule, ], controllers: [NotificationInboxController], providers: [ diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts index 7cfbf81df..119019997 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-inbox.service.ts @@ -1,4 +1,5 @@ import { + NotificationAudience, NotificationChannels, NotificationChannelsSent, NotificationDto, @@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { Repository } from "typeorm"; +import { ChatBridgeService } from "../chat/chat-bridge.service"; import { EmailClientService } from "../notifications/email-client.service"; import { SmsClientService } from "../notifications/sms-client.service"; import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; @@ -37,6 +39,7 @@ export class NotificationInboxService { private readonly gateway: NotificationsGateway, private readonly emailClient: EmailClientService, private readonly smsClient: SmsClientService, + private readonly chatBridge: ChatBridgeService, @InjectRepository(User) private readonly users: Repository, ) {} @@ -44,11 +47,18 @@ export class NotificationInboxService { /** * Fan a logical notification out to every resolved recipient: persist one row * each, push it live over WebSocket, and (for HIGH priority) also queue - * email/SMS via the existing clients. + * email/SMS via the existing clients. BACKOFFICE-audience notifications are + * also mirrored into internal chat (ChatBridgeService) — a shared-room + * broadcast, not per-recipient, so it runs once regardless of how many (if + * any) in-app rows get created below. Never PORTAL — that's customer-facing + * and must never reach a staff room. */ async notify(input: NotifyInput): Promise { try { const userIds = await this.recipients.resolve(input.recipients); + if (input.audience === NotificationAudience.BACKOFFICE) { + await this.chatBridge.bridge(input); + } if (userIds.length === 0) { this.logger.debug( `notify(${input.type}) resolved 0 recipients — skipped`, diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index 1798a8d5f..e4ab41954 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -242,6 +242,7 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ "edr_freight_app:hierarchy_positions:view", "edr_freight_app:hierarchy_employee_assignment:view", "edr_freight_app:position_types:view", + "edr_freight_app:chat:view", ], }, { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9e905df6d..4043430e2 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -523,6 +523,12 @@ export const SHIPPING_LINE_PERMISSIONS: FreightPermissionSeed[] = [ ), ]; +// Internal chat (Matrix/Element) — sidebar visibility + manual reconcile trigger. +export const CHAT_PERMISSIONS: FreightPermissionSeed[] = [ + perm('c9a00001-0001-4000-8000-000000000001', 'edr_freight_app:chat:view', 'Open internal chat'), + perm('c9a00001-0001-4000-8000-000000000002', 'edr_freight_app:chat:sync', 'Re-run chat room/membership sync'), +]; + // D. Finance — payments + invoices export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ perm( @@ -1641,6 +1647,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ ...REPORT_PERMISSIONS, ...CUSTOMER_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS, + ...CHAT_PERMISSIONS, ...FINANCE_PERMISSIONS, ...MILE_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS, @@ -1872,6 +1879,10 @@ export const FREIGHT_PERMS = { /** Reject any pending invoice request. */ invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", }, + chat: { + view: 'edr_freight_app:chat:view', + sync: 'edr_freight_app:chat:sync', + }, payments: { view: "edr_freight_app:payments:view", }, diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 9ac730bda..2ea1c1890 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -115,6 +115,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage"; import { UserManagementRoutes } from "./user-management/route"; import SetPassword from "./shared/components/SetPassword"; import SupportInboxPage from "./pages/support/SupportInboxPage"; +import ChatLaunchPage from "./pages/chat/ChatLaunchPage"; import { APP_TITLE, buildSidebarSections, @@ -298,6 +299,14 @@ const App = () => { } /> + + + + } + /> = [ subtitle: "Manage your account and signature", }, }, + { + prefix: "/dashboard/chat", + meta: { + title: "Chat", + subtitle: "Internal messaging for EDR staff", + }, + }, { // Invoices, Payments, and USD Payments are tabs on one page now // (FinanceHubPage); the header title itself is set per-tab there. diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 293e82450..301bf6939 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -32,6 +32,7 @@ import { Users, Wallet, LifeBuoy, + MessageSquare, TrainFront, XCircle, } from "lucide-react"; @@ -124,6 +125,12 @@ export const buildSidebarSections = ( icon: , permission: FREIGHT_PERMS.support.agentView, }, + { + label: "Chat", + href: "/dashboard/chat", + icon: , + permission: FREIGHT_PERMS.chat.view, + }, ...demoItems, ], }, diff --git a/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts new file mode 100644 index 000000000..65522aafd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts @@ -0,0 +1,13 @@ +import { api } from "@/auth/http"; + +/** + * Internal chat (Matrix/Element) REST calls. Just the one endpoint — Chat + * itself is a separate app (chat.edr.et); this backoffice only ever asks for + * a fresh sign-in link into it. + */ +export const chatApi = { + getSsoUrl: async (): Promise => { + const { data } = await api.get<{ url: string }>("/chat/sso"); + return data.url; + }, +}; diff --git a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts new file mode 100644 index 000000000..d25b5f4a1 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; + +import { chatApi } from "./chatApi"; + +export const CHAT_SSO_KEY = ["chat", "sso"] as const; + +/** + * The login_token this resolves to is single-use and expires in 5 minutes + * (Synapse default) — the global `staleTime: 0` (queryClient.ts) already + * means every fresh mount of the launch page refetches rather than reusing + * a possibly-spent link. + */ +export function useChatSso() { + return useQuery({ + queryKey: CHAT_SSO_KEY, + queryFn: chatApi.getSsoUrl, + retry: false, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index fe4d4fff9..b2797fa59 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -33,6 +33,10 @@ export const FREIGHT_PERMS = { staffUsers: { view: "edr_freight_app:staff:users:view", }, + chat: { + view: "edr_freight_app:chat:view", + sync: "edr_freight_app:chat:sync", + }, bookings: { view: "edr_freight_app:bookings:view", create: "edr_freight_app:bookings:create", diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx new file mode 100644 index 000000000..6b51b6512 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -0,0 +1,62 @@ +import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core"; +import { MessageSquare, TriangleAlert } from "lucide-react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { useChatSso } from "@/features/chat/useChatSso"; + +/** + * Chat itself lives at chat.edr.et (Element), not in this app — this page's + * only job is a fresh one-click sign-in link into it. A real `` (not + * `window.open()` in a click handler) so the browser never treats it as a + * blocked popup, and no iframe: Element's own CSP refuses to be framed. + */ +export default function ChatLaunchPage() { + const { data: url, isLoading, isError, refetch } = useChatSso(); + + return ( + + + +
+ + {isLoading && } + + {isError && ( + + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + + + + )} + + {url && ( + + + + Opens EDR Chat in a new tab, already signed in as you. + + + + )} + +
+
+
+ ); +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d5faa2de..050afc5df 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -116,3 +116,31 @@ services: env_file: - apps/edr-payment-api/.env restart: always + + # Internal employee chat (freight backoffice). No federation, no public + # registration — see infrastructure/matrix/synapse/homeserver.yaml.tmpl. + synapse: + build: + context: infrastructure/matrix/synapse + ports: + - "${SYNAPSE_PORT:-8008}:8008" + env_file: + - infrastructure/matrix/synapse/.env + volumes: + - matrix-data:/data + # Local dev: Postgres runs in the separate docker-compose.db.dev.yml + # project (different docker network) and is only reachable from here via + # the host's published port — see MATRIX_DB_HOST in synapse/.env.example. + extra_hosts: + - "host.docker.internal:host-gateway" + restart: always + + element-web: + build: + context: infrastructure/matrix/element + ports: + - "${ELEMENT_WEB_PORT:-8080}:80" + restart: always + +volumes: + matrix-data: diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile new file mode 100644 index 000000000..5ab02cd9b --- /dev/null +++ b/infrastructure/matrix/element/Dockerfile @@ -0,0 +1,9 @@ +# syntax=docker/dockerfile:1 +# +# EDR internal chat web client. Unmodified upstream Element Web + our public, +# non-secret config (homeserver URL, branding) and the SSO handoff page. +# Pin the tag; never float on `latest`. +FROM ghcr.io/element-hq/element-web:v1.11.108 + +COPY config.json /app/config.json +COPY sso.html /app/sso.html diff --git a/infrastructure/matrix/element/config.json b/infrastructure/matrix/element/config.json new file mode 100644 index 000000000..6f988d3e9 --- /dev/null +++ b/infrastructure/matrix/element/config.json @@ -0,0 +1,17 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "https://matrix.edr.et", + "server_name": "matrix.edr.et" + } + }, + "brand": "EDR Chat", + "permalink_prefix": "https://chat.edr.et", + "disable_guests": true, + "disable_3pid_login": true, + "disable_custom_urls": true, + "default_theme": "light", + "settingDefaults": { + "UIFeature.registration": false + } +} diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html new file mode 100644 index 000000000..59a458fa4 --- /dev/null +++ b/infrastructure/matrix/element/sso.html @@ -0,0 +1,36 @@ + + + + + + Signing in to EDR Chat… + + + + + diff --git a/infrastructure/matrix/synapse/Dockerfile b/infrastructure/matrix/synapse/Dockerfile new file mode 100644 index 000000000..ec424e41f --- /dev/null +++ b/infrastructure/matrix/synapse/Dockerfile @@ -0,0 +1,15 @@ +# syntax=docker/dockerfile:1 +# +# EDR internal chat homeserver. Unmodified upstream Synapse + our config +# template — no source build. Pin the tag; never float on `latest`. +FROM ghcr.io/element-hq/synapse:v1.140.0 + +RUN apt-get update && apt-get install -y --no-install-recommends gettext-base \ + && rm -rf /var/lib/apt/lists/* + +COPY homeserver.yaml.tmpl /synapse/homeserver.yaml.tmpl +COPY log.config /synapse/log.config +COPY docker-entrypoint.sh /synapse/docker-entrypoint.sh +RUN chmod +x /synapse/docker-entrypoint.sh + +ENTRYPOINT ["/synapse/docker-entrypoint.sh"] diff --git a/infrastructure/matrix/synapse/docker-entrypoint.sh b/infrastructure/matrix/synapse/docker-entrypoint.sh new file mode 100644 index 000000000..674bf7947 --- /dev/null +++ b/infrastructure/matrix/synapse/docker-entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Renders homeserver.yaml from the template using the runtime env (so +# MATRIX_JWT_SECRET / DB password / registration_shared_secret come from the +# service's .env file, never get baked into the image), then hands off to the +# upstream Synapse image's own entrypoint. +set -eu + +mkdir -p /data +envsubst \ + '${MATRIX_SERVER_NAME} ${MATRIX_PUBLIC_BASEURL} ${MATRIX_DB_USER} ${MATRIX_DB_PASSWORD} ${MATRIX_DB_NAME} ${MATRIX_DB_HOST} ${MATRIX_DB_PORT} ${MATRIX_JWT_SECRET} ${MATRIX_REGISTRATION_SHARED_SECRET}' \ + < /synapse/homeserver.yaml.tmpl > /data/homeserver.yaml + +# start.py's `run` mode (the implicit default we hit below) gosu's straight +# into uid 991 with no chown — it only chowns /data in its `generate` / +# `migrate_config` modes, which we skip by providing our own pre-rendered +# config. Without this, 991 can't write its signing key on first boot. +chown -R 991:991 /data + +export SYNAPSE_CONFIG_PATH=/data/homeserver.yaml +exec /start.py "$@" diff --git a/infrastructure/matrix/synapse/homeserver.yaml.tmpl b/infrastructure/matrix/synapse/homeserver.yaml.tmpl new file mode 100644 index 000000000..2f7d59502 --- /dev/null +++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl @@ -0,0 +1,95 @@ +# EDR internal chat — Synapse homeserver config. +# +# Rendered to /data/homeserver.yaml at container start by docker-entrypoint.sh +# (envsubst over this template) so secrets come from the runtime env file, +# never baked into the image — same convention as freight-api's .env. +# +# server_name is PERMANENT: it is baked into every user id and event and +# cannot change without wiping the server. Do not repoint this at a +# different value after go-live. +server_name: "${MATRIX_SERVER_NAME}" +public_baseurl: "${MATRIX_PUBLIC_BASEURL}" +pid_file: /data/homeserver.pid + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation] + compress: false + +database: + name: psycopg2 + args: + user: "${MATRIX_DB_USER}" + password: "${MATRIX_DB_PASSWORD}" + dbname: "${MATRIX_DB_NAME}" + host: "${MATRIX_DB_HOST}" + port: ${MATRIX_DB_PORT} + cp_min: 5 + cp_max: 10 + +media_store_path: /data/media_store +max_upload_size: 50M + +log_config: "/synapse/log.config" + +# Internal comms tool: no federation, no open registration, no E2EE-by-default. +# ponytail: E2EE off — turn on per-room (HR/legal) if compliance asks. +federation_domain_whitelist: [] +enable_registration: false +encryption_enabled_by_default_for_room_type: "off" + +# Employees authenticate via freight-api's SSO handoff, never a Matrix +# password prompt. This is the entire auth story for this deployment. +password_config: + enabled: false + +jwt_config: + enabled: true + secret: "${MATRIX_JWT_SECRET}" + algorithm: "HS256" + issuer: "edr-freight-api" + audiences: ["matrix"] + # Matches the `name` claim chat-sso.service.ts puts in the JWT — only read + # on first login (auto-registration), never updates it on later logins. + display_name_claim: "name" + +# Consumes the login_token minted by freight-api's SSO endpoint via +# POST /_matrix/client/v1/login/get_token (issued against an existing, +# already-JWT-authenticated session — not a bare password grant). +login_via_existing_session: + enabled: true + require_ui_auth: false + token_timeout: 5m + +# Bootstrap-only: used once by ops to register the first admin account +# (register_new_matrix_user against /_synapse/admin/v1/register), whose +# access token becomes MATRIX_ADMIN_TOKEN for freight-api's provisioning +# service. Rotate/remove after bootstrap if desired — nothing else depends +# on shared-secret registration once the admin account exists. +registration_shared_secret: "${MATRIX_REGISTRATION_SHARED_SECRET}" + +trusted_key_servers: [] +suppress_key_server_warning: true + +report_stats: false + +# Synapse's default rc_login is sized to defend against internet-facing +# password brute-forcing. That threat doesn't exist on this deployment — +# password login is off (see password_config above), and the only path in +# requires a freight-api-signed JWT — so the default is mostly just +# punishing legitimate rapid logins from the same office/NAT IP or normal +# page-refresh retries. Loosened, not disabled, to keep some ceiling. +rc_login: + address: + per_second: 100 + burst_count: 200 + account: + per_second: 100 + burst_count: 200 + failed_attempts: + per_second: 100 + burst_count: 200 diff --git a/infrastructure/matrix/synapse/log.config b/infrastructure/matrix/synapse/log.config new file mode 100644 index 000000000..0894e974f --- /dev/null +++ b/infrastructure/matrix/synapse/log.config @@ -0,0 +1,25 @@ +# Log straight to stdout — the container runtime (docker compose logs / the +# self-hosted runner's log collection) owns rotation and retention, matching +# how every other app container in this repo logs. +version: 1 + +formatters: + precise: + format: "%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(message)s" + +handlers: + console: + class: logging.StreamHandler + formatter: precise + stream: ext://sys.stdout + +loggers: + synapse.storage.SQL: + # SQL queries are DEBUG-only noise; leave at INFO unless diagnosing. + level: INFO + +root: + level: INFO + handlers: [console] + +disable_existing_loggers: false diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 69f7eef56..1e9a1ff52 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -31,6 +31,11 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-portal"]="apps/edr-passenger-web/portal/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" + ["synapse"]="infrastructure/matrix/synapse/.env" + # element-web has no runtime secrets (its config.json is baked into the + # image), but the sync step still runs unconditionally per service and + # needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose. + ["element-web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do