feat: WIP element Chat intergration

This commit is contained in:
Nathnael
2026-07-31 06:36:03 +00:00
parent a2c30a3c96
commit 00bd1250ee
31 changed files with 1128 additions and 2 deletions

View File

@@ -43,6 +43,8 @@ jobs:
"passenger-portal" "passenger-portal"
"passenger-backoffice" "passenger-backoffice"
"payment-api" "payment-api"
"synapse"
"element-web"
) )
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then 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/portal/" && SERVICES+=("passenger-portal")
echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice")
echo "$CHANGED" | grep -q "^apps/edr-payment-api/" && SERVICES+=("payment-api") 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)) SERVICES=($(printf '%s\n' "${SERVICES[@]}" | sort -u))
@@ -119,7 +125,7 @@ jobs:
- name: Resolve project and build env file - name: Resolve project and build env file
run: | run: |
case "${{ matrix.service }}" in 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 "PROJECT=edr-freight" >> "$GITHUB_ENV"
echo "BUILD_ENV_FILE=freight-web.build.env" >> "$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 * * * * EIMS_AUTO_SUBMIT_CRON=0 */5 * * * *
# MoR rejects documents older than 3 days; the sweep will not attempt those. # MoR rejects documents older than 3 days; the sweep will not attempt those.
EIMS_AUTO_SUBMIT_MAX_AGE_DAYS=3 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 rabbitmqConfig from "./config/rabbitmq.config";
import faydaConfig from "./config/fayda.config"; import faydaConfig from "./config/fayda.config";
import eimsConfig from "./config/eims.config"; import eimsConfig from "./config/eims.config";
import chatConfig from "./config/chat.config";
import { BookingsModule } from "./modules/bookings/bookings.module"; import { BookingsModule } from "./modules/bookings/bookings.module";
import { ContractsModule } from "./modules/contracts/contracts.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 { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module"; import { AiModule } from "./modules/ai/ai.module";
import { AuditModule } from "./modules/audit/audit.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 { RequestLogMiddleware } from "@edr/api-common";
import { ChatModule } from "./modules/chat/chat.module";
import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware";
import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache";
@@ -135,6 +139,7 @@ if (!process.env.APPLICATION_NAME) {
rabbitmqConfig, rabbitmqConfig,
faydaConfig, faydaConfig,
eimsConfig, eimsConfig,
chatConfig,
], ],
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
@@ -252,6 +257,7 @@ if (!process.env.APPLICATION_NAME) {
FleetHistoryModule, FleetHistoryModule,
AiModule, AiModule,
AuditModule, AuditModule,
ChatModule,
], ],
providers: [ providers: [
EdrOrgSeeder, EdrOrgSeeder,

View File

@@ -49,6 +49,8 @@ export const MixedAudience = (permission: string | string[]) =>
export const BookingView = () => BookingStaff(FREIGHT_PERMS.bookings.view); 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 * The document-review countdown in the backoffice header. Its own permission so
* it can be granted to exactly the position types that decide operation * 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,192 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { MatrixClient } from './matrix.client';
/** edr-org.seeder.ts's EDR_ORG_KEY / EDR_UNIT_KEY — the org is currently flat
* (one org, one unit), so this is the entire scope of what gets provisioned. */
const ORG_KEY = 'edr_freight';
const UNIT_KEY = 'edr_freight_app';
const SPACE_ALIAS = 'edr-freight';
const GENERAL_ALIAS = 'general';
interface PositionHolder {
positionKey: string;
positionName: string;
userId: string;
userName: string;
}
export interface ReconcileResult {
rooms: number;
joined: number;
kicked: number;
deactivated: number;
}
/**
* Keeps Matrix rooms and their membership in sync with IAM's unit/position
* tree. There is no local hook on "employee position changed" — IAM writes
* happen inside the vendored @tria-plc/iamapi-common package — so this is a
* reconcile loop, not an event handler: nightly, plus on-demand via
* POST /chat/sync.
*
* Room identity is a deterministic alias (#dept-<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,
);
}
}
private async currentHolders(): 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`,
[ORG_KEY, UNIT_KEY],
);
}
/** 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.forceJoin(roomId, userId);
joined += 1;
}
}
const kicked: string[] = [];
for (const userId of currentSet) {
if (!desiredUserIds.has(userId)) {
await this.matrix.kick(roomId, userId, 'No longer assigned to this room');
kicked.push(userId);
}
}
return { joined, kicked };
}
async reconcile(): Promise<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.mxid(h.userId)));
// Accounts are otherwise only created lazily on first JWT login (see
// ChatSsoService) — force-joining someone who has never clicked "Chat"
// yet 404s ("User not found") without this.
const seenUserIds = new Set<string>();
for (const h of holders) {
const mxid = this.matrix.mxid(h.userId);
if (seenUserIds.has(mxid)) continue;
seenUserIds.add(mxid);
await this.matrix.ensureUser(mxid, h.userName);
}
let rooms = 2; // space + general
let joined = 0;
let kicked = 0;
// A user kicked from anything while holding zero current positions
// anywhere in the unit (allUserIds spans every position) is a full
// leaver, not just moved between positions — deactivate their account.
const kickedUserIds = new Set<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.mxid(h.userId));
byPosition.set(h.positionKey, entry);
}
for (const [positionKey, { name, userIds }] of byPosition) {
const roomId = await this.matrix.ensureRoom(`dept-${positionKey}`, name, {
parentSpaceId: spaceId,
});
rooms += 1;
const diff = await this.syncMembership(roomId, userIds, botMxid);
joined += diff.joined;
kicked += diff.kicked.length;
diff.kicked.forEach((uid) => kickedUserIds.add(uid));
}
let deactivated = 0;
for (const userId of kickedUserIds) {
if (allUserIds.has(userId)) continue; // moved position, still current elsewhere
try {
await this.matrix.deactivateUser(userId);
deactivated += 1;
} catch (err) {
this.logger.warn(
`Failed to deactivate departed user ${userId}: ${(err as Error).message}`,
);
}
}
return { rooms, joined, kicked, deactivated };
}
}

View File

@@ -0,0 +1,59 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { SignJWT } from 'jose';
import chatConfig from '../../config/chat.config';
import { MatrixClient } from './matrix.client';
/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */
const JWT_TTL_SECONDS = 60;
function displayName(user: TCurrentUser): string {
return (
user.name?.en ||
Object.values(user.name ?? {}).find((v) => typeof v === 'string' && v) ||
user.username ||
user.email
);
}
/**
* The SSO handoff: turn an already-authenticated freight session into a
* one-click Element sign-in link, with no second password anywhere.
*
* 1. Sign a short-lived JWT asserting this user's id (Synapse's
* org.matrix.login.jwt auto-registers the account on first use).
* 2. Trade that JWT for a real Matrix access token.
* 3. Trade the access token for a one-shot login_token.
* 4. Hand the caller a link to Element's sso.html shim, which seeds
* localStorage and forwards the token into Element's own login flow.
*/
@Injectable()
export class ChatSsoService {
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
private readonly matrix: MatrixClient,
) {}
async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> {
const secret = new TextEncoder().encode(this.config.jwtSecret);
const jwt = await new SignJWT({ name: displayName(user) })
.setProtectedHeader({ alg: 'HS256' })
.setSubject(user.id)
.setIssuer('edr-freight-api')
.setAudience('matrix')
.setIssuedAt()
.setExpirationTime(`${JWT_TTL_SECONDS}s`)
.sign(secret);
const { access_token } = await this.matrix.loginWithJwt(jwt);
const { login_token } = await this.matrix.getLoginToken(access_token);
const url = new URL(`${this.config.webUrl}/sso.html`);
url.searchParams.set('t', login_token);
url.searchParams.set('hs', this.config.publicBaseUrl);
return { url: url.toString() };
}
}

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,263 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ConfigType } from '@nestjs/config';
import chatConfig from '../../config/chat.config';
/**
* Thin wrapper over the handful of Matrix Client-Server + Synapse Admin API
* calls this app needs. Not a general Matrix SDK — matrix-js-sdk is a
* browser/Element concern; the server side only ever provisions rooms/users
* and posts bot messages, so a fetch wrapper is the whole job.
*
* All admin-scoped calls act as the account behind MATRIX_ADMIN_TOKEN. That
* same account also posts the notification-bridge messages (see
* ChatBridgeService) — one bot/admin account covers both jobs, no separate
* bot user needed.
*/
@Injectable()
export class MatrixClient {
constructor(
@Inject(chatConfig.KEY)
private readonly config: ConfigType<typeof chatConfig>,
) {}
/** `@<localpart>:<server_name>` — the one place this format is assembled. */
mxid(localpart: string): string {
return `@${localpart}:${this.config.serverName}`;
}
get serverName(): string {
return this.config.serverName;
}
private async request<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);
}
/** Exchange a fresh access token for a one-shot login_token (5 min TTL). */
getLoginToken(accessToken: string): Promise<{ login_token: string }> {
return this.request(
'POST',
'/_matrix/client/v1/login/get_token',
{},
accessToken,
);
}
/** null when the alias doesn't resolve to a room yet. */
resolveAlias(alias: string): Promise<{ room_id: string } | null> {
return this.requestOrNull(
'GET',
`/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`,
);
}
createRoom(input: {
alias: string;
name: string;
topic?: string;
isSpace?: boolean;
parentSpaceId?: string;
}): Promise<{ room_id: string }> {
return this.request('POST', '/_matrix/client/v3/createRoom', {
room_alias_name: input.alias,
name: input.name,
topic: input.topic,
preset: 'private_chat',
creation_content: input.isSpace ? { type: 'm.space' } : undefined,
initial_state: input.parentSpaceId
? [
{
type: 'm.space.parent',
state_key: input.parentSpaceId,
content: { via: [this.config.serverName], canonical: true },
},
]
: undefined,
});
}
addToSpace(spaceId: string, childRoomId: string): Promise<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(
alias: string,
name: string,
opts: { isSpace?: boolean; parentSpaceId?: string } = {},
): Promise<string> {
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 },
);
}
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 { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { BackofficeModule } from "../backoffice/backoffice.module"; import { BackofficeModule } from "../backoffice/backoffice.module";
import { ChatModule } from "../chat/chat.module";
import { CompaniesModule } from "../companies/companies.module"; import { CompaniesModule } from "../companies/companies.module";
import { NotificationsModule } from "../notifications/notifications.module"; import { NotificationsModule } from "../notifications/notifications.module";
import { Notification } from "./entities/notification.entity"; import { Notification } from "./entities/notification.entity";
@@ -24,6 +25,8 @@ import { WsAuthService } from "./ws-auth.service";
BackofficeModule, BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out) // EmailClientService + SmsClientService (HIGH-priority fan-out)
NotificationsModule, NotificationsModule,
// ChatBridgeService (mirrors BACKOFFICE notifications into chat)
ChatModule,
], ],
controllers: [NotificationInboxController], controllers: [NotificationInboxController],
providers: [ providers: [

View File

@@ -1,4 +1,5 @@
import { import {
NotificationAudience,
NotificationChannels, NotificationChannels,
NotificationChannelsSent, NotificationChannelsSent,
NotificationDto, NotificationDto,
@@ -11,6 +12,7 @@ import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity"; import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { ChatBridgeService } from "../chat/chat-bridge.service";
import { EmailClientService } from "../notifications/email-client.service"; import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service"; import { SmsClientService } from "../notifications/sms-client.service";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto"; import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
@@ -37,6 +39,7 @@ export class NotificationInboxService {
private readonly gateway: NotificationsGateway, private readonly gateway: NotificationsGateway,
private readonly emailClient: EmailClientService, private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService, private readonly smsClient: SmsClientService,
private readonly chatBridge: ChatBridgeService,
@InjectRepository(User) @InjectRepository(User)
private readonly users: Repository<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 * Fan a logical notification out to every resolved recipient: persist one row
* each, push it live over WebSocket, and (for HIGH priority) also queue * 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> { async notify(input: NotifyInput): Promise<void> {
try { try {
const userIds = await this.recipients.resolve(input.recipients); const userIds = await this.recipients.resolve(input.recipients);
if (input.audience === NotificationAudience.BACKOFFICE) {
await this.chatBridge.bridge(input);
}
if (userIds.length === 0) { if (userIds.length === 0) {
this.logger.debug( this.logger.debug(
`notify(${input.type}) resolved 0 recipients — skipped`, `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_positions:view",
"edr_freight_app:hierarchy_employee_assignment:view", "edr_freight_app:hierarchy_employee_assignment:view",
"edr_freight_app:position_types: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 // D. Finance — payments + invoices
export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
perm( perm(
@@ -1641,6 +1647,7 @@ export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [
...REPORT_PERMISSIONS, ...REPORT_PERMISSIONS,
...CUSTOMER_PERMISSIONS, ...CUSTOMER_PERMISSIONS,
...SHIPPING_LINE_PERMISSIONS, ...SHIPPING_LINE_PERMISSIONS,
...CHAT_PERMISSIONS,
...FINANCE_PERMISSIONS, ...FINANCE_PERMISSIONS,
...MILE_PERMISSIONS, ...MILE_PERMISSIONS,
...FLEET_RAIL_PERMISSIONS, ...FLEET_RAIL_PERMISSIONS,
@@ -1872,6 +1879,10 @@ export const FREIGHT_PERMS = {
/** Reject any pending invoice request. */ /** Reject any pending invoice request. */
invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject", invoiceReject: "edr_freight_app:shipping_line_credits:invoice_reject",
}, },
chat: {
view: 'edr_freight_app:chat:view',
sync: 'edr_freight_app:chat:sync',
},
payments: { payments: {
view: "edr_freight_app:payments:view", view: "edr_freight_app:payments:view",
}, },

View File

@@ -115,6 +115,7 @@ import FaydaCallbackPage from "./pages/FaydaCallbackPage";
import { UserManagementRoutes } from "./user-management/route"; import { UserManagementRoutes } from "./user-management/route";
import SetPassword from "./shared/components/SetPassword"; import SetPassword from "./shared/components/SetPassword";
import SupportInboxPage from "./pages/support/SupportInboxPage"; import SupportInboxPage from "./pages/support/SupportInboxPage";
import ChatLaunchPage from "./pages/chat/ChatLaunchPage";
import { import {
APP_TITLE, APP_TITLE,
buildSidebarSections, buildSidebarSections,
@@ -298,6 +299,14 @@ const App = () => {
</RequirePermission> </RequirePermission>
} }
/> />
<Route
path="chat"
element={
<RequirePermission permission={FREIGHT_PERMS.chat.view}>
<ChatLaunchPage />
</RequirePermission>
}
/>
<Route <Route
path="customers" path="customers"
element={ element={

View File

@@ -71,6 +71,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [
subtitle: "Manage your account and signature", 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 // Invoices, Payments, and USD Payments are tabs on one page now
// (FinanceHubPage); the header title itself is set per-tab there. // (FinanceHubPage); the header title itself is set per-tab there.

View File

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

@@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { chatApi } from "./chatApi";
export const CHAT_SSO_KEY = ["chat", "sso"] as const;
/**
* The login_token this resolves to is single-use and expires in 5 minutes
* (Synapse default) — the global `staleTime: 0` (queryClient.ts) already
* means every fresh mount of the launch page refetches rather than reusing
* a possibly-spent link.
*/
export function useChatSso() {
return useQuery({
queryKey: CHAT_SSO_KEY,
queryFn: chatApi.getSsoUrl,
retry: false,
});
}

View File

@@ -33,6 +33,10 @@ export const FREIGHT_PERMS = {
staffUsers: { staffUsers: {
view: "edr_freight_app:staff:users:view", view: "edr_freight_app:staff:users:view",
}, },
chat: {
view: "edr_freight_app:chat:view",
sync: "edr_freight_app:chat:sync",
},
bookings: { bookings: {
view: "edr_freight_app:bookings:view", view: "edr_freight_app:bookings:view",
create: "edr_freight_app:bookings:create", create: "edr_freight_app:bookings:create",

View File

@@ -0,0 +1,62 @@
import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core";
import { MessageSquare, TriangleAlert } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useChatSso } from "@/features/chat/useChatSso";
/**
* Chat itself lives at chat.edr.et (Element), not in this app — this page's
* only job is a fresh one-click sign-in link into it. A real `<a>` (not
* `window.open()` in a click handler) so the browser never treats it as a
* blocked popup, and no iframe: Element's own CSP refuses to be framed.
*/
export default function ChatLaunchPage() {
const { data: url, isLoading, isError, refetch } = useChatSso();
return (
<PageContainer>
<PageHeader title="Chat" subtitle="Internal messaging for EDR staff" />
<Card withBorder radius="md" p="xl">
<Center>
<Stack align="center" gap="md" py="xl">
{isLoading && <Loader />}
{isError && (
<Stack align="center" gap="sm">
<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>
<Button variant="light" onClick={() => refetch()}>
Retry
</Button>
</Stack>
)}
{url && (
<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
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
leftSection={<MessageSquare size={16} />}
>
Open EDR Chat
</Button>
</Stack>
)}
</Stack>
</Center>
</Card>
</PageContainer>
);
}

View File

@@ -116,3 +116,31 @@ services:
env_file: env_file:
- apps/edr-payment-api/.env - apps/edr-payment-api/.env
restart: always restart: always
# Internal employee chat (freight backoffice). No federation, no public
# registration — see infrastructure/matrix/synapse/homeserver.yaml.tmpl.
synapse:
build:
context: infrastructure/matrix/synapse
ports:
- "${SYNAPSE_PORT:-8008}:8008"
env_file:
- infrastructure/matrix/synapse/.env
volumes:
- matrix-data:/data
# Local dev: Postgres runs in the separate docker-compose.db.dev.yml
# project (different docker network) and is only reachable from here via
# the host's published port — see MATRIX_DB_HOST in synapse/.env.example.
extra_hosts:
- "host.docker.internal:host-gateway"
restart: always
element-web:
build:
context: infrastructure/matrix/element
ports:
- "${ELEMENT_WEB_PORT:-8080}:80"
restart: always
volumes:
matrix-data:

View File

@@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1
#
# EDR internal chat web client. Unmodified upstream Element Web + our public,
# non-secret config (homeserver URL, branding) and the SSO handoff page.
# Pin the tag; never float on `latest`.
FROM ghcr.io/element-hq/element-web:v1.11.108
COPY config.json /app/config.json
COPY sso.html /app/sso.html

View File

@@ -0,0 +1,17 @@
{
"default_server_config": {
"m.homeserver": {
"base_url": "https://matrix.edr.et",
"server_name": "matrix.edr.et"
}
},
"brand": "EDR Chat",
"permalink_prefix": "https://chat.edr.et",
"disable_guests": true,
"disable_3pid_login": true,
"disable_custom_urls": true,
"default_theme": "light",
"settingDefaults": {
"UIFeature.registration": false
}
}

View File

@@ -0,0 +1,36 @@
<!doctype html>
<!--
Element only honours a `?loginToken=` on `/` if `mx_sso_hs_url` is already
in localStorage (element-web apps/web/src/Lifecycle.ts attemptTokenLogin,
key defined in apps/web/src/BasePlatform.ts). Normally that key is written
by Element itself at the start of an SSO redirect; freight-api's SSO
handoff skips that redirect (it already knows the homeserver), so this
page seeds the key by hand and forwards straight to the login-token URL.
freight-api's chat-sso.service.ts links here as
https://chat.edr.et/sso.html?t=<login_token>&hs=<homeserver base_url>.
`hs` is passed rather than hardcoded so this file doesn't need to change if
MATRIX_PUBLIC_BASEURL ever does.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Signing in to EDR Chat…</title>
</head>
<body>
<script>
var params = new URLSearchParams(window.location.search);
var token = params.get("t");
var homeserver = params.get("hs");
if (token && homeserver) {
localStorage.setItem("mx_sso_hs_url", homeserver);
window.location.replace(
"/?loginToken=" + encodeURIComponent(token),
);
} else {
document.body.textContent =
"Missing sign-in token. 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,95 @@
# EDR internal chat — Synapse homeserver config.
#
# Rendered to /data/homeserver.yaml at container start by docker-entrypoint.sh
# (envsubst over this template) so secrets come from the runtime env file,
# never baked into the image — same convention as freight-api's .env.
#
# server_name is PERMANENT: it is baked into every user id and event and
# cannot change without wiping the server. Do not repoint this at a
# different value after go-live.
server_name: "${MATRIX_SERVER_NAME}"
public_baseurl: "${MATRIX_PUBLIC_BASEURL}"
pid_file: /data/homeserver.pid
listeners:
- port: 8008
tls: false
type: http
x_forwarded: true
resources:
- names: [client, federation]
compress: false
database:
name: psycopg2
args:
user: "${MATRIX_DB_USER}"
password: "${MATRIX_DB_PASSWORD}"
dbname: "${MATRIX_DB_NAME}"
host: "${MATRIX_DB_HOST}"
port: ${MATRIX_DB_PORT}
cp_min: 5
cp_max: 10
media_store_path: /data/media_store
max_upload_size: 50M
log_config: "/synapse/log.config"
# Internal comms tool: no federation, no open registration, no E2EE-by-default.
# ponytail: E2EE off — turn on per-room (HR/legal) if compliance asks.
federation_domain_whitelist: []
enable_registration: false
encryption_enabled_by_default_for_room_type: "off"
# Employees authenticate via freight-api's SSO handoff, never a Matrix
# password prompt. This is the entire auth story for this deployment.
password_config:
enabled: false
jwt_config:
enabled: true
secret: "${MATRIX_JWT_SECRET}"
algorithm: "HS256"
issuer: "edr-freight-api"
audiences: ["matrix"]
# Matches the `name` claim chat-sso.service.ts puts in the JWT — only read
# on first login (auto-registration), never updates it on later logins.
display_name_claim: "name"
# Consumes the login_token minted by freight-api's SSO endpoint via
# POST /_matrix/client/v1/login/get_token (issued against an existing,
# already-JWT-authenticated session — not a bare password grant).
login_via_existing_session:
enabled: true
require_ui_auth: false
token_timeout: 5m
# Bootstrap-only: used once by ops to register the first admin account
# (register_new_matrix_user against /_synapse/admin/v1/register), whose
# access token becomes MATRIX_ADMIN_TOKEN for freight-api's provisioning
# service. Rotate/remove after bootstrap if desired — nothing else depends
# on shared-secret registration once the admin account exists.
registration_shared_secret: "${MATRIX_REGISTRATION_SHARED_SECRET}"
trusted_key_servers: []
suppress_key_server_warning: true
report_stats: false
# Synapse's default rc_login is sized to defend against internet-facing
# password brute-forcing. That threat doesn't exist on this deployment —
# password login is off (see password_config above), and the only path in
# requires a freight-api-signed JWT — so the default is mostly just
# punishing legitimate rapid logins from the same office/NAT IP or normal
# page-refresh retries. Loosened, not disabled, to keep some ceiling.
rc_login:
address:
per_second: 100
burst_count: 200
account:
per_second: 100
burst_count: 200
failed_attempts:
per_second: 100
burst_count: 200

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

@@ -31,6 +31,11 @@ declare -A SERVICE_ENV_TARGET=(
["passenger-portal"]="apps/edr-passenger-web/portal/.env" ["passenger-portal"]="apps/edr-passenger-web/portal/.env"
["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment-api"]="apps/edr-payment-api/.env" ["payment-api"]="apps/edr-payment-api/.env"
["synapse"]="infrastructure/matrix/synapse/.env"
# element-web has no runtime secrets (its config.json is baked into the
# image), but the sync step still runs unconditionally per service and
# needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose.
["element-web"]="infrastructure/matrix/element/.env"
) )
for service in "$@"; do for service in "$@"; do