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..b122d8073 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -0,0 +1,237 @@ +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, + ); + } + } + + /** Every current holder in the unit, or just one person's rows when `userId` is given. */ + private async currentHolders(userId?: string): 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 + ${userId ? 'AND e.user_id = $3' : ''}`, + userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY], + ); + } + + /** + * Put one person in their rooms right now. + * + * {@link reconcile} is nightly, so without this a new employee's first + * sign-in shows an empty client until 3AM — the SSO handoff creates their + * account but joins them to nothing. Called on every /chat/sso, so it is + * scoped to the one user (a full reconcile per click would be a room-count + * multiple of Matrix calls) and every step is get-or-create. + * + * Someone holding no current position in the unit joins nothing, by the same + * rule the reconcile uses — chat membership follows the org tree. + */ + async joinUserRooms(userId: string, displayName: string): Promise { + const positions = await this.currentHolders(userId); + if (positions.length === 0) return 0; + + const mxid = this.matrix.mxidFor(userId, displayName); + // The JWT login auto-registers too, but that happens after this runs and + // the admin join API 404s on an account that does not exist yet. + await this.matrix.ensureUser(mxid, displayName); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + await this.matrix.ensureJoined(generalRoomId, mxid); + + for (const position of positions) { + const roomId = await this.matrix.ensureRoom( + `dept-${position.positionKey}`, + position.positionName, + { parentSpaceId: spaceId }, + ); + await this.matrix.ensureJoined(roomId, mxid); + } + + return positions.length + 1; + } + + /** 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.ensureJoined(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.mxidFor(h.userId, h.userName)), + ); + + // 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.mxidFor(h.userId, h.userName); + 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.mxidFor(h.userId, h.userName)); + 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..22edb8e1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -0,0 +1,91 @@ +import { Inject, Injectable, Logger } 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 { ChatProvisioningService } from './chat-provisioning.service'; +import { MatrixClient, chatLocalpart } from './matrix.client'; + +/** Long enough for one login call, short enough to be worthless if it leaks. */ +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 session. + * 3. Hand the caller a link to Element's sso.html shim, which writes that + * session into localStorage and drops the user straight into Element. + * + * Step 3 used to mint a one-shot login_token and let Element redeem it. That + * path is capped at one request per user per minute by a limiter hardcoded in + * Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a + * spent token surfaces in Element as "Incorrect username and/or password". + * Element accepts a plaintext token out of localStorage (Lifecycle.ts + * getStoredToken/tryDecryptToken), so handing over the session we already hold + * removes both failure modes and one round-trip. + */ +@Injectable() +export class ChatSsoService { + private readonly logger = new Logger(ChatSsoService.name); + + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + private readonly matrix: MatrixClient, + private readonly provisioning: ChatProvisioningService, + ) {} + + async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { + const secret = new TextEncoder().encode(this.config.jwtSecret); + const name = displayName(user); + + // Before the link, not after: the reconcile that fills rooms is nightly, so + // a first sign-in would otherwise open an empty client. Best-effort — + // failing to join a room is no reason to refuse someone a sign-in link. + try { + await this.provisioning.joinUserRooms(user.id, name); + } catch (err) { + this.logger.error( + `Room join on sign-in failed for ${user.id}: ${(err as Error).message}`, + ); + } + // Synapse takes the localpart straight from `sub` on auto-registration, so + // this must be byte-identical to what ChatProvisioningService derives for + // the same person — otherwise SSO signs them into one account while the + // reconcile force-joins a different one into the rooms. + const jwt = await new SignJWT({ name }) + .setProtectedHeader({ alg: 'HS256' }) + .setSubject(chatLocalpart(user.id, name)) + .setIssuer('edr-freight-api') + .setAudience('matrix') + .setIssuedAt() + .setExpirationTime(`${JWT_TTL_SECONDS}s`) + .sign(secret); + + const session = await this.matrix.loginWithJwt(jwt); + + // Session goes in the URL fragment, never the query: a fragment is not sent + // to any server, so the token stays out of Element's access log, and + // sso.html replaces the entry so it does not linger in history either. + const params = new URLSearchParams({ + hs: this.config.publicBaseUrl, + t: session.access_token, + u: session.user_id, + d: session.device_id, + }); + return { url: `${this.config.webUrl}/sso.html#${params.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.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts new file mode 100644 index 000000000..ea5faa978 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -0,0 +1,35 @@ +import { chatLocalpart } from './matrix.client'; + +describe('chatLocalpart', () => { + it('reads from the name, not the id', () => { + expect( + chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'), + ).toBe('nati-wondi.03f5eb'); + }); + + it('separates two people who share a name', () => { + // Both of these are real dev rows — same name, different employees. + const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA'); + const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA'); + expect(a).not.toBe(b); + }); + + it('is stable for the same person', () => { + const id = '7d798218-09de-47a1-98eb-f61ec44e9280'; + expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod')); + }); + + it('still yields a usable localpart for a name that slugs to nothing', () => { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe( + 'user.7d7982', + ); + }); + + it('only emits characters Matrix accepts in a localpart', () => { + for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch( + /^[a-z0-9._=\-/]+$/, + ); + } + }); +}); 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..1cd09ac33 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -0,0 +1,320 @@ +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. + */ +/** + * Localpart of a staff member's MXID: their name, plus the first 6 hex of + * their freight user id. + * + * The tail is not decoration. Names collide — 19 of the 114 users in the dev + * IAM share a slug with someone else ("MARKOS REGASA" and "Markos REGASA" are + * two different people) — and an MXID is permanent, so a bare slug would hand + * two employees the same Matrix account and each other's rooms. The id is + * already random, so 6 hex of it separates them without a lookup or a mapping + * table, and keeps the derivation pure: ChatSsoService (which mints the JWT + * `sub`) and ChatProvisioningService (which force-joins rooms) must agree on + * this string exactly or they provision two accounts per person. + */ +export function chatLocalpart(userId: string, displayName: string): string { + const slug = displayName + // NFKD splits an accent off its letter; the non-alnum sweep below then + // folds the leftover mark into the same `-` run as the neighbouring space. + .normalize('NFKD') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 40); + // Amharic-only names slug to nothing — the tail still makes it unique. + return `${slug || 'user'}.${userId.replace(/-/g, '').slice(0, 6)}`; +} + +@Injectable() +export class MatrixClient { + constructor( + @Inject(chatConfig.KEY) + private readonly config: ConfigType, + ) {} + + /** + * Alias localparts go in a URL path segment, so a `/` in one is fatal: + * Synapse decodes the path before routing, and `%2F` splits the request into + * a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404 + * as "no such room" and ensureRoom then tries to create the same broken alias + * on every run. Position keys are `edr_freight_app/opn` shaped, so this hits + * every dept room but the handful whose key happens to be a bare word. + */ + private static aliasSafe(alias: string): string { + return alias.replace(/[^A-Za-z0-9._=-]/g, '-'); + } + + /** `@:` — the one place this format is assembled. */ + mxid(localpart: string): string { + return `@${localpart}:${this.config.serverName}`; + } + + /** The MXID of a freight user — see {@link chatLocalpart}. */ + mxidFor(userId: string, displayName: string): string { + return this.mxid(chatLocalpart(userId, displayName)); + } + + 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); + } + + // No getLoginToken here on purpose. POST /_matrix/client/v1/login/get_token + // is rate limited to 1 request per user per MINUTE, hardcoded in Synapse + // (rest/client/login_token_request.py: "Ratelimit aggressively … could be + // abused by a malicious client to create many sessions") and not settable + // from homeserver.yaml. A second click inside a minute got M_LIMIT_EXCEEDED. + // ChatSsoService hands Element the session from loginWithJwt directly + // instead, which needs no second call. + + /** 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( + rawAlias: string, + name: string, + opts: { isSpace?: boolean; parentSpaceId?: string } = {}, + ): Promise { + const alias = MatrixClient.aliasSafe(rawAlias); + 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 }, + ); + } + + /** + * Force-join, treating "already a member" as success. Synapse answers a + * repeat join with 403 `M_FORBIDDEN: " is already in the room."`, which + * is a failure only if you assumed you knew the membership first. Callers + * that just want someone in a room (sign-in, reconcile racing itself) want + * this; the raw 403 tells them nothing they can act on. + */ + async ensureJoined(roomIdOrAlias: string, userId: string): Promise { + try { + await this.forceJoin(roomIdOrAlias, userId); + } catch (err) { + if (!/already in the room/i.test((err as Error).message)) throw err; + } + } + + 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 380a62f02..01c1d67d0 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 c131af4bd..d4f99be8d 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/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..78f0e7c36 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -0,0 +1,78 @@ +import { Alert, Button, Card, Center, Stack, Text } from "@mantine/core"; +import { MessageSquare, TriangleAlert } from "lucide-react"; +import { useState } from "react"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { chatApi } from "@/features/chat/chatApi"; + +/** + * Chat itself lives at chat.edr.et (Element), not in this app — this page's + * only job is a one-click sign-in link into it. No iframe: Element's own CSP + * refuses to be framed. + * + * The link is minted per click, never on mount and never cached: Synapse's + * login_token is single-use and expires in 5 minutes, and Element reports a + * spent one as "Incorrect username and/or password". A held-onto url is + * therefore wrong on the second click, on a remount served from cache, and on + * any click more than 5 minutes after the page loaded. + */ +export default function ChatLaunchPage() { + const [state, setState] = useState<"idle" | "loading" | "error">("idle"); + + const open = async () => { + // Opened before the await so it still counts as the user's click — a + // window.open() after it is treated as a popup and blocked. + // + // No "noopener" in the features: passing it makes window.open return null, + // which would leave this blank tab orphaned and send Element into the + // current tab instead. Clearing .opener on the handle does the same job. + const tab = window.open("", "_blank"); + if (tab) tab.opener = null; + setState("loading"); + try { + const url = await chatApi.getSsoUrl(); + if (tab) tab.location.replace(url); + else window.location.assign(url); // popup blocked — go in this tab + setState("idle"); + } catch { + tab?.close(); + setState("error"); + } + }; + + return ( + + + +
+ + {state === "error" && ( + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + + )} + + + + + Opens EDR Chat in a new tab, already signed in as you. + + + + +
+
+
+ ); +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d5faa2de..a3f57ebb5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -116,3 +116,35 @@ 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" + # config.json is rendered at container start, not baked — one image serves + # dev, staging and prod. See infrastructure/matrix/element/.env.example. + env_file: + - infrastructure/matrix/element/.env + restart: always + +volumes: + matrix-data: diff --git a/infrastructure/matrix/element/40-element-config.sh b/infrastructure/matrix/element/40-element-config.sh new file mode 100644 index 000000000..9826a5fc7 --- /dev/null +++ b/infrastructure/matrix/element/40-element-config.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Renders /app/config.json from config.json.tmpl at container start. +# +# The homeserver URL and server_name differ per environment, and config.json is +# read by the browser rather than the build, so baking it into the image would +# mean one image per environment. Dropped into /docker-entrypoint.d, which the +# upstream nginx entrypoint runs (in lexical order) before starting nginx — +# no ENTRYPOINT override, so the image's own startup work still happens. +set -eu + +: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}" +: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}" +: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}" + +envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \ + < /app/config.json.tmpl > /app/config.json + +echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})" diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile new file mode 100644 index 000000000..330098af6 --- /dev/null +++ b/infrastructure/matrix/element/Dockerfile @@ -0,0 +1,23 @@ +# 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.tmpl /app/config.json.tmpl +COPY sso.html /app/sso.html +# Replaces the upstream manifest, which names the app "Element" and advertises +# the Play/App Store builds under related_applications. Those apps cannot log +# in here — this deployment has no password login and no SSO provider, only the +# JWT handoff from freight-api — so pointing staff at them is a dead end. +COPY manifest.json /app/manifest.json +COPY 40-element-config.sh /docker-entrypoint.d/40-element-config.sh + +# The image runs as uid 101 (nginx) but ships /app root-owned, so the startup +# hook could not write the rendered config without this. +USER root +RUN chmod +x /docker-entrypoint.d/40-element-config.sh \ + && touch /app/config.json \ + && chown nginx:nginx /app/config.json +USER nginx diff --git a/infrastructure/matrix/element/config.json.tmpl b/infrastructure/matrix/element/config.json.tmpl new file mode 100644 index 000000000..f08900601 --- /dev/null +++ b/infrastructure/matrix/element/config.json.tmpl @@ -0,0 +1,18 @@ +{ + "default_server_config": { + "m.homeserver": { + "base_url": "${MATRIX_PUBLIC_BASEURL}", + "server_name": "${MATRIX_SERVER_NAME}" + } + }, + "brand": "EDR Chat", + "permalink_prefix": "${ELEMENT_PUBLIC_URL}", + "disable_guests": true, + "disable_3pid_login": true, + "disable_custom_urls": true, + "default_theme": "light", + "mobile_guide_toast": false, + "settingDefaults": { + "UIFeature.registration": false + } +} diff --git a/infrastructure/matrix/element/manifest.json b/infrastructure/matrix/element/manifest.json new file mode 100644 index 000000000..75f5f33e6 --- /dev/null +++ b/infrastructure/matrix/element/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "EDR Chat", + "short_name": "EDR Chat", + "display": "standalone", + "theme_color": "#0dbd8b", + "start_url": "index.html", + "icons": [ + { "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" }, + { "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" }, + { "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" } + ] +} diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html new file mode 100644 index 000000000..77f378122 --- /dev/null +++ b/infrastructure/matrix/element/sso.html @@ -0,0 +1,49 @@ + + + + + + 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..efb7372ac --- /dev/null +++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl @@ -0,0 +1,113 @@ +# 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" + +# Turning rooms' encryption off above is not enough on its own: Element still +# bootstraps cross-signing on a user's first login, and from then on gates +# EVERY later login behind "Verify this device" (MatrixChat: crossSigningIsSetUp +# -> Views.COMPLETE_SECURITY). Nobody on this deployment can clear that gate — +# each SSO click is a brand-new device, so there is never a second verified +# device to accept the request, and resetting the identity needs UIA, which +# password_config.enabled: false makes impossible. +# +# This tells Element encryption is off here, so it skips the bootstrap +# (shouldSkipSetupEncryption) and the gate is never armed. Only helps accounts +# that have no cross-signing keys yet — anyone already bootstrapped keeps +# hitting the gate until their keys are cleared. +extra_well_known_client_content: + io.element.e2ee: + default: false + force_disable: true + secure_backup_required: false + +# 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-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh index 6a405ab28..0835f86d9 100644 --- a/scripts/deploy/sync-env-from-env-manager.sh +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -29,6 +29,12 @@ 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 holds no secrets, but its config.json is rendered at container + # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL + # (see infrastructure/matrix/element), so it needs an env file like the rest — + # plus the PORT line every service env is required to carry. + ["element_web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 69f7eef56..4fb7fbe6b 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 holds no secrets, but its config.json is rendered at container + # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL, + # and the sync step needs a PORT= line to compute ELEMENT_WEB_PORT anyway. + ["element-web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do