Merge pull request #1324 from Tria-plc/freight/feat/element-chat

Freight/feat/element chat
This commit is contained in:
Nathnael Wondisha
2026-08-17 15:55:37 +03:00
committed by GitHub
34 changed files with 1380 additions and 2 deletions

View File

@@ -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"
;;

View File

@@ -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=

View File

@@ -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,

View File

@@ -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

View File

@@ -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!,
};
});

View File

@@ -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<Record<NotificationType, { alias: string; name: string }>> = {
[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<typeof chatConfig>,
private readonly matrix: MatrixClient,
) {}
async bridge(input: NotifyInput): Promise<void> {
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 = `<strong>${escapeHtml(input.title)}</strong><br/>${escapeHtml(input.body)}${
input.link ? `<br/><a href="${escapeHtml(input.link)}">${escapeHtml(input.link)}</a>` : ''
}`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -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-<positionKey>), 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<void> {
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<PositionHolder[]> {
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<number> {
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<string>,
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<ReconcileResult> {
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<string>();
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<string>();
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<string, { name: string; userIds: Set<string> }>();
for (const h of holders) {
const entry = byPosition.get(h.positionKey) ?? {
name: h.positionName,
userIds: new Set<string>(),
};
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 };
}
}

View File

@@ -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<typeof chatConfig>,
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()}` };
}
}

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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._=\-/]+$/,
);
}
});
});

View File

@@ -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<typeof chatConfig>,
) {}
/**
* 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, '-');
}
/** `@<localpart>:<server_name>` — 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<T>(
method: string,
path: string,
body?: unknown,
token: string = this.config.adminToken,
): Promise<T> {
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<T>(
method: string,
path: string,
body: unknown,
): Promise<T> {
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<T>(
method: string,
path: string,
token?: string,
): Promise<T | null> {
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<string> {
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<string[]> {
const res = await this.request<{ joined: Record<string, unknown> }>(
'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<void> {
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<string> {
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<void> {
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<void> {
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: "<user> 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<void> {
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<void> {
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<void> {
return this.request(
'POST',
`/_synapse/admin/v1/deactivate/${encodeURIComponent(userId)}`,
{ erase: false },
);
}
sendMessage(roomId: string, body: string, formattedBody?: string): Promise<void> {
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 },
);
}
}

View File

@@ -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: [

View File

@@ -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<User>,
) {}
@@ -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<void> {
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`,

View File

@@ -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",
],
},
{

View File

@@ -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",
},

View File

@@ -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 = () => {
</RequirePermission>
}
/>
<Route
path="chat"
element={
<RequirePermission permission={FREIGHT_PERMS.chat.view}>
<ChatLaunchPage />
</RequirePermission>
}
/>
<Route
path="customers"
element={

View File

@@ -71,6 +71,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
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.

View File

@@ -32,6 +32,7 @@ import {
Users,
Wallet,
LifeBuoy,
MessageSquare,
TrainFront,
XCircle,
} from "lucide-react";
@@ -124,6 +125,12 @@ export const buildSidebarSections = (
icon: <LifeBuoy />,
permission: FREIGHT_PERMS.support.agentView,
},
{
label: "Chat",
href: "/dashboard/chat",
icon: <MessageSquare />,
permission: FREIGHT_PERMS.chat.view,
},
...demoItems,
],
},

View File

@@ -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<string> => {
const { data } = await api.get<{ url: string }>("/chat/sso");
return data.url;
},
};

View File

@@ -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",

View File

@@ -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 (
<PageContainer>
<PageHeader title="Chat" subtitle="Internal messaging for EDR staff" />
<Card withBorder radius="md" p="xl">
<Center>
<Stack align="center" gap="md" py="xl">
{state === "error" && (
<Alert
icon={<TriangleAlert size={18} />}
color="red"
title="Couldn't get a sign-in link"
variant="light"
>
Something went wrong reaching chat. Try again.
</Alert>
)}
<Stack align="center" gap="sm">
<MessageSquare size={40} strokeWidth={1.5} />
<Text c="dimmed" ta="center" maw={360}>
Opens EDR Chat in a new tab, already signed in as you.
</Text>
<Button
onClick={open}
loading={state === "loading"}
leftSection={<MessageSquare size={16} />}
>
Open EDR Chat
</Button>
</Stack>
</Stack>
</Center>
</Card>
</PageContainer>
);
}

View File

@@ -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:

View File

@@ -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})"

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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" }
]
}

View File

@@ -0,0 +1,49 @@
<!doctype html>
<!--
Session handoff from freight-api into Element.
freight-api's chat-sso.service.ts links here as
https://chat.edr.et/sso.html#hs=<homeserver>&t=<access_token>&u=<user_id>&d=<device_id>
— a fragment, not a query, so the token is never sent to a server and never
lands in an access log. location.replace() below drops this URL from history
as well, so the token does not survive the redirect.
The keys written here are the ones Element reads on startup
(element-web src/Lifecycle.ts getStoredSessionVars/getStoredToken): the token
is looked up in IndexedDB first and falls back to localStorage, which Element
then migrates into IndexedDB itself. A plaintext token is accepted —
tryDecryptToken returns a string token as-is, and only decrypts when it finds
an encrypted payload.
This replaced a ?loginToken= handoff: POST /_matrix/client/v1/login/get_token
is capped at one call per user per minute by a limiter hardcoded in Synapse,
so clicking Chat twice in a minute failed.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Signing in to EDR Chat…</title>
</head>
<body>
<script>
var params = new URLSearchParams(window.location.hash.slice(1));
var homeserver = params.get("hs");
var token = params.get("t");
var userId = params.get("u");
var deviceId = params.get("d");
if (homeserver && token && userId && deviceId) {
localStorage.setItem("mx_hs_url", homeserver);
localStorage.setItem("mx_user_id", userId);
localStorage.setItem("mx_device_id", deviceId);
localStorage.setItem("mx_access_token", token);
localStorage.setItem("mx_has_access_token", "true");
localStorage.setItem("mx_is_guest", "false");
window.location.replace("/");
} else {
document.body.textContent =
"Missing sign-in details. Go back to the EDR backoffice and click Chat again.";
}
</script>
</body>
</html>

View File

@@ -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"]

View File

@@ -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 "$@"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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