feat(chat): join users to rooms on sign-in

This commit is contained in:
Nathnael
2026-08-17 11:33:26 +00:00
parent 00bd1250ee
commit ab734aecc3
15 changed files with 366 additions and 114 deletions

View File

@@ -67,7 +67,8 @@ export class ChatProvisioningService {
}
}
private async currentHolders(): Promise<PositionHolder[]> {
/** 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",
@@ -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<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,
@@ -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<string>();
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<string>(),
};
entry.userIds.add(this.matrix.mxid(h.userId));
entry.userIds.add(this.matrix.mxidFor(h.userId, h.userName));
byPosition.set(h.positionKey, entry);
}

View File

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

View File

@@ -0,0 +1,35 @@
import { chatLocalpart } from './matrix.client';
describe('chatLocalpart', () => {
it('reads from the name, not the id', () => {
expect(
chatLocalpart('03f5eb9e-23a0-4413-8d98-8de4b98b1be2', 'Nati Wondi'),
).toBe('nati-wondi.03f5eb');
});
it('separates two people who share a name', () => {
// Both of these are real dev rows — same name, different employees.
const a = chatLocalpart('11111111-1111-4111-8111-111111111111', 'MARKOS REGASA');
const b = chatLocalpart('22222222-2222-4222-8222-222222222222', 'Markos REGASA');
expect(a).not.toBe(b);
});
it('is stable for the same person', () => {
const id = '7d798218-09de-47a1-98eb-f61ec44e9280';
expect(chatLocalpart(id, 'Naod')).toBe(chatLocalpart(id, 'Naod'));
});
it('still yields a usable localpart for a name that slugs to nothing', () => {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', 'ናኦድ')).toBe(
'user.7d7982',
);
});
it('only emits characters Matrix accepts in a localpart', () => {
for (const name of ['Mubarek Jemal Hassen', "N'gozi O_Brien", 'ናኦድ', 'José']) {
expect(chatLocalpart('7d798218-09de-47a1-98eb-f61ec44e9280', name)).toMatch(
/^[a-z0-9._=\-/]+$/,
);
}
});
});

View File

@@ -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<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;
}
@@ -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<string> {
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: "<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',

View File

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

View File

@@ -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 `<a>` (not
* `window.open()` in a click handler) so the browser never treats it as a
* blocked popup, and no iframe: Element's own CSP refuses to be framed.
* 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 (
<PageContainer>
@@ -19,41 +46,30 @@ export default function ChatLaunchPage() {
<Card withBorder radius="md" p="xl">
<Center>
<Stack align="center" gap="md" py="xl">
{isLoading && <Loader />}
{isError && (
<Stack align="center" gap="sm">
<Alert
icon={<TriangleAlert size={18} />}
color="red"
title="Couldn't get a sign-in link"
variant="light"
>
Something went wrong reaching chat. Try again.
</Alert>
<Button variant="light" onClick={() => refetch()}>
Retry
</Button>
</Stack>
{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>
)}
{url && (
<Stack align="center" gap="sm">
<MessageSquare size={40} strokeWidth={1.5} />
<Text c="dimmed" ta="center" maw={360}>
Opens EDR Chat in a new tab, already signed in as you.
</Text>
<Button
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
leftSection={<MessageSquare size={16} />}
>
Open EDR Chat
</Button>
</Stack>
)}
<Stack 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>

View File

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

View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Renders /app/config.json from config.json.tmpl at container start.
#
# The homeserver URL and server_name differ per environment, and config.json is
# read by the browser rather than the build, so baking it into the image would
# mean one image per environment. Dropped into /docker-entrypoint.d, which the
# upstream nginx entrypoint runs (in lexical order) before starting nginx —
# no ENTRYPOINT override, so the image's own startup work still happens.
set -eu
: "${MATRIX_PUBLIC_BASEURL:?MATRIX_PUBLIC_BASEURL is required}"
: "${MATRIX_SERVER_NAME:?MATRIX_SERVER_NAME is required}"
: "${ELEMENT_PUBLIC_URL:?ELEMENT_PUBLIC_URL is required}"
envsubst '${MATRIX_PUBLIC_BASEURL} ${MATRIX_SERVER_NAME} ${ELEMENT_PUBLIC_URL}' \
< /app/config.json.tmpl > /app/config.json
echo "element-config: homeserver ${MATRIX_PUBLIC_BASEURL} (${MATRIX_SERVER_NAME})"

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
{
"name": "EDR Chat",
"short_name": "EDR Chat",
"display": "standalone",
"theme_color": "#0dbd8b",
"start_url": "index.html",
"icons": [
{ "src": "/vector-icons/150.png", "sizes": "150x150", "type": "image/png" },
{ "src": "/vector-icons/300.png", "sizes": "300x300", "type": "image/png" },
{ "src": "/vector-icons/1024.png", "sizes": "1024x1024", "type": "image/png" }
]
}

View File

@@ -1,16 +1,23 @@
<!doctype html>
<!--
Element only honours a `?loginToken=` on `/` if `mx_sso_hs_url` is already
in localStorage (element-web apps/web/src/Lifecycle.ts attemptTokenLogin,
key defined in apps/web/src/BasePlatform.ts). Normally that key is written
by Element itself at the start of an SSO redirect; freight-api's SSO
handoff skips that redirect (it already knows the homeserver), so this
page seeds the key by hand and forwards straight to the login-token URL.
Session handoff from freight-api into Element.
freight-api's chat-sso.service.ts links here as
https://chat.edr.et/sso.html?t=<login_token>&hs=<homeserver base_url>.
`hs` is passed rather than hardcoded so this file doesn't need to change if
MATRIX_PUBLIC_BASEURL ever does.
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>
@@ -19,17 +26,23 @@
</head>
<body>
<script>
var params = new URLSearchParams(window.location.search);
var token = params.get("t");
var params = new URLSearchParams(window.location.hash.slice(1));
var homeserver = params.get("hs");
if (token && homeserver) {
localStorage.setItem("mx_sso_hs_url", homeserver);
window.location.replace(
"/?loginToken=" + encodeURIComponent(token),
);
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 token. Go back to the EDR backoffice and click Chat again.";
"Missing sign-in details. Go back to the EDR backoffice and click Chat again.";
}
</script>
</body>

View File

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

View File

@@ -29,6 +29,12 @@ declare -A SERVICE_ENV_TARGET=(
["passenger_portal"]="apps/edr-passenger-web/portal/.env"
["passenger_backoffice"]="apps/edr-passenger-web/backoffice/.env"
["payment_api"]="apps/edr-payment-api/.env"
["synapse"]="infrastructure/matrix/synapse/.env"
# element_web holds no secrets, but its config.json is rendered at container
# start from MATRIX_PUBLIC_BASEURL / MATRIX_SERVER_NAME / ELEMENT_PUBLIC_URL
# (see infrastructure/matrix/element), so it needs an env file like the rest —
# plus the PORT line every service env is required to carry.
["element_web"]="infrastructure/matrix/element/.env"
)
for service in "$@"; do

View File

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