From ab734aecc30e210121dd605eb7c60cd50d44d0a9 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Mon, 17 Aug 2026 11:33:26 +0000 Subject: [PATCH] feat(chat): join users to rooms on sign-in --- .../modules/chat/chat-provisioning.service.ts | 59 ++++++++++-- .../src/modules/chat/chat-sso.service.ts | 62 +++++++++--- .../src/modules/chat/matrix.client.spec.ts | 35 +++++++ .../src/modules/chat/matrix.client.ts | 77 +++++++++++++-- .../src/features/chat/useChatSso.ts | 19 ---- .../src/pages/chat/ChatLaunchPage.tsx | 94 +++++++++++-------- docker-compose.yaml | 4 + .../matrix/element/40-element-config.sh | 18 ++++ infrastructure/matrix/element/Dockerfile | 16 +++- .../element/{config.json => config.json.tmpl} | 7 +- infrastructure/matrix/element/manifest.json | 12 +++ infrastructure/matrix/element/sso.html | 47 ++++++---- .../matrix/synapse/homeserver.yaml.tmpl | 18 ++++ scripts/deploy/sync-env-from-env-manager.sh | 6 ++ scripts/deploy/sync-env-from-server.sh | 6 +- 15 files changed, 366 insertions(+), 114 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts delete mode 100644 apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts create mode 100644 infrastructure/matrix/element/40-element-config.sh rename infrastructure/matrix/element/{config.json => config.json.tmpl} (61%) create mode 100644 infrastructure/matrix/element/manifest.json diff --git a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts index 7b748e576..b122d8073 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-provisioning.service.ts @@ -67,7 +67,8 @@ export class ChatProvisioningService { } } - private async currentHolders(): Promise { + /** Every current holder in the unit, or just one person's rows when `userId` is given. */ + private async currentHolders(userId?: string): Promise { return this.dataSource.query( `SELECT p.key AS "positionKey", COALESCE(p.name->>'en', p.key) AS "positionName", @@ -82,11 +83,53 @@ export class ChatProvisioningService { WHERE ep.is_current = true AND e.is_current = true AND o.key = $1 - AND u.key = $2`, - [ORG_KEY, UNIT_KEY], + AND u.key = $2 + ${userId ? 'AND e.user_id = $3' : ''}`, + userId ? [ORG_KEY, UNIT_KEY, userId] : [ORG_KEY, UNIT_KEY], ); } + /** + * Put one person in their rooms right now. + * + * {@link reconcile} is nightly, so without this a new employee's first + * sign-in shows an empty client until 3AM — the SSO handoff creates their + * account but joins them to nothing. Called on every /chat/sso, so it is + * scoped to the one user (a full reconcile per click would be a room-count + * multiple of Matrix calls) and every step is get-or-create. + * + * Someone holding no current position in the unit joins nothing, by the same + * rule the reconcile uses — chat membership follows the org tree. + */ + async joinUserRooms(userId: string, displayName: string): Promise { + const positions = await this.currentHolders(userId); + if (positions.length === 0) return 0; + + const mxid = this.matrix.mxidFor(userId, displayName); + // The JWT login auto-registers too, but that happens after this runs and + // the admin join API 404s on an account that does not exist yet. + await this.matrix.ensureUser(mxid, displayName); + + const spaceId = await this.matrix.ensureRoom(SPACE_ALIAS, 'EDR Freight', { + isSpace: true, + }); + const generalRoomId = await this.matrix.ensureRoom(GENERAL_ALIAS, 'General', { + parentSpaceId: spaceId, + }); + await this.matrix.ensureJoined(generalRoomId, mxid); + + for (const position of positions) { + const roomId = await this.matrix.ensureRoom( + `dept-${position.positionKey}`, + position.positionName, + { parentSpaceId: spaceId }, + ); + await this.matrix.ensureJoined(roomId, mxid); + } + + return positions.length + 1; + } + /** Force-joins additions, kicks+deactivates users no longer entitled anywhere. */ private async syncMembership( roomId: string, @@ -99,7 +142,7 @@ export class ChatProvisioningService { let joined = 0; for (const userId of desiredUserIds) { if (!currentSet.has(userId)) { - await this.matrix.forceJoin(roomId, userId); + await this.matrix.ensureJoined(roomId, userId); joined += 1; } } @@ -126,14 +169,16 @@ export class ChatProvisioningService { parentSpaceId: spaceId, }); - const allUserIds = new Set(holders.map((h) => this.matrix.mxid(h.userId))); + const allUserIds = new Set( + holders.map((h) => this.matrix.mxidFor(h.userId, h.userName)), + ); // Accounts are otherwise only created lazily on first JWT login (see // ChatSsoService) — force-joining someone who has never clicked "Chat" // yet 404s ("User not found") without this. const seenUserIds = new Set(); for (const h of holders) { - const mxid = this.matrix.mxid(h.userId); + const mxid = this.matrix.mxidFor(h.userId, h.userName); if (seenUserIds.has(mxid)) continue; seenUserIds.add(mxid); await this.matrix.ensureUser(mxid, h.userName); @@ -158,7 +203,7 @@ export class ChatProvisioningService { name: h.positionName, userIds: new Set(), }; - entry.userIds.add(this.matrix.mxid(h.userId)); + entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName)); byPosition.set(h.positionKey, entry); } diff --git a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts index 8f9357e10..22edb8e1c 100644 --- a/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts +++ b/apps/edr-freight-api/src/modules/chat/chat-sso.service.ts @@ -1,12 +1,13 @@ -import { Inject, Injectable } from '@nestjs/common'; +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 { MatrixClient } from './matrix.client'; +import { ChatProvisioningService } from './chat-provisioning.service'; +import { MatrixClient, chatLocalpart } from './matrix.client'; -/** Matrix login_tokens are single-use and expire in 5 minutes (Synapse default). */ +/** Long enough for one login call, short enough to be worthless if it leaks. */ const JWT_TTL_SECONDS = 60; function displayName(user: TCurrentUser): string { @@ -24,36 +25,67 @@ function displayName(user: TCurrentUser): string { * * 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. + * 2. Trade that JWT for a real Matrix session. + * 3. Hand the caller a link to Element's sso.html shim, which writes that + * session into localStorage and drops the user straight into Element. + * + * Step 3 used to mint a one-shot login_token and let Element redeem it. That + * path is capped at one request per user per minute by a limiter hardcoded in + * Synapse, so a second click inside a minute returned M_LIMIT_EXCEEDED — and a + * spent token surfaces in Element as "Incorrect username and/or password". + * Element accepts a plaintext token out of localStorage (Lifecycle.ts + * getStoredToken/tryDecryptToken), so handing over the session we already hold + * removes both failure modes and one round-trip. */ @Injectable() export class ChatSsoService { + private readonly logger = new Logger(ChatSsoService.name); + constructor( @Inject(chatConfig.KEY) private readonly config: ConfigType, private readonly matrix: MatrixClient, + private readonly provisioning: ChatProvisioningService, ) {} async getSsoUrl(user: TCurrentUser): Promise<{ url: string }> { const secret = new TextEncoder().encode(this.config.jwtSecret); - const jwt = await new SignJWT({ name: displayName(user) }) + 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(user.id) + .setSubject(chatLocalpart(user.id, name)) .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 session = await this.matrix.loginWithJwt(jwt); - 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() }; + // Session goes in the URL fragment, never the query: a fragment is not sent + // to any server, so the token stays out of Element's access log, and + // sso.html replaces the entry so it does not linger in history either. + const params = new URLSearchParams({ + hs: this.config.publicBaseUrl, + t: session.access_token, + u: session.user_id, + d: session.device_id, + }); + return { url: `${this.config.webUrl}/sso.html#${params.toString()}` }; } } diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts new file mode 100644 index 000000000..ea5faa978 --- /dev/null +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.spec.ts @@ -0,0 +1,35 @@ +import { chatLocalpart } from './matrix.client'; + +describe('chatLocalpart', () => { + it('reads from the name, not the id', () => { + expect( + chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'), + ).toBe('nati-wondi.03f5eb'); + }); + + it('separates two people who share a name', () => { + // Both of these are real dev rows — same name, different employees. + const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA'); + const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA'); + expect(a).not.toBe(b); + }); + + it('is stable for the same person', () => { + const id = '7d798218-09de-47a1-98eb-f61ec44e9280'; + expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod')); + }); + + it('still yields a usable localpart for a name that slugs to nothing', () => { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe( + 'user.7d7982', + ); + }); + + it('only emits characters Matrix accepts in a localpart', () => { + for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) { + expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch( + /^[a-z0-9._=\-/]+$/, + ); + } + }); +}); diff --git a/apps/edr-freight-api/src/modules/chat/matrix.client.ts b/apps/edr-freight-api/src/modules/chat/matrix.client.ts index 01dafb6de..1cd09ac33 100644 --- a/apps/edr-freight-api/src/modules/chat/matrix.client.ts +++ b/apps/edr-freight-api/src/modules/chat/matrix.client.ts @@ -14,6 +14,32 @@ import chatConfig from '../../config/chat.config'; * 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( @@ -21,11 +47,28 @@ export class MatrixClient { private readonly config: ConfigType, ) {} + /** + * Alias localparts go in a URL path segment, so a `/` in one is fatal: + * Synapse decodes the path before routing, and `%2F` splits the request into + * a route that doesn't exist ("M_UNRECOGNIZED"). resolveAlias reads that 404 + * as "no such room" and ensureRoom then tries to create the same broken alias + * on every run. Position keys are `edr_freight_app/opn` shaped, so this hits + * every dept room but the handful whose key happens to be a bare word. + */ + private static aliasSafe(alias: string): string { + return alias.replace(/[^A-Za-z0-9._=-]/g, '-'); + } + /** `@:` — the one place this format is assembled. */ mxid(localpart: string): string { return `@${localpart}:${this.config.serverName}`; } + /** The MXID of a freight user — see {@link chatLocalpart}. */ + mxidFor(userId: string, displayName: string): string { + return this.mxid(chatLocalpart(userId, displayName)); + } + get serverName(): string { return this.config.serverName; } @@ -123,15 +166,13 @@ export class MatrixClient { 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, - ); - } + // 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> { @@ -180,10 +221,11 @@ export class MatrixClient { * on every reconcile run and every bridged notification alike. */ async ensureRoom( - alias: string, + rawAlias: string, name: string, opts: { isSpace?: boolean; parentSpaceId?: string } = {}, ): Promise { + const alias = MatrixClient.aliasSafe(rawAlias); const existing = await this.resolveAlias(`#${alias}:${this.config.serverName}`); if (existing) return existing.room_id; @@ -228,6 +270,21 @@ export class MatrixClient { ); } + /** + * Force-join, treating "already a member" as success. Synapse answers a + * repeat join with 403 `M_FORBIDDEN: " is already in the room."`, which + * is a failure only if you assumed you knew the membership first. Callers + * that just want someone in a room (sign-in, reconcile racing itself) want + * this; the raw 403 tells them nothing they can act on. + */ + async ensureJoined(roomIdOrAlias: string, userId: string): Promise { + try { + await this.forceJoin(roomIdOrAlias, userId); + } catch (err) { + if (!/already in the room/i.test((err as Error).message)) throw err; + } + } + kick(roomId: string, userId: string, reason: string): Promise { return this.request( 'POST', diff --git a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts b/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts deleted file mode 100644 index d25b5f4a1..000000000 --- a/apps/edr-freight-web/backoffice/src/features/chat/useChatSso.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; - -import { chatApi } from "./chatApi"; - -export const CHAT_SSO_KEY = ["chat", "sso"] as const; - -/** - * The login_token this resolves to is single-use and expires in 5 minutes - * (Synapse default) — the global `staleTime: 0` (queryClient.ts) already - * means every fresh mount of the launch page refetches rather than reusing - * a possibly-spent link. - */ -export function useChatSso() { - return useQuery({ - queryKey: CHAT_SSO_KEY, - queryFn: chatApi.getSsoUrl, - retry: false, - }); -} diff --git a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx index 6b51b6512..78f0e7c36 100644 --- a/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/chat/ChatLaunchPage.tsx @@ -1,17 +1,44 @@ -import { Alert, Button, Card, Center, Loader, Stack, Text } from "@mantine/core"; +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 { useChatSso } from "@/features/chat/useChatSso"; +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 fresh one-click sign-in link into it. A real `` (not - * `window.open()` in a click handler) so the browser never treats it as a - * blocked popup, and no iframe: Element's own CSP refuses to be framed. + * 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 { data: url, isLoading, isError, refetch } = useChatSso(); + 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 ( @@ -19,41 +46,30 @@ export default function ChatLaunchPage() {
- {isLoading && } - - {isError && ( - - } - color="red" - title="Couldn't get a sign-in link" - variant="light" - > - Something went wrong reaching chat. Try again. - - - + {state === "error" && ( + } + color="red" + title="Couldn't get a sign-in link" + variant="light" + > + Something went wrong reaching chat. Try again. + )} - {url && ( - - - - Opens EDR Chat in a new tab, already signed in as you. - - - - )} + + + + Opens EDR Chat in a new tab, already signed in as you. + + +
diff --git a/docker-compose.yaml b/docker-compose.yaml index 050afc5df..a3f57ebb5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -140,6 +140,10 @@ services: 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: diff --git a/infrastructure/matrix/element/40-element-config.sh b/infrastructure/matrix/element/40-element-config.sh new file mode 100644 index 000000000..9826a5fc7 --- /dev/null +++ b/infrastructure/matrix/element/40-element-config.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Renders /app/config.json from config.json.tmpl at container start. +# +# The homeserver URL and server_name differ per environment, and config.json is +# read by the browser rather than the build, so baking it into the image would +# mean one image per environment. Dropped into /docker-entrypoint.d, which the +# upstream nginx entrypoint runs (in lexical order) before starting nginx — +# no ENTRYPOINT override, so the image's own startup work still happens. +set -eu + +: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}" +: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}" +: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}" + +envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \ + < /app/config.json.tmpl > /app/config.json + +echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})" diff --git a/infrastructure/matrix/element/Dockerfile b/infrastructure/matrix/element/Dockerfile index 5ab02cd9b..330098af6 100644 --- a/infrastructure/matrix/element/Dockerfile +++ b/infrastructure/matrix/element/Dockerfile @@ -5,5 +5,19 @@ # Pin the tag; never float on `latest`. FROM ghcr.io/element-hq/element-web:v1.11.108 -COPY config.json /app/config.json +COPY config.json.tmpl /app/config.json.tmpl COPY sso.html /app/sso.html +# Replaces the upstream manifest, which names the app "Element" and advertises +# the Play/App Store builds under related_applications. Those apps cannot log +# in here — this deployment has no password login and no SSO provider, only the +# JWT handoff from freight-api — so pointing staff at them is a dead end. +COPY manifest.json /app/manifest.json +COPY 40-element-config.sh /docker-entrypoint.d/40-element-config.sh + +# The image runs as uid 101 (nginx) but ships /app root-owned, so the startup +# hook could not write the rendered config without this. +USER root +RUN chmod +x /docker-entrypoint.d/40-element-config.sh \ + && touch /app/config.json \ + && chown nginx:nginx /app/config.json +USER nginx diff --git a/infrastructure/matrix/element/config.json b/infrastructure/matrix/element/config.json.tmpl similarity index 61% rename from infrastructure/matrix/element/config.json rename to infrastructure/matrix/element/config.json.tmpl index 6f988d3e9..f08900601 100644 --- a/infrastructure/matrix/element/config.json +++ b/infrastructure/matrix/element/config.json.tmpl @@ -1,16 +1,17 @@ { "default_server_config": { "m.homeserver": { - "base_url": "https://matrix.edr.et", - "server_name": "matrix.edr.et" + "base_url": "${MATRIX_PUBLIC_BASEURL}", + "server_name": "${MATRIX_SERVER_NAME}" } }, "brand": "EDR Chat", - "permalink_prefix": "https://chat.edr.et", + "permalink_prefix": "${ELEMENT_PUBLIC_URL}", "disable_guests": true, "disable_3pid_login": true, "disable_custom_urls": true, "default_theme": "light", + "mobile_guide_toast": false, "settingDefaults": { "UIFeature.registration": false } diff --git a/infrastructure/matrix/element/manifest.json b/infrastructure/matrix/element/manifest.json new file mode 100644 index 000000000..75f5f33e6 --- /dev/null +++ b/infrastructure/matrix/element/manifest.json @@ -0,0 +1,12 @@ +{ + "name": "EDR Chat", + "short_name": "EDR Chat", + "display": "standalone", + "theme_color": "#0dbd8b", + "start_url": "index.html", + "icons": [ + { "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" }, + { "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" }, + { "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" } + ] +} diff --git a/infrastructure/matrix/element/sso.html b/infrastructure/matrix/element/sso.html index 59a458fa4..77f378122 100644 --- a/infrastructure/matrix/element/sso.html +++ b/infrastructure/matrix/element/sso.html @@ -1,16 +1,23 @@ @@ -19,17 +26,23 @@ diff --git a/infrastructure/matrix/synapse/homeserver.yaml.tmpl b/infrastructure/matrix/synapse/homeserver.yaml.tmpl index 2f7d59502..efb7372ac 100644 --- a/infrastructure/matrix/synapse/homeserver.yaml.tmpl +++ b/infrastructure/matrix/synapse/homeserver.yaml.tmpl @@ -42,6 +42,24 @@ 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: diff --git a/scripts/deploy/sync-env-from-env-manager.sh b/scripts/deploy/sync-env-from-env-manager.sh index 6a405ab28..0835f86d9 100644 --- a/scripts/deploy/sync-env-from-env-manager.sh +++ b/scripts/deploy/sync-env-from-env-manager.sh @@ -29,6 +29,12 @@ declare -A SERVICE_ENV_TARGET=( ["passenger_portal"]="apps/edr-passenger-web/portal/.env" ["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment_api"]="apps/edr-payment-api/.env" + ["synapse"]="infrastructure/matrix/synapse/.env" + # element_web holds no secrets, but its config.json is rendered at container + # start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL + # (see infrastructure/matrix/element), so it needs an env file like the rest — + # plus the PORT line every service env is required to carry. + ["element_web"]="infrastructure/matrix/element/.env" ) for service in "$@"; do diff --git a/scripts/deploy/sync-env-from-server.sh b/scripts/deploy/sync-env-from-server.sh index 1e9a1ff52..4fb7fbe6b 100644 --- a/scripts/deploy/sync-env-from-server.sh +++ b/scripts/deploy/sync-env-from-server.sh @@ -32,9 +32,9 @@ declare -A SERVICE_ENV_TARGET=( ["passenger-backoffice"]="apps/edr-passenger-web/backoffice/.env" ["payment-api"]="apps/edr-payment-api/.env" ["synapse"]="infrastructure/matrix/synapse/.env" - # element-web has no runtime secrets (its config.json is baked into the - # image), but the sync step still runs unconditionally per service and - # needs a PORT= line to compute ELEMENT_WEB_PORT for docker compose. + # element-web 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" )