mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #1324 from Tria-plc/freight/feat/element-chat
Freight/feat/element chat
This commit is contained in:
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -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"
|
||||
;;
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
59
apps/edr-freight-api/src/config/chat.config.ts
Normal file
59
apps/edr-freight-api/src/config/chat.config.ts
Normal 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!,
|
||||
};
|
||||
});
|
||||
73
apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
Normal file
73
apps/edr-freight-api/src/modules/chat/chat-bridge.service.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
91
apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
Normal file
91
apps/edr-freight-api/src/modules/chat/chat-sso.service.ts
Normal 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()}` };
|
||||
}
|
||||
}
|
||||
35
apps/edr-freight-api/src/modules/chat/chat.controller.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/chat.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
16
apps/edr-freight-api/src/modules/chat/chat.module.ts
Normal file
16
apps/edr-freight-api/src/modules/chat/chat.module.ts
Normal 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 {}
|
||||
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal file
35
apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts
Normal 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._=\-/]+$/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
320
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal file
320
apps/edr-freight-api/src/modules/chat/matrix.client.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
},
|
||||
|
||||
13
apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts
Normal file
13
apps/edr-freight-web/backoffice/src/features/chat/chatApi.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
18
infrastructure/matrix/element/40-element-config.sh
Normal file
18
infrastructure/matrix/element/40-element-config.sh
Normal 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})"
|
||||
23
infrastructure/matrix/element/Dockerfile
Normal file
23
infrastructure/matrix/element/Dockerfile
Normal 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
|
||||
18
infrastructure/matrix/element/config.json.tmpl
Normal file
18
infrastructure/matrix/element/config.json.tmpl
Normal 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
|
||||
}
|
||||
}
|
||||
12
infrastructure/matrix/element/manifest.json
Normal file
12
infrastructure/matrix/element/manifest.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
49
infrastructure/matrix/element/sso.html
Normal file
49
infrastructure/matrix/element/sso.html
Normal 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>
|
||||
15
infrastructure/matrix/synapse/Dockerfile
Normal file
15
infrastructure/matrix/synapse/Dockerfile
Normal 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"]
|
||||
20
infrastructure/matrix/synapse/docker-entrypoint.sh
Normal file
20
infrastructure/matrix/synapse/docker-entrypoint.sh
Normal 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 "$@"
|
||||
113
infrastructure/matrix/synapse/homeserver.yaml.tmpl
Normal file
113
infrastructure/matrix/synapse/homeserver.yaml.tmpl
Normal 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
|
||||
25
infrastructure/matrix/synapse/log.config
Normal file
25
infrastructure/matrix/synapse/log.config
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user